flozi00 commited on
Commit
0020de5
·
verified ·
1 Parent(s): d593c17

traceforge: pipeline behind a Gradio Space, keyed by content hash

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. BUILD.json +7 -0
  2. README.md +115 -6
  3. app.py +24 -0
  4. data/dataset/dataset_card.json +167 -0
  5. data/space/reference_census.json +23 -0
  6. guidelines/IDEAL_TRACE_GUIDELINES.md +230 -0
  7. requirements.txt +3 -0
  8. traceforge/__init__.py +16 -0
  9. traceforge/__pycache__/__init__.cpython-314.pyc +0 -0
  10. traceforge/__pycache__/config.cpython-314.pyc +0 -0
  11. traceforge/__pycache__/trace.cpython-314.pyc +0 -0
  12. traceforge/audit.py +272 -0
  13. traceforge/benchmark.py +811 -0
  14. traceforge/chunker.py +90 -0
  15. traceforge/classify.py +394 -0
  16. traceforge/cli.py +0 -0
  17. traceforge/config.py +276 -0
  18. traceforge/defects.py +186 -0
  19. traceforge/difficulty.py +66 -0
  20. traceforge/enrich.py +334 -0
  21. traceforge/export.py +564 -0
  22. traceforge/grounding.py +256 -0
  23. traceforge/identity.py +131 -0
  24. traceforge/improver/__init__.py +4 -0
  25. traceforge/improver/digest_prompts.py +105 -0
  26. traceforge/improver/harness.py +850 -0
  27. traceforge/improver/longtrace.py +182 -0
  28. traceforge/improver/prompt.py +241 -0
  29. traceforge/improver/review.py +231 -0
  30. traceforge/improver/verify.py +210 -0
  31. traceforge/live.py +508 -0
  32. traceforge/progress.py +194 -0
  33. traceforge/readers/__init__.py +11 -0
  34. traceforge/readers/_common.py +184 -0
  35. traceforge/readers/claude.py +531 -0
  36. traceforge/readers/codex.py +637 -0
  37. traceforge/readers/hermes.py +319 -0
  38. traceforge/readers/nemotron.py +491 -0
  39. traceforge/space/__init__.py +23 -0
  40. traceforge/space/__pycache__/__init__.cpython-314.pyc +0 -0
  41. traceforge/space/__pycache__/hashing.cpython-314.pyc +0 -0
  42. traceforge/space/__pycache__/ingest.cpython-314.pyc +0 -0
  43. traceforge/space/__pycache__/pipeline.cpython-314.pyc +0 -0
  44. traceforge/space/__pycache__/stats.cpython-314.pyc +0 -0
  45. traceforge/space/__pycache__/store.cpython-314.pyc +0 -0
  46. traceforge/space/app.py +465 -0
  47. traceforge/space/hashing.py +214 -0
  48. traceforge/space/ingest.py +175 -0
  49. traceforge/space/jobs.py +118 -0
  50. traceforge/space/pipeline.py +239 -0
BUILD.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "built_at": "2026-10-26T22:04:33",
3
+ "commit": "6bc868a",
4
+ "files": 48,
5
+ "with_store": false,
6
+ "with_card": true
7
+ }
README.md CHANGED
@@ -1,13 +1,122 @@
1
  ---
2
- title: Traceforge
3
- emoji: 😻
4
- colorFrom: gray
5
- colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.30.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: traceforge
3
+ emoji: 🧭
4
+ colorFrom: indigo
5
+ colorTo: green
6
  sdk: gradio
7
  sdk_version: 6.30.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: other
11
+ short_description: Turn real agent traces into ideal-world references
12
  ---
13
 
14
+ # traceforge
15
+
16
+ Turn a real agent trace into its **ideal-world reference**: the same work,
17
+ with the dead ends removed, gated for structure and grounding.
18
+
19
+ This Space is the portable face of the traceforge pipeline. The pipeline
20
+ itself is unchanged — triage labels every message, a windowed rewriter
21
+ produces the ideal path, and a judge admits only traces that pass the six
22
+ pillars. The UI hides that machinery behind three actions.
23
+
24
+ ## Submit a trace
25
+
26
+ Upload a JSON/JSONL trace (traceforge native, OpenAI chat, or a bare message
27
+ list) or paste it. The app computes a **content hash** of the seed and
28
+ queues it.
29
+
30
+ **One seed, one output.** The hash covers the conversation content — message
31
+ text, tool calls and the set of tools the agent had — and excludes session
32
+ ids, timestamps, cwd and tool-call ids. Submitting the same trace twice
33
+ returns the existing output instead of creating a second one. That is what
34
+ makes the output dataset a set, not a log.
35
+
36
+ ## Sponsor a dataset
37
+
38
+ Upload a JSONL of traces. Every row is hashed; new seeds are queued and
39
+ attributed to the sponsor name you give, known seeds are skipped. Processed
40
+ rows join the public output dataset. The dashboard shows the sponsor
41
+ leaderboard.
42
+
43
+ ## Dashboard
44
+
45
+ Public statistics over this Space's corpus and, when bundled, the reference
46
+ corpus: seeds, outputs, queue, failure rate, task mix, languages, runtime
47
+ distribution, and **time sensitivity**.
48
+
49
+ ## Time sensitivity
50
+
51
+ A trace whose correct answer is a fact that changes over time is a bad
52
+ training target when the agent had no way to look that fact up. Train on it
53
+ and the model learns to *assert* the fact from its weights — "the current
54
+ president is X" — which is wrong the moment X changes.
55
+
56
+ The same question is a good target when the agent had a retrieval tool. The
57
+ ideal path calls the tool and answers from its result, so what the model
58
+ imitates is the procedure (search, read, answer), not the fact. The
59
+ procedure does not decay.
60
+
61
+ | class | meaning |
62
+ |---|---|
63
+ | `time_sensitive` | temporal fact, no retrieval tool — the answer decays |
64
+ | `time_anchored` | temporal fact, but a retrieval tool can fetch it |
65
+ | `not_time_sensitive` | no time-dependent fact in the task |
66
+
67
+ `time_anchored` carries a sub-flag: whether the tool was actually **called**.
68
+ A temporal task with a search tool available but never used is anchored in
69
+ principle — the ideal rewrite should make the agent call it — but the raw
70
+ trace answered from weights, so it needs a rewrite rather than a pass.
71
+
72
+ The detector is deliberately conservative: a false `time_sensitive` throws
73
+ away a good trace, so a pattern must name the **fact** (an officeholder, a
74
+ price, a released version, the weather), not just a temporal word. "The
75
+ current branch" and "the latest fix round" are local artifacts of the
76
+ session, not decaying facts.
77
+
78
+ ## Configuration
79
+
80
+ Set these as Space secrets / variables:
81
+
82
+ | variable | meaning |
83
+ |---|---|
84
+ | `TRACEFORGE_BASE_URL` | OpenAI-compatible endpoint (`…/v1`) |
85
+ | `TRACEFORGE_LLM_KEY` | bearer token |
86
+ | `TRACEFORGE_MODEL` | writer/judge model id |
87
+ | `TRACEFORGE_TRIAGE_MODEL` | cheap labeler (default: `TRACEFORGE_MODEL`) |
88
+ | `TRACEFORGE_JUDGE_MODEL` | judge override (default: `TRACEFORGE_MODEL`) |
89
+ | `TRACEFORGE_DATA_DIR` | store root (default `./data/space`) |
90
+ | `TRACEFORGE_SPACE_WORKERS` | parallel pipeline workers (default 1) |
91
+
92
+ Without an endpoint the app still starts and the dashboard still works; the
93
+ Submit tab reports that the pipeline is not configured instead of hanging.
94
+
95
+ ## Storage
96
+
97
+ The store is content-addressed on disk:
98
+
99
+ ```
100
+ data/space/index.jsonl append-only journal (last row per hash wins)
101
+ data/space/seeds/<hash>.json the seed exactly as submitted
102
+ data/space/outputs/<hash>.json the improved trace
103
+ ```
104
+
105
+ A Hugging Face Space's disk is ephemeral unless persistent storage is
106
+ attached. For a durable public corpus, mount persistent storage at
107
+ `/data` and set `TRACEFORGE_DATA_DIR=/data/space`, or sync the store to a
108
+ dataset repo.
109
+
110
+ ## Local use
111
+
112
+ ```bash
113
+ pip install -e ".[webui]"
114
+ python app.py # http://127.0.0.1:7860
115
+ traceforge space serve --port 7860
116
+ traceforge space hash trace.json # print the seed hash
117
+ traceforge space stats # store census
118
+ ```
119
+
120
+ ## License
121
+
122
+ Private research corpus. All rights reserved.
app.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Spaces entry point.
2
+
3
+ The Space runs this file. Everything it needs is in `traceforge/space/`;
4
+ configuration comes from Space secrets / variables:
5
+
6
+ TRACEFORGE_BASE_URL OpenAI-compatible endpoint (…/v1)
7
+ TRACEFORGE_LLM_KEY bearer token
8
+ TRACEFORGE_MODEL writer/judge model id
9
+ TRACEFORGE_TRIAGE_MODEL cheap labeler (default: TRACEFORGE_MODEL)
10
+ TRACEFORGE_DATA_DIR store root (default: ./data/space)
11
+
12
+ Locally: `python app.py` (or `traceforge space serve`).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+
18
+ from traceforge.space.app import CSS, build_app
19
+
20
+ demo = build_app()
21
+ demo.queue(default_concurrency_limit=4)
22
+
23
+ if __name__ == "__main__" or os.environ.get("SPACE_ID"):
24
+ demo.launch(show_error=True, css=CSS)
data/dataset/dataset_card.json ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "traces": 3652,
3
+ "dedup": {
4
+ "removed": 1,
5
+ "pct": 0.0,
6
+ "key": "(task, tool-names + sha1(args))"
7
+ },
8
+ "languages": {
9
+ "en": 3444,
10
+ "de": 208
11
+ },
12
+ "agent_roles": {
13
+ "subagent": 278,
14
+ "orchestrator": 11
15
+ },
16
+ "tasks": {
17
+ "review": 1118,
18
+ "debug": 682,
19
+ "implement": 620,
20
+ "research": 468,
21
+ "other": 293,
22
+ "test": 165,
23
+ "ops": 108,
24
+ "data-analysis": 86,
25
+ "creative": 54,
26
+ "refactor": 44,
27
+ "office-docs": 14
28
+ },
29
+ "messages": {
30
+ "total": 112327,
31
+ "by_role": {
32
+ "assistant": 54700,
33
+ "tool": 53265,
34
+ "user": 4070,
35
+ "system": 292
36
+ },
37
+ "per_trace": {
38
+ "mean": 30.8,
39
+ "median": 20,
40
+ "p90": 64,
41
+ "max": 252
42
+ }
43
+ },
44
+ "tool_calls": {
45
+ "total": 53265,
46
+ "per_trace": {
47
+ "mean": 14.6,
48
+ "median": 9,
49
+ "p90": 32,
50
+ "max": 125
51
+ },
52
+ "traces_with_none": 1,
53
+ "distinct_tools": 553,
54
+ "top_tools": {
55
+ "Bash": 14327,
56
+ "exec": 13084,
57
+ "terminal": 3813,
58
+ "read_file": 3008,
59
+ "Read": 2085,
60
+ "search_files": 1837,
61
+ "execute_code": 1601,
62
+ "tool_call": 1512,
63
+ "Edit": 1497,
64
+ "skill_view": 918,
65
+ "Grep": 917,
66
+ "patch": 758,
67
+ "unlock_discoverable_agent_tool": 457,
68
+ "call_discoverable_agent_tool": 457,
69
+ "wait": 436
70
+ }
71
+ },
72
+ "user_turns": {
73
+ "per_trace": {
74
+ "mean": 1.1,
75
+ "median": 1,
76
+ "p90": 1,
77
+ "max": 18
78
+ },
79
+ "single_turn_traces": 3352,
80
+ "multi_turn_traces": 300
81
+ },
82
+ "tokens": {
83
+ "method": "chars/4 (prose proxy). Calibrated against a real tokenizer on this corpus: chars/4 runs ~13% low (median 21.4k est vs 24.5k actual), so scale by ~1.15 for a central estimate; tool output is code/JSON and tokenizes denser than prose.",
84
+ "budget": 131072,
85
+ "chars_total": 323084312,
86
+ "est_total": 80769721,
87
+ "per_trace": {
88
+ "mean": 22116.6,
89
+ "median": 16996,
90
+ "p90": 53126,
91
+ "max": 119767
92
+ },
93
+ "fits_budget": {
94
+ "4096": {
95
+ "traces": 1043,
96
+ "pct": 28.6
97
+ },
98
+ "8192": {
99
+ "traces": 1218,
100
+ "pct": 33.4
101
+ },
102
+ "16384": {
103
+ "traces": 1773,
104
+ "pct": 48.5
105
+ },
106
+ "65536": {
107
+ "traces": 3570,
108
+ "pct": 97.8
109
+ },
110
+ "131072": {
111
+ "traces": 3652,
112
+ "pct": 100.0
113
+ }
114
+ }
115
+ },
116
+ "split": null,
117
+ "supervision": {
118
+ "contract": "loss on assistant spans only; tool/user/system spans are masked context",
119
+ "chars_by_role": {
120
+ "user": 4777684,
121
+ "assistant": 41191827,
122
+ "tool": 276324543,
123
+ "system": 790258
124
+ },
125
+ "assistant_share_pct": 12.7,
126
+ "tool_share_pct": 85.5,
127
+ "recovery": {
128
+ "traces": 47,
129
+ "pct": 1.3,
130
+ "cap_pct": 15.0,
131
+ "over_cap": false,
132
+ "method": "verified label: exactly one environment-caused failure (flaky endpoint, missing dependency, exhausted resource) followed by exactly one corrective step; model-caused errors and retry loops disqualify (docs/DATA_QUALITY.md §4.1)"
133
+ }
134
+ },
135
+ "difficulty": {
136
+ "tool_calls": {
137
+ "mean": 14.6,
138
+ "median": 9,
139
+ "p90": 32,
140
+ "max": 125
141
+ },
142
+ "messages": {
143
+ "mean": 30.8,
144
+ "median": 20,
145
+ "p90": 64,
146
+ "max": 252
147
+ },
148
+ "est_tokens": {
149
+ "mean": 22116.6,
150
+ "median": 16996,
151
+ "p90": 53126,
152
+ "max": 119767
153
+ },
154
+ "bands": {
155
+ "calls_lt_10": 1916,
156
+ "calls_10_24": 1149,
157
+ "calls_25_49": 450,
158
+ "calls_ge_50": 137
159
+ }
160
+ },
161
+ "notes": [
162
+ "identity/refusal burns gated (traceforge/identity.py)",
163
+ "clip markers, template leaks, permission-ask finals gated",
164
+ "residual-defect estimate ~5% (selfhosted cross-exam, triaged)",
165
+ "reasoning channel dropped per message (keep_reasoning=False)"
166
+ ]
167
+ }
data/space/reference_census.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "key": {
3
+ "mtime": 1793044902,
4
+ "size": 406218197
5
+ },
6
+ "census": {
7
+ "total": 3652,
8
+ "classes": {
9
+ "time_sensitive": 13,
10
+ "time_anchored": 12,
11
+ "not_time_sensitive": 3627
12
+ },
13
+ "labels": {
14
+ "time_sensitive": "answer decays: temporal fact, no retrieval tool",
15
+ "time_anchored": "temporal fact, but a retrieval tool can fetch it",
16
+ "not_time_sensitive": "no time-dependent fact in the task"
17
+ },
18
+ "needs_rewrite": 0,
19
+ "time_sensitive_share": 0.0036,
20
+ "source": "data/dataset/train.jsonl",
21
+ "available": true
22
+ }
23
+ }
guidelines/IDEAL_TRACE_GUIDELINES.md ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ideal-Trace Guidelines — v0.3
2
+
3
+ > **Authoritative spec for what an "ideal trace" looks like.** Read at runtime
4
+ > by the improver, digest, long-trace and review stages. The six quality
5
+ > pillars (§3) are owner-decided and binding. §4 states the training-signal
6
+ > contract that makes the pillars necessary; §6 scopes what this reference is
7
+ > for (evaluation, not training). Evidence base: `docs/DATA_QUALITY.md`.
8
+ >
9
+ > **Integrity rule:** this file is a spec, not a notebook. Keep it under
10
+ > 24 KB. Never paste a worked example, a trace, or your own reasoning into it
11
+ > — examples live in `data/traces/improved/`. `tests/test_guidelines.py`
12
+ > enforces this.
13
+
14
+ ## 1. Purpose
15
+
16
+ An ideal trace is the **ideal-world execution of the same task**: same intent,
17
+ same real environment, flawless execution. It is NOT a re-execution — it is a
18
+ rewrite of the recorded trace, using the trace's own tool outputs as the only
19
+ evidence about the world.
20
+
21
+ The ideal trace is the **reference standard** the benchmark scores a model
22
+ against: the less the pipeline has to edit a model's trace to reach it, the
23
+ better the model. The training-signal contract in §4 is retained because the
24
+ reference must also be a valid target if the corpus is reused for SFT — but
25
+ the official scope is evaluation, not training.
26
+
27
+ ## 2. Stable structural rules (hard requirements)
28
+
29
+ 1. **Preserve the task intent.** The first user message carries the task. The
30
+ ideal trace solves *that* task. When the source trace starts with a handoff
31
+ artifact — a Hermes `[CONTEXT COMPACTION …]` row, or a Claude Code "This
32
+ session is being continued…" summary row — the first REAL user task message
33
+ defines the intent: reproduce it verbatim and byte-exact as the ideal
34
+ trace's opening message.
35
+ 2. **Tool-call pairing.** Every assistant tool_call has exactly one matching
36
+ tool result; every tool result references an existing call; tool arguments
37
+ are valid JSON strings.
38
+ 3. **Message shape.** user / assistant(+calls) / tool / … / final assistant.
39
+ The last message is the assistant's final answer. No two consecutive user
40
+ rows, no two consecutive text-only assistant rows.
41
+ 4. **Metadata is set by the harness**, not by the improver.
42
+ 5. **No scaffold leakage.** Never copy prompt scaffolding, clip markers
43
+ (`…[+N chars clipped]`), compaction banners, or harness self-talk into the
44
+ ideal trace.
45
+
46
+ ## 3. The six quality pillars (owner-decided, binding)
47
+
48
+ **P1 — Efficient but deep reasoning.**
49
+ Reasoning is sharp and high-signal: the key decision logic per turn (why this
50
+ call, what the result implies, what to verify next). No filler, no restating
51
+ the obvious, no rambling. Depth where it matters (diagnoses, trade-offs,
52
+ verification logic) — silence where it doesn't. **Answer focus:** every
53
+ answer, final or mid-trace, gives exactly what its question asked for. A
54
+ follow-up gets only the new piece; never re-present facts the user already
55
+ received. Over-answering is a P1 violation.
56
+
57
+ **Deliver the substance, not a description of it.** The final answer must
58
+ present the concrete result the task asked for — the actual numbers, names,
59
+ paths, values, or code from the tool evidence — not a summary of what was
60
+ retrieved. "I pulled the nutrition data and the recipe" is poor; the data and
61
+ the recipe ARE the answer. Naming the work instead of showing its result fails
62
+ the task even when every tool call was correct.
63
+
64
+ **P2 — No committed mistakes (mine the negative signal first).**
65
+ Before writing, identify every mistake in the real trace: failed tool calls
66
+ (error results), retry loops, redundant calls, wrong turns that later needed
67
+ correction, and USER FEEDBACK (corrections, re-asks, complaints). The ideal
68
+ trace commits none of them: the correct route is taken the first time. Where
69
+ the real agent repaired itself, the ideal trace shows only the correct action
70
+ — not the failure, not the repair.
71
+ *Why:* the assistant turns are the loss-bearing target. A mistake in the
72
+ target is imitated; a mistake in the context is not. See §4 for the one
73
+ narrow exception (environment-caused failures).
74
+
75
+ **P3 — Perfect tool calls, only when really needed.**
76
+ Every call is necessary, correctly parameterized, and the natural next step.
77
+ Independent calls are batched in parallel.
78
+
79
+ **Batching is structural, not stylistic.** When the real trace issues two
80
+ calls in separate assistant turns but the second does not consume the first's
81
+ output, the ideal path issues them in ONE assistant turn carrying both
82
+ `tool_calls`, with the two tool results following in the same order. Do not
83
+ preserve the source's one-call-per-turn rhythm — that rhythm is the defect.
84
+ Measured 2026-10-25: 75 of 621 repair-exhausted failures are exactly this
85
+ shape. A duplicate call (same tool, same arguments, same turn) is never
86
+ batched; it is dropped. No exploratory calls, no re-reads
87
+ of what is already in context, no call whose result the narrative doesn't
88
+ use. When the ideal path makes the SAME call as the real trace, the tool
89
+ result must be the real trace's output — verbatim or a faithful compression
90
+ of it — never a plausible re-simulation of what the call might have printed.
91
+
92
+ **P4 — Fact-driven, no hallucinations (two layers, both binding).**
93
+ Layer 1 (trace-internal): reuse the real trace's tool outputs verbatim
94
+ wherever the ideal path makes the same call; never invent files, commands, or
95
+ results; no fake success — the final answer is grounded in real evidence from
96
+ the trace. Layer 2 (external world): statements about the outside world
97
+ (library versions, API behavior, dates, technical facts) must be either (a)
98
+ taken from the trace's own evidence or (b) kept out unless you are certain
99
+ they are correct. The harness fact-checks external claims with live web
100
+ searches after you finish — unsupported claims send the trace back.
101
+ **Construction order:** decide the ideal call path → copy each result
102
+ byte-verbatim from `outputs.jsonl` → only then write reasoning and the final
103
+ answer around those real results. A tool result you cannot quote is not
104
+ writable: reroute the path or drop the claim. Stitching individually-plausible
105
+ fragments is fabrication and is the single most common rejection.
106
+
107
+ **P5 — Perfect grammar and language quality, single language.**
108
+ One language per trace: the language of the original first user message. No
109
+ code-switching, no stray fragments in other languages. Assistant prose is
110
+ clean, grammatically perfect, professionally concise. The user message is kept
111
+ verbatim — realism of the input matters more than its typos. Never open by
112
+ restating the brief: the first assistant turn must do work (a tool call, a
113
+ decision, a finding), not echo the user's own text back at them.
114
+
115
+ **P6 — No refusals.**
116
+ The ideal agent completes the task without refusing. If the source task
117
+ genuinely cannot be completed (harmful or impossible), do NOT write an ideal
118
+ version: reply starting with `REJECTED:` plus a one-line reason, and write no
119
+ output file.
120
+
121
+ ## 4. Training-signal contract (why the pillars are shaped this way)
122
+
123
+ The corpus is consumed by SFT: loss is computed on **assistant target tokens
124
+ only**; tool results, user turns and system prompts are masked context. The
125
+ model imitates what is in the target span.
126
+
127
+ > **Failures may be context. Failures must never be target.**
128
+
129
+ Therefore:
130
+
131
+ 1. **A model-caused error must never appear in an assistant turn** — not as
132
+ "I'll try X", not as a failed call, not as a self-correction narrative.
133
+ Wrong tool name, wrong arguments, wrong path, bad reasoning: route around
134
+ it. This is P2.
135
+ 2. **An environment-caused error may appear as a tool result** (context), with
136
+ the correct adaptation in the next assistant turn. This is the only
137
+ exception to P2, and it exists because a corpus of only first-try-clean
138
+ paths loses the only examples of "tool returned an error → read it →
139
+ adapt", and silently skews the corpus toward easy tasks.
140
+ 3. **The recovery is the target, never the mistake.** If the ideal path cannot
141
+ avoid the error, the assistant's next turn is the supervised recovery; the
142
+ error text stays byte-verbatim in the tool row.
143
+
144
+ ### 4.1 The narrow recovery carve-out
145
+
146
+ A trace may keep a real failure **only** when ALL of these hold:
147
+
148
+ - the failure is **environment-caused** — a tool returned an error the ideal
149
+ agent could not have foreseen (flaky endpoint, missing dependency, a
150
+ genuinely absent file). Not a wrong tool name, wrong arguments, wrong path,
151
+ or a reasoning error;
152
+ - the error text is **byte-verbatim** from `outputs.jsonl` — never composed,
153
+ never paraphrased, never invented;
154
+ - there is **one corrective step per error**; two or more consecutive
155
+ identical failed calls disqualify the trace;
156
+ - the harness records the trace as a **retained recovery** (manifest + dataset
157
+ card) so the corpus can report failure density and a trainer can filter it
158
+ out;
159
+ - the final state is **verified** — a clean-looking recovery whose final
160
+ answer is wrong is rejected outright.
161
+
162
+ Everything else follows P2: route around the mistake, show only the correct
163
+ action. When in doubt, route around it — the carve-out is an exception, not a
164
+ license.
165
+
166
+ ### 4.2 What must never be in the target span
167
+
168
+ - A fabricated or paraphrased error message.
169
+ - A failure the ideal agent could have avoided by reading its own context.
170
+ - A "let me try again" narrative whose first attempt is not shown (the model
171
+ learns the shape of recovery without the substance).
172
+ - A recovery loop longer than one corrective step.
173
+ - A success claim that the tool results do not support.
174
+
175
+ ## 5. Rewrite procedure (binding)
176
+
177
+ 1. Read these guidelines in full.
178
+ 2. Mine the negative signal: list the real trace's failed calls, retries,
179
+ wrong turns, and user feedback (P2).
180
+ 3. Classify each failure: **harness artefact** (wrong cwd, missing env var,
181
+ stale workdir, tool not installed) → route around it; **environment-caused
182
+ and unavoidable** → eligible for the §4.1 carve-out; **model-caused** →
183
+ route around it, always.
184
+ 4. Map the ideal path: the minimal set of correct, necessary calls (P3).
185
+ 5. Copy every tool result on that path byte-verbatim from `outputs.jsonl`
186
+ (P4), then write the reasoning and final answer around them (P1, P5).
187
+ 6. Write the ideal trace JSON (schema in the task prompt).
188
+ 7. Verify: re-read the file; check JSON validity, call/result pairing, every
189
+ pillar, and that no failure sits in an assistant turn. Fix, then summarize
190
+ the changes.
191
+
192
+ ## 6. Scope: what this corpus is and is not
193
+
194
+ This is an **evaluation reference**. It is not a training corpus by official
195
+ scope, not a preference corpus, and not an RL environment.
196
+
197
+ - **The benchmark** scores a model by how little the pipeline has to edit its
198
+ traces to reach the reference. The reference is the ruler, not the product.
199
+ - **SFT reuse** is possible — the §4 contract keeps the reference a valid
200
+ target — but it is a downstream decision, not this repo's purpose.
201
+ - **KTO/DPO** needs rejected samples from the policy being aligned,
202
+ length-matched to the chosen side, with a bounded negative loss. A
203
+ hand-written "bad" trace is a strawman and teaches length/style, not
204
+ preference.
205
+ - **PPO/GRPO** needs rollouts and a verifiable reward; failed rollouts are
206
+ advantage, not imitation. A static JSONL of ideal traces cannot supply that
207
+ signal.
208
+ - If those corpora are built later, they are separate artifacts with separate
209
+ rules. Do not repurpose this corpus by relabelling its rows.
210
+
211
+ Corpus-level obligations that belong to the harness, not the improver: report
212
+ failure density and difficulty distribution per batch; cap retained recoveries
213
+ at ≤15% of traces until an in-repo ablation says otherwise; state the masking
214
+ contract in the dataset card; dedup at the (task, tool-sequence) level.
215
+
216
+ ## 7. Remaining open items
217
+
218
+ - System prompt: keep original vs. normalize to a canonical corpus prompt.
219
+ - Corpus-level dedup threshold for batch runs (Jaccard > 0.9 is the current
220
+ audit default).
221
+ - Whether the §4.1 carve-out improves or degrades held-out recovery rate —
222
+ needs an in-repo ablation (same traces, with and without retained
223
+ recoveries, scored on a forced-error recovery eval).
224
+
225
+ ## 8. Provenance
226
+
227
+ Improved traces carry source session id, parent session id, and generator
228
+ version; the manifest records per-trace status, attempts, and errors. The
229
+ harness records retained recoveries (§4.1) in the manifest and reports failure
230
+ density in the dataset card.
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio>=6.0
2
+ pyyaml>=6
3
+ huggingface_hub>=0.24
traceforge/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """traceforge — an evaluation harness for agent models.
2
+
3
+ Reads real agent traces (Hermes Agent, Claude Code, Codex), then uses the
4
+ Hermes Agent CLI itself as the improver harness to rewrite them into
5
+ ideal-world references, validated against the guidelines in
6
+ guidelines/IDEAL_TRACE_GUIDELINES.md. The benchmark scores a model by how
7
+ little that rewrite has to change: if the pipeline has nothing to do, the
8
+ model is already perfect.
9
+ """
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ from traceforge.config import Config
14
+ from traceforge.trace import Message, ToolCall, Trace
15
+
16
+ __all__ = ["Config", "Message", "ToolCall", "Trace", "__version__"]
traceforge/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (853 Bytes). View file
 
traceforge/__pycache__/config.cpython-314.pyc ADDED
Binary file (12.4 kB). View file
 
traceforge/__pycache__/trace.cpython-314.pyc ADDED
Binary file (21.2 kB). View file
 
traceforge/audit.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Corpus audit: mechanical, LLM-free quality gates over the PROMOTED corpus.
2
+
3
+ Supervisor tool — run before any training export. Zero LLM calls; every
4
+ finding is a mechanical pattern with a trace + index citation, so it can
5
+ drive repairs or corpus pruning deterministically.
6
+
7
+ Audit classes:
8
+ 1. harness self-talk — compaction/continuation narrative, spillover
9
+ slices, retry language in ASSISTANT prose (raw-session plumbing an
10
+ ideal trace must never show a model saying)
11
+ 2. clip markers — the static gate, re-checked corpus-wide
12
+ 3. empty/degenerate assistant turns — prose turns with < 20 chars
13
+ that are not a tool-call carrier
14
+ 4. judge/quality residue — "defect": false entries that leaked into
15
+ promoted artifacts (should be impossible; proves a gate bypass)
16
+ 5. near-duplicates — normalized assistant-prose shingles > 0.9
17
+ Jaccard across traces (overfit risk for the trainee)
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import re
25
+ from collections import Counter
26
+
27
+ # ---------------------------------------------------------------- patterns
28
+
29
+ # Harness/plumbing noise in ASSISTANT prose. Deliberately narrower than
30
+ # the raw-trace patterns: words like "harness"/"retry" are legitimate in
31
+ # domain tasks (test harness, git retry), so we anchor on unambiguous
32
+ # self-referential plumbing.
33
+ SELF_TALK_RE = re.compile(
34
+ r"("
35
+ r"[Cc]ontext was compacted"
36
+ r"|\[\s*CONTEXT COMPACTION\s*\]"
37
+ r"|⟪HERMES-CONTEXT-COMPRESSION"
38
+ r"|spillover slice"
39
+ r"|\bcompacted mid-"
40
+ r"|\bmid context-compression\b"
41
+ r"|\bcontinuation handoff\b"
42
+ r"|CONTEXT-COMPRESSION:"
43
+ r")"
44
+ )
45
+
46
+ # A task ABOUT harness/crash/database repair legitimately discusses
47
+ # compaction state — that is domain content, not self-talk leakage.
48
+ DOMAIN_TASK_RE = re.compile(
49
+ r"crash|repair|database|hermes|corrupt|restore|recover|diagnos",
50
+ re.IGNORECASE,
51
+ )
52
+
53
+ CLIP_RE = re.compile(r"…?\s*\[\+[\d,.]+ ?(?:chars|bytes) clipped\]")
54
+
55
+ # same-family judge leak marker: judge entries surviving into artifacts
56
+ JUDGE_LEAK_RE = re.compile(r'"defect"\s*:\s*false|"answer_quality"', re.I)
57
+
58
+ # identity burns / refusal phrasings — must never reach the reference
59
+ from traceforge.identity import IDENTITY_RE, REFUSAL_RE # noqa: E402
60
+ from traceforge.splice import _task_demands_json # noqa: E402
61
+
62
+ # a final answer that IS a raw JSON dump (not fenced, parses as JSON)
63
+ # teaches the trainee to close sessions with machine output
64
+ def _final_is_blob(msgs: list) -> bool:
65
+ if not msgs:
66
+ return False
67
+ last = msgs[-1]
68
+ if last.get("role") != "assistant":
69
+ return False
70
+ c = (last.get("content") or "").strip()
71
+ if len(c) < 400 or c.startswith("```"):
72
+ return False
73
+ if not (c.startswith("{") or c.startswith("[")):
74
+ return False
75
+ try:
76
+ json.loads(c)
77
+ return True
78
+ except json.JSONDecodeError:
79
+ return False
80
+
81
+
82
+ def _shingle(text: str, k: int = 8) -> set[str]:
83
+ """Word k-gram hashes for near-duplicate detection."""
84
+ words = re.findall(r"\w+", text.lower())
85
+ if len(words) < k:
86
+ return set()
87
+ return {hashlib.md5(
88
+ " ".join(words[i:i + k]).encode()).hexdigest()
89
+ for i in range(len(words) - k + 1)}
90
+
91
+
92
+ def _jaccard(a: set, b: set) -> float:
93
+ if not a or not b:
94
+ return 0.0
95
+ return len(a & b) / len(a | b)
96
+
97
+
98
+ def audit_trace(t: dict) -> list[dict]:
99
+ """One promoted trace -> list of findings."""
100
+ findings = []
101
+ sid = t.get("parent_session_id") or t.get("session_id", "?")
102
+ msgs = t.get("messages", [])
103
+ # opening user task (skip compaction/continuation handoffs)
104
+ task = next((m.get("content") or "" for m in msgs
105
+ if m.get("role") == "user"
106
+ and not (m.get("content") or "").startswith(
107
+ ("[CONTEXT", "⟪"))), "")
108
+ task_domain = bool(DOMAIN_TASK_RE.search(task))
109
+ if _final_is_blob(msgs) and not _task_demands_json(task):
110
+ findings.append({
111
+ "cls": "final_blob", "i": len(msgs) - 1,
112
+ "detail": "final answer is a raw JSON dump, not prose",
113
+ "ctx": (msgs[-1].get("content") or "")[:160].replace("\n", " "),
114
+ })
115
+ for i, m in enumerate(msgs):
116
+ role = m.get("role")
117
+ c = m.get("content") or ""
118
+ if role == "assistant":
119
+ mo = SELF_TALK_RE.search(c)
120
+ if mo:
121
+ findings.append({
122
+ "cls": "self_talk_warn" if task_domain else "self_talk",
123
+ "i": i,
124
+ "detail": mo.group(0)[:80],
125
+ "ctx": c[max(0, mo.start() - 30):
126
+ mo.end() + 50].replace("\n", " ")[:160],
127
+ })
128
+ # clip markers must not survive in ANY role's content (tool
129
+ # rows carry them when a rewrite copied the window rendering)
130
+ mo = CLIP_RE.search(c)
131
+ if mo:
132
+ findings.append({
133
+ "cls": "clip_marker", "i": i,
134
+ "detail": mo.group(0)[:80], "ctx": c[
135
+ max(0, mo.start() - 30):
136
+ mo.end() + 50].replace("\n", " ")[:160],
137
+ })
138
+ if role == "assistant":
139
+ has_calls = bool(m.get("tool_calls"))
140
+ if not has_calls and len(c.strip()) < 20:
141
+ findings.append({
142
+ "cls": "degenerate_turn", "i": i,
143
+ "detail": f"prose turn {len(c.strip())} chars",
144
+ "ctx": c.strip()[:160],
145
+ })
146
+ mo = JUDGE_LEAK_RE.search(c)
147
+ if mo:
148
+ findings.append({
149
+ "cls": "judge_residue", "i": i,
150
+ "detail": mo.group(0)[:80],
151
+ "ctx": c[max(0, mo.start() - 30):
152
+ mo.end() + 50].replace("\n", " ")[:160],
153
+ })
154
+ mo = IDENTITY_RE.search(c)
155
+ if mo:
156
+ findings.append({
157
+ "cls": "identity_burn", "i": i,
158
+ "detail": mo.group(0)[:80],
159
+ "ctx": c[max(0, mo.start() - 30):
160
+ mo.end() + 50].replace("\n", " ")[:160],
161
+ })
162
+ mo = REFUSAL_RE.search(c)
163
+ if mo:
164
+ findings.append({
165
+ "cls": "refusal", "i": i,
166
+ "detail": mo.group(0)[:80],
167
+ "ctx": c[max(0, mo.start() - 30):
168
+ mo.end() + 50].replace("\n", " ")[:160],
169
+ })
170
+ return findings
171
+
172
+
173
+ def audit_corpus(improved_dir: str, *, max_dupes: int = 20) -> dict:
174
+ """Whole-corpus audit: per-trace findings + near-duplicate clusters."""
175
+ per_trace: dict[str, list] = {}
176
+ shingles: dict[str, set] = {}
177
+ sizes = []
178
+ for f in sorted(os.listdir(improved_dir)):
179
+ if not f.endswith(".json"):
180
+ continue
181
+ sid = f[:-5]
182
+ try:
183
+ t = json.load(open(os.path.join(improved_dir, f)))
184
+ except Exception as e:
185
+ per_trace[sid] = [{"cls": "unparseable", "i": -1,
186
+ "detail": str(e)[:120], "ctx": ""}]
187
+ continue
188
+ per_trace[sid] = audit_trace(t)
189
+ prose = " ".join((m.get("content") or "")
190
+ for m in t.get("messages", [])
191
+ if m.get("role") == "assistant")
192
+ sh = _shingle(prose)
193
+ if sh:
194
+ shingles[sid] = sh
195
+ sizes.append((sid, len(json.dumps(t))))
196
+
197
+ # Near-duplicate clusters: greedy grouping against cluster reps.
198
+ #
199
+ # Comparing every trace against every cluster is O(n^2) Jaccard on sets of
200
+ # thousands of shingles: at 2,983 traces it ran for over 20 minutes and
201
+ # blocked every export. Two sets with Jaccard > 0.9 almost certainly share
202
+ # their smallest shingle hashes, so bucket by the 3 smallest and only
203
+ # compare inside a bucket. That is the standard LSH banding trick and it
204
+ # turns the pass into O(n) buckets with a handful of comparisons each.
205
+ buckets: dict[str, list[str]] = {}
206
+ for sid, sh in shingles.items():
207
+ for h in sorted(sh)[:3]:
208
+ buckets.setdefault(h, []).append(sid)
209
+
210
+ clusters: list[list[str]] = []
211
+ assigned: dict[str, list[str]] = {}
212
+ for a in shingles:
213
+ placed = False
214
+ # candidate clusters: any cluster whose rep shares a bucket with `a`
215
+ cands: list[list[str]] = []
216
+ seen_ids: set[int] = set()
217
+ for h in sorted(shingles[a])[:3]:
218
+ for other in buckets.get(h, ()):
219
+ cl = assigned.get(other)
220
+ if cl is not None and id(cl) not in seen_ids:
221
+ seen_ids.add(id(cl))
222
+ cands.append(cl)
223
+ for cl in cands:
224
+ if _jaccard(shingles[a], shingles[cl[0]]) > 0.9:
225
+ cl.append(a)
226
+ assigned[a] = cl
227
+ placed = True
228
+ break
229
+ if not placed:
230
+ cl = [a]
231
+ clusters.append(cl)
232
+ assigned[a] = cl
233
+ dupes = [cl for cl in clusters if len(cl) > 1][:max_dupes]
234
+
235
+ return {"per_trace": per_trace, "dupes": dupes,
236
+ "total": len(per_trace)}
237
+
238
+
239
+ def main(improved_dir: str, *, verbose: bool = False) -> int:
240
+ rep = audit_corpus(improved_dir)
241
+ counts: Counter = Counter()
242
+ flagged = {}
243
+ for sid, findings in rep["per_trace"].items():
244
+ for f in findings:
245
+ counts[f["cls"]] += 1
246
+ if findings:
247
+ flagged[sid] = findings
248
+ print(f"audited {rep['total']} promoted traces")
249
+ for cls, n in sorted(counts.items()):
250
+ print(f" {cls}: {n}")
251
+ for sid, fs in list(flagged.items())[:10 if verbose else 5]:
252
+ print(f"\n--- {sid}")
253
+ for f in fs[:4]:
254
+ print(f" [{f['i']}] {f['cls']}: {f['detail']}")
255
+ print(f" …{f['ctx'][:120]}…")
256
+ if rep["dupes"]:
257
+ print(f"\nnear-duplicate clusters (J>0.9): {len(rep['dupes'])}")
258
+ for cl in rep["dupes"][:8]:
259
+ print(f" {' ~ '.join(cl)}")
260
+ else:
261
+ print("\nno near-duplicate clusters (J>0.9)")
262
+ return 1 if any(counts[c] for c in
263
+ ("self_talk", "clip_marker", "unparseable",
264
+ "judge_residue", "identity_burn", "refusal",
265
+ "final_blob")) else 0
266
+
267
+
268
+ if __name__ == "__main__":
269
+ import sys
270
+ raise SystemExit(main(sys.argv[1] if len(sys.argv) > 1
271
+ else "data/traces/improved",
272
+ verbose="-v" in sys.argv))
traceforge/benchmark.py ADDED
@@ -0,0 +1,811 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Benchmark suite — score a model by how little the pipeline had to edit.
2
+
3
+ The pipeline turns a real agent trace into an ideal-world reference. That
4
+ makes it an evaluation instrument: **the amount of work the pipeline has to
5
+ do on a model's trace is an inverse measure of how close the model already
6
+ is to the reference.** If the pipeline has nothing to do, the model is
7
+ already perfect.
8
+
9
+ Two independent signals, both "less is better":
10
+
11
+ 1. **Fidelity** — similarity between the model's trace and the ideal
12
+ rewrite. A trace the pipeline can promote unchanged scores 1.0; every
13
+ dropped, rephrased or added message lowers it. Computed as a Dice
14
+ coefficient over aligned message pairs, so a rewrite that deletes half
15
+ the trace cannot score high by matching the surviving half perfectly.
16
+ 2. **Labels** — the triage pass labels every message ``keep`` / ``drop`` /
17
+ ``rephrase`` / ``fact_check`` / ``summarize``. ``keep`` is a positive
18
+ judgement, ``drop`` a negative one, and the shape edits sit in between.
19
+ The label score is graded (keep 1.0, shape edit 0.5, drop 0.0) and is
20
+ computed over ASSISTANT rows only: those are the model's own turns, while
21
+ a long tool result the triage wants compressed is the environment's
22
+ verbosity, not a model defect.
23
+
24
+ Composite score (0-100)::
25
+
26
+ score = 100 * (w_sim * fidelity + w_label * label_score)
27
+
28
+ Defaults are 0.6 / 0.4. Weights are a policy choice, not a measurement;
29
+ they are recorded in every report so a score is never quoted without them.
30
+
31
+ Why Dice and not a plain mean: a mean over aligned pairs rewards a rewrite
32
+ that keeps one message and deletes nine. Dice divides by the sum of both
33
+ lengths, so deletions and additions are edits and cost score.
34
+
35
+ Normalisation: the readers prepend pipeline framing markers to user rows
36
+ (``[trace segment 2 of 2 …]``, ``[subagent brief — role: …]``). The model
37
+ never wrote those, so they are stripped before comparison — otherwise every
38
+ split segment and subagent trace is penalised for a reader artifact.
39
+
40
+ Caveats the report states explicitly: triage labels come from a cheap
41
+ model and are a judgement, not ground truth; similarity measures agreement
42
+ with the reference, not correctness of the task outcome; and a corpus
43
+ smaller than ~30 traces cannot support a stable mean.
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import difflib
48
+ import json
49
+ import math
50
+ import os
51
+ import re
52
+ import time
53
+ from collections import Counter, defaultdict
54
+ from dataclasses import dataclass, field
55
+ from typing import Any, Iterable, Optional
56
+
57
+ from traceforge.trace import Message, Trace
58
+ from traceforge.triage import ACTIONS
59
+ from traceforge.validate import strip_scaffold_markers
60
+
61
+ BENCHMARK_VERSION = "1.0"
62
+
63
+ # Policy weights, not measurements. Recorded in every report.
64
+ DEFAULT_WEIGHTS: dict[str, float] = {"similarity": 0.6, "labels": 0.4}
65
+
66
+ # A pair at or above this is "kept verbatim" (normalised); below it the
67
+ # pipeline rewrote the message. 0.55 is the alignment floor: two messages
68
+ # that share less than half their text are different messages, not edits.
69
+ VERBATIM = 0.98
70
+ REPHRASE = 0.55
71
+
72
+ _WS_RE = re.compile(r"\s+")
73
+
74
+
75
+ # --------------------------------------------------------------- similarity
76
+
77
+
78
+ def _norm(text: Optional[str]) -> str:
79
+ """Collapse whitespace and case so formatting noise is not an edit."""
80
+ return _WS_RE.sub(" ", (text or "").strip().lower())
81
+
82
+
83
+ def text_similarity(a: str, b: str, *, head: int = 800, tail: int = 200) -> float:
84
+ """Similarity of two message texts in [0, 1].
85
+
86
+ Long tool outputs are compared on head + tail: a rewrite that preserves
87
+ an output preserves both ends, and running difflib over 50 KB strings is
88
+ quadratic for no extra signal. The length-ratio prefilter is what keeps
89
+ a full-corpus run under two minutes — a message half the length of its
90
+ counterpart is a compression, not a match.
91
+ """
92
+ a, b = _norm(a), _norm(b)
93
+ if a == b:
94
+ return 1.0
95
+ if not a or not b:
96
+ return 0.0
97
+ la, lb = len(a), len(b)
98
+ if min(la, lb) / max(la, lb) < 0.5:
99
+ return 0.0
100
+ if la > head + tail or lb > head + tail:
101
+ a = a[:head] + "\n…\n" + a[-tail:]
102
+ b = b[:head] + "\n…\n" + b[-tail:]
103
+ sm = difflib.SequenceMatcher(None, a, b, autojunk=False)
104
+ # quick_ratio is an upper bound on ratio; below the alignment floor the
105
+ # exact value cannot change any decision, so skip the quadratic pass.
106
+ if sm.quick_ratio() < 0.4:
107
+ return 0.0
108
+ return sm.ratio()
109
+
110
+
111
+ def _msg_text(m: Message) -> str:
112
+ """Everything a message carries: prose plus its tool calls."""
113
+ parts = [m.content or ""]
114
+ for tc in m.tool_calls:
115
+ parts.append(f"{tc.name} {tc.arguments}")
116
+ return "\n".join(p for p in parts if p)
117
+
118
+
119
+ def _call_sig(tc) -> tuple[str, str]:
120
+ return (tc.name, _norm(tc.arguments)[:300])
121
+
122
+
123
+ def _seq_similarity(a: list, b: list) -> float:
124
+ """Dice coefficient over two sequences (1.0 when both are empty)."""
125
+ if not a and not b:
126
+ return 1.0
127
+ if not a or not b:
128
+ return 0.0
129
+ sm = difflib.SequenceMatcher(None, a, b, autojunk=False)
130
+ return sm.ratio()
131
+
132
+
133
+ def message_similarity(raw_m: Message, imp_m: Message) -> float:
134
+ """Similarity of one aligned message pair.
135
+
136
+ A tool-only assistant turn carries no prose, so its calls ARE the
137
+ message; weighting prose and calls equally would score every such pair
138
+ as a perfect match on the empty string.
139
+ """
140
+ if raw_m.role != imp_m.role:
141
+ return 0.0
142
+ text_sim = text_similarity(_msg_text(raw_m), _msg_text(imp_m))
143
+ raw_calls = [_call_sig(tc) for tc in raw_m.tool_calls]
144
+ imp_calls = [_call_sig(tc) for tc in imp_m.tool_calls]
145
+ if raw_calls or imp_calls:
146
+ call_sim = _seq_similarity(raw_calls, imp_calls)
147
+ if not _norm(raw_m.content) and not _norm(imp_m.content):
148
+ return call_sim
149
+ return 0.5 * text_sim + 0.5 * call_sim
150
+ return text_sim
151
+
152
+
153
+ def align_messages(raw_msgs: list[Message],
154
+ imp_msgs: list[Message]) -> list[tuple[int, int]]:
155
+ """Monotonic alignment of raw -> improved messages.
156
+
157
+ Roles are the stable skeleton: a rewrite keeps the user/assistant/tool
158
+ shape and drops or adds whole turns. SequenceMatcher over the role
159
+ sequences finds the equal runs and the replaced blocks; inside a block
160
+ messages pair by position. That is O(n) pairs instead of the O(n·window)
161
+ of a content-driven search, and it is the right model of the edit: the
162
+ pipeline does not reorder turns, it removes and rewrites them.
163
+ """
164
+ raw_roles = [m.role for m in raw_msgs]
165
+ imp_roles = [m.role for m in imp_msgs]
166
+ sm = difflib.SequenceMatcher(None, raw_roles, imp_roles, autojunk=False)
167
+ pairs: list[tuple[int, int]] = []
168
+ for tag, i1, i2, j1, j2 in sm.get_opcodes():
169
+ if tag in ("equal", "replace"):
170
+ for k in range(min(i2 - i1, j2 - j1)):
171
+ pairs.append((i1 + k, j1 + k))
172
+ return pairs
173
+
174
+
175
+ # ------------------------------------------------------------------- labels
176
+
177
+
178
+ def label_stats(triage: Optional[dict],
179
+ messages: Optional[list[Message]] = None) -> dict[str, Any]:
180
+ """Counts and rates over one triage label file.
181
+
182
+ ``keep`` is the positive judgement; ``drop`` the negative one.
183
+ ``rephrase``/``summarize`` are shape edits (right content, wrong form),
184
+ ``fact_check`` is a verification flag — neither is a negative label, so
185
+ they are reported separately instead of being folded into the keep rate.
186
+
187
+ When ``messages`` is given, the same counts are also computed over
188
+ ASSISTANT rows only. That is the number the composite uses: the model
189
+ wrote its own turns, not the tool outputs, and a long tool result the
190
+ triage wants compressed is the environment's verbosity, not a model
191
+ defect.
192
+ """
193
+ labels = (triage or {}).get("labels") or []
194
+ valid = [lab for lab in labels
195
+ if isinstance(lab, dict) and lab.get("action") in ACTIONS]
196
+ counts = Counter(lab["action"] for lab in valid)
197
+ total = sum(counts.values())
198
+ out: dict[str, Any] = {"total": total}
199
+ for action in ACTIONS:
200
+ out[action] = counts.get(action, 0)
201
+ out["keep_rate"] = (counts.get("keep", 0) / total) if total else None
202
+ out["negative_rate"] = (counts.get("drop", 0) / total) if total else None
203
+ out["edit_rate"] = (
204
+ (counts.get("rephrase", 0) + counts.get("summarize", 0)) / total
205
+ if total else None
206
+ )
207
+ # Graded positive/negative score: keep = 1.0, a shape edit (rephrase /
208
+ # summarize / fact_check) = 0.5, drop = 0.0. The strict keep rate is
209
+ # dominated by the triage model's compression preference — 58% of
210
+ # assistant labels on the real corpus are `summarize` because assistant
211
+ # turns carry large tool-call arguments — so the composite uses the
212
+ # graded score and the report shows both.
213
+ out["label_score"] = (
214
+ (counts.get("keep", 0)
215
+ + 0.5 * (counts.get("rephrase", 0) + counts.get("summarize", 0)
216
+ + counts.get("fact_check", 0))) / total
217
+ if total else None
218
+ )
219
+ if messages is not None:
220
+ roles = {i: m.role for i, m in enumerate(messages)}
221
+ a_counts = Counter(
222
+ lab["action"] for lab in valid
223
+ if isinstance(lab.get("i"), int)
224
+ and roles.get(lab["i"]) == "assistant"
225
+ )
226
+ a_total = sum(a_counts.values())
227
+ out["assistant_total"] = a_total
228
+ for action in ACTIONS:
229
+ out[f"assistant_{action}"] = a_counts.get(action, 0)
230
+ out["assistant_keep_rate"] = (
231
+ a_counts.get("keep", 0) / a_total if a_total else None)
232
+ out["assistant_negative_rate"] = (
233
+ a_counts.get("drop", 0) / a_total if a_total else None)
234
+ out["assistant_label_score"] = (
235
+ (a_counts.get("keep", 0)
236
+ + 0.5 * (a_counts.get("rephrase", 0) + a_counts.get("summarize", 0)
237
+ + a_counts.get("fact_check", 0))) / a_total
238
+ if a_total else None
239
+ )
240
+ return out
241
+
242
+
243
+ def load_triage(path: str) -> Optional[dict]:
244
+ try:
245
+ with open(path, "r", encoding="utf-8") as f:
246
+ return json.load(f)
247
+ except (OSError, ValueError):
248
+ return None
249
+
250
+
251
+ # ------------------------------------------------------------------ scoring
252
+
253
+
254
+ @dataclass
255
+ class TraceScore:
256
+ """One model trace scored against its ideal rewrite."""
257
+
258
+ session_id: str
259
+ task: Optional[str] = None
260
+ source: Optional[str] = None
261
+ model: Optional[str] = None
262
+
263
+ # fidelity
264
+ similarity: float = 0.0
265
+ tool_similarity: float = 0.0
266
+ kept_verbatim: int = 0
267
+ rephrased: int = 0
268
+ dropped: int = 0
269
+ added: int = 0
270
+ raw_messages: int = 0
271
+ improved_messages: int = 0
272
+ raw_chars: int = 0
273
+
274
+ # labels
275
+ labels_total: int = 0
276
+ keep: int = 0
277
+ drop: int = 0
278
+ rephrase: int = 0
279
+ fact_check: int = 0
280
+ summarize: int = 0
281
+ keep_rate: Optional[float] = None
282
+ negative_rate: Optional[float] = None
283
+ label_score: Optional[float] = None
284
+ # assistant-only: the model's own turns (the loss-bearing target)
285
+ assistant_labels_total: int = 0
286
+ assistant_keep: int = 0
287
+ assistant_drop: int = 0
288
+ assistant_keep_rate: Optional[float] = None
289
+ assistant_negative_rate: Optional[float] = None
290
+ assistant_label_score: Optional[float] = None
291
+
292
+ # gates (best effort — only traces with a manifest attempts row have it)
293
+ attempts: Optional[int] = None
294
+ first_pass: Optional[bool] = None
295
+
296
+ # composite
297
+ score: float = 0.0
298
+ pipeline_idle: bool = False
299
+
300
+ @property
301
+ def edits(self) -> int:
302
+ return self.dropped + self.added + self.rephrased
303
+
304
+ def to_dict(self) -> dict[str, Any]:
305
+ d = {
306
+ "session_id": self.session_id,
307
+ "task": self.task,
308
+ "source": self.source,
309
+ "model": self.model,
310
+ "score": round(self.score, 2),
311
+ "similarity": round(self.similarity, 4),
312
+ "tool_similarity": round(self.tool_similarity, 4),
313
+ "keep_rate": (None if self.keep_rate is None
314
+ else round(self.keep_rate, 4)),
315
+ "negative_rate": (None if self.negative_rate is None
316
+ else round(self.negative_rate, 4)),
317
+ "assistant_keep_rate": (None if self.assistant_keep_rate is None
318
+ else round(self.assistant_keep_rate, 4)),
319
+ "assistant_negative_rate": (
320
+ None if self.assistant_negative_rate is None
321
+ else round(self.assistant_negative_rate, 4)),
322
+ "label_score": (None if self.label_score is None
323
+ else round(self.label_score, 4)),
324
+ "assistant_label_score": (
325
+ None if self.assistant_label_score is None
326
+ else round(self.assistant_label_score, 4)),
327
+ "edits": {
328
+ "kept_verbatim": self.kept_verbatim,
329
+ "rephrased": self.rephrased,
330
+ "dropped": self.dropped,
331
+ "added": self.added,
332
+ "total": self.edits,
333
+ },
334
+ "messages": {
335
+ "raw": self.raw_messages,
336
+ "improved": self.improved_messages,
337
+ "raw_chars": self.raw_chars,
338
+ },
339
+ "labels": {
340
+ "total": self.labels_total,
341
+ "keep": self.keep,
342
+ "drop": self.drop,
343
+ "rephrase": self.rephrase,
344
+ "fact_check": self.fact_check,
345
+ "summarize": self.summarize,
346
+ },
347
+ "attempts": self.attempts,
348
+ "first_pass": self.first_pass,
349
+ "pipeline_idle": self.pipeline_idle,
350
+ }
351
+ return d
352
+
353
+
354
+ def score_pair(raw: Trace, improved: Trace, *, triage: Optional[dict] = None,
355
+ attempts: Optional[int] = None,
356
+ weights: Optional[dict[str, float]] = None) -> TraceScore:
357
+ """Score one (model trace, ideal rewrite) pair.
358
+
359
+ ``raw`` is mutated in place by the scaffold strip — pass a freshly
360
+ loaded trace, which is what the CLI does.
361
+ """
362
+ w = {**DEFAULT_WEIGHTS, **(weights or {})}
363
+ strip_scaffold_markers(raw)
364
+
365
+ pairs = align_messages(raw.messages, improved.messages)
366
+ sims = [message_similarity(raw.messages[i], improved.messages[j])
367
+ for i, j in pairs]
368
+ n_raw, n_imp = len(raw.messages), len(improved.messages)
369
+ similarity = (2.0 * sum(sims) / (n_raw + n_imp)) if (n_raw + n_imp) else 1.0
370
+
371
+ kept = sum(1 for s in sims if s >= VERBATIM)
372
+ rephrased = sum(1 for s in sims if REPHRASE <= s < VERBATIM)
373
+ dropped = n_raw - len(pairs)
374
+ added = n_imp - len(pairs)
375
+
376
+ tool_similarity = _seq_similarity(
377
+ [_call_sig(tc) for m in raw.messages for tc in m.tool_calls],
378
+ [_call_sig(tc) for m in improved.messages for tc in m.tool_calls],
379
+ )
380
+
381
+ lab = label_stats(triage, raw.messages)
382
+ keep_rate = lab["keep_rate"]
383
+ # The composite judges the model on its OWN turns. Tool outputs are
384
+ # context the model did not write; a long result the triage wants
385
+ # compressed is the environment's verbosity, not a model defect.
386
+ label_score = (lab.get("assistant_label_score")
387
+ if lab.get("assistant_label_score") is not None
388
+ else lab.get("label_score"))
389
+ # Idle is strict: any non-keep label means the pipeline had work to do.
390
+ strict_keep = (lab.get("assistant_keep_rate")
391
+ if lab.get("assistant_keep_rate") is not None else keep_rate)
392
+
393
+ # Composite: renormalise over the components that exist. A trace with no
394
+ # triage file is scored on fidelity alone rather than being penalised
395
+ # for a missing label pass.
396
+ parts = [(w["similarity"], similarity)]
397
+ if label_score is not None:
398
+ parts.append((w["labels"], label_score))
399
+ total_w = sum(p[0] for p in parts)
400
+ score = (100.0 * sum(p[0] * p[1] for p in parts) / total_w) if total_w else 0.0
401
+
402
+ idle = (similarity >= 0.999 and dropped == 0 and added == 0
403
+ and (strict_keep is None or strict_keep >= 0.999))
404
+
405
+ return TraceScore(
406
+ session_id=raw.session_id,
407
+ task=improved.task or raw.task,
408
+ source=raw.source,
409
+ model=improved.model or raw.model,
410
+ similarity=similarity,
411
+ tool_similarity=tool_similarity,
412
+ kept_verbatim=kept,
413
+ rephrased=rephrased,
414
+ dropped=dropped,
415
+ added=added,
416
+ raw_messages=n_raw,
417
+ improved_messages=n_imp,
418
+ raw_chars=sum(len(m.content or "") for m in raw.messages),
419
+ labels_total=lab["total"],
420
+ keep=lab["keep"],
421
+ drop=lab["drop"],
422
+ rephrase=lab["rephrase"],
423
+ fact_check=lab["fact_check"],
424
+ summarize=lab["summarize"],
425
+ keep_rate=keep_rate,
426
+ negative_rate=lab["negative_rate"],
427
+ label_score=lab.get("label_score"),
428
+ assistant_labels_total=lab.get("assistant_total", 0),
429
+ assistant_keep=lab.get("assistant_keep", 0),
430
+ assistant_drop=lab.get("assistant_drop", 0),
431
+ assistant_keep_rate=lab.get("assistant_keep_rate"),
432
+ assistant_negative_rate=lab.get("assistant_negative_rate"),
433
+ assistant_label_score=lab.get("assistant_label_score"),
434
+ attempts=attempts,
435
+ first_pass=(attempts == 1) if attempts is not None else None,
436
+ score=score,
437
+ pipeline_idle=idle,
438
+ )
439
+
440
+
441
+ # --------------------------------------------------------------- statistics
442
+
443
+
444
+ def _dist(vals: Iterable[float]) -> dict[str, Any]:
445
+ """Distribution summary. Percentiles are nearest-rank, not interpolated."""
446
+ v = sorted(float(x) for x in vals)
447
+ if not v:
448
+ return {"n": 0, "mean": None, "median": None, "p10": None,
449
+ "p90": None, "min": None, "max": None}
450
+
451
+ def pct(q: float) -> float:
452
+ idx = min(len(v) - 1, max(0, int(round(q * (len(v) - 1)))))
453
+ return v[idx]
454
+
455
+ return {
456
+ "n": len(v),
457
+ "mean": round(sum(v) / len(v), 4),
458
+ "median": round(pct(0.5), 4),
459
+ "p10": round(pct(0.1), 4),
460
+ "p90": round(pct(0.9), 4),
461
+ "min": round(v[0], 4),
462
+ "max": round(v[-1], 4),
463
+ }
464
+
465
+
466
+ def _pearson(xs: list[float], ys: list[float]) -> Optional[float]:
467
+ n = len(xs)
468
+ if n < 3 or n != len(ys):
469
+ return None
470
+ mx, my = sum(xs) / n, sum(ys) / n
471
+ num = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
472
+ dx = math.sqrt(sum((x - mx) ** 2 for x in xs))
473
+ dy = math.sqrt(sum((y - my) ** 2 for y in ys))
474
+ if dx == 0 or dy == 0:
475
+ return None
476
+ return round(num / (dx * dy), 3)
477
+
478
+
479
+ def _histogram(vals: list[float], edges: list[float]) -> dict[str, int]:
480
+ """Bucket counts; the last bucket is inclusive of its upper edge."""
481
+ out: dict[str, int] = {}
482
+ last = len(edges) - 2
483
+ for i, (lo, hi) in enumerate(zip(edges, edges[1:])):
484
+ out[f"{lo:g}-{hi:g}"] = sum(
485
+ 1 for v in vals if lo <= v < hi or (i == last and v == hi))
486
+ return out
487
+
488
+
489
+ def _group(scores: list[TraceScore], key) -> dict[str, dict[str, Any]]:
490
+ buckets: dict[str, list[TraceScore]] = defaultdict(list)
491
+ for s in scores:
492
+ buckets[key(s) or "unknown"].append(s)
493
+ return {
494
+ k: {"n": len(v), "score_mean": round(sum(x.score for x in v) / len(v), 2),
495
+ "similarity_mean": round(sum(x.similarity for x in v) / len(v), 3),
496
+ "keep_rate_mean": (
497
+ round(sum(x.keep_rate for x in v if x.keep_rate is not None)
498
+ / max(1, sum(1 for x in v if x.keep_rate is not None)), 3)
499
+ if any(x.keep_rate is not None for x in v) else None)}
500
+ for k, v in sorted(buckets.items(), key=lambda kv: -len(kv[1]))
501
+ }
502
+
503
+
504
+ def _size_band(chars: int) -> str:
505
+ kb = chars / 1000.0
506
+ if kb < 60:
507
+ return "<60KB"
508
+ if kb < 120:
509
+ return "60-120KB"
510
+ if kb < 180:
511
+ return "120-180KB"
512
+ if kb < 240:
513
+ return "180-240KB"
514
+ if kb < 300:
515
+ return "240-300KB"
516
+ return "300KB+"
517
+
518
+
519
+ def build_report(scores: list[TraceScore], *, label: Optional[str] = None,
520
+ weights: Optional[dict[str, float]] = None,
521
+ corpus: Optional[dict[str, Any]] = None,
522
+ generated_at: Optional[str] = None) -> dict[str, Any]:
523
+ """Corpus-level statistics over a set of scored trace pairs."""
524
+ w = {**DEFAULT_WEIGHTS, **(weights or {})}
525
+ n = len(scores)
526
+ sims = [s.similarity for s in scores]
527
+ keeps = [s.keep_rate for s in scores if s.keep_rate is not None]
528
+ a_keeps = [s.assistant_keep_rate for s in scores
529
+ if s.assistant_keep_rate is not None]
530
+ a_scores = [s.assistant_label_score for s in scores
531
+ if s.assistant_label_score is not None]
532
+ negs = [s.negative_rate for s in scores if s.negative_rate is not None]
533
+ a_negs = [s.assistant_negative_rate for s in scores
534
+ if s.assistant_negative_rate is not None]
535
+ comp = [s.score for s in scores]
536
+ edits = [s.edits for s in scores]
537
+ tools = [s.tool_similarity for s in scores]
538
+
539
+ label_totals = Counter()
540
+ for s in scores:
541
+ for action in ACTIONS:
542
+ label_totals[action] += getattr(s, action)
543
+ label_total = sum(label_totals.values())
544
+
545
+ edit_totals = {
546
+ "kept_verbatim": sum(s.kept_verbatim for s in scores),
547
+ "rephrased": sum(s.rephrased for s in scores),
548
+ "dropped": sum(s.dropped for s in scores),
549
+ "added": sum(s.added for s in scores),
550
+ }
551
+ edit_total = sum(edit_totals.values()) or 1
552
+
553
+ idle = [s for s in scores if s.pipeline_idle]
554
+ gated = [s for s in scores if s.attempts is not None]
555
+ first_pass = [s for s in gated if s.first_pass]
556
+
557
+ # Fidelity and labels are independent instruments; where they disagree
558
+ # the pipeline is inconsistent (a trace promoted unchanged although the
559
+ # triage wanted edits, or a rewrite of turns the triage called clean).
560
+ # That is a pipeline diagnostic, not a model defect — report it.
561
+ def _lab(s: TraceScore) -> float:
562
+ return s.assistant_label_score if s.assistant_label_score is not None else 0.0
563
+
564
+ fid_perfect = [s for s in scores if s.similarity >= 0.999]
565
+ lab_perfect = [s for s in scores if _lab(s) >= 0.999]
566
+ both_perfect = [s for s in scores
567
+ if s.similarity >= 0.999 and _lab(s) >= 0.999]
568
+
569
+ corr = _pearson(
570
+ [s.similarity for s in scores if s.assistant_label_score is not None],
571
+ [s.assistant_label_score for s in scores
572
+ if s.assistant_label_score is not None],
573
+ )
574
+
575
+ worst = sorted(scores, key=lambda s: s.score)[:10]
576
+ best = sorted(scores, key=lambda s: -s.score)[:10]
577
+
578
+ report: dict[str, Any] = {
579
+ "benchmark_version": BENCHMARK_VERSION,
580
+ "generated_at": generated_at or time.strftime("%Y-%m-%dT%H:%M:%S%z"),
581
+ "label": label,
582
+ "weights": w,
583
+ "corpus": {
584
+ "scored": n,
585
+ "with_labels": len(keeps),
586
+ "with_gates": len(gated),
587
+ **(corpus or {}),
588
+ },
589
+ "score": {
590
+ **_dist(comp),
591
+ "histogram": _histogram(comp, [0, 20, 40, 60, 80, 100]),
592
+ },
593
+ "components": {
594
+ "similarity": _dist(sims),
595
+ "keep_rate": _dist(keeps),
596
+ "assistant_keep_rate": _dist(a_keeps),
597
+ "label_score": _dist(a_scores),
598
+ "negative_rate": _dist(negs),
599
+ "assistant_negative_rate": _dist(a_negs),
600
+ "tool_sequence_similarity": _dist(tools),
601
+ },
602
+ "edits": {
603
+ "totals": edit_totals,
604
+ "share_pct": {k: round(100.0 * v / edit_total, 1)
605
+ for k, v in edit_totals.items()},
606
+ "per_trace": _dist(edits),
607
+ },
608
+ "labels": {
609
+ "totals": dict(label_totals),
610
+ "total": label_total,
611
+ "share_pct": {k: round(100.0 * v / label_total, 1)
612
+ for k, v in label_totals.items()} if label_total else {},
613
+ },
614
+ "pipeline_idle": {
615
+ "traces": len(idle),
616
+ "pct": round(100.0 * len(idle) / n, 1) if n else 0.0,
617
+ "definition": "similarity >= 0.999, no dropped/added message, "
618
+ "keep_rate >= 0.999 — the pipeline had nothing to do",
619
+ },
620
+ "agreement": {
621
+ "both_perfect": len(both_perfect),
622
+ "fidelity_perfect_labels_edit": len(fid_perfect) - len(both_perfect),
623
+ "labels_perfect_fidelity_edit": len(lab_perfect) - len(both_perfect),
624
+ "note": "fidelity and labels are independent instruments; a "
625
+ "disagreement is a pipeline inconsistency (promoted "
626
+ "unchanged although triage wanted edits, or rewritten "
627
+ "although triage called it clean), not a model defect",
628
+ },
629
+ "gates": {
630
+ "traces_with_attempts": len(gated),
631
+ "first_pass": len(first_pass),
632
+ "first_pass_pct": (round(100.0 * len(first_pass) / len(gated), 1)
633
+ if gated else None),
634
+ "attempts_mean": (round(sum(s.attempts for s in gated) / len(gated), 2)
635
+ if gated else None),
636
+ },
637
+ "by_task": _group(scores, lambda s: s.task),
638
+ "by_source": _group(scores, lambda s: s.source),
639
+ "by_size": _group(scores, lambda s: _size_band(s.raw_chars)),
640
+ "correlation": {"similarity_vs_label_score": corr},
641
+ "worst": [s.to_dict() for s in worst],
642
+ "best": [s.to_dict() for s in best],
643
+ "notes": [
644
+ "score = 100 * (w_similarity * fidelity + w_labels * label_score); "
645
+ "weights are policy, not measurement",
646
+ "fidelity is a Dice coefficient over aligned message pairs, so "
647
+ "dropped and added messages both cost score",
648
+ "label_score is graded: keep=1.0, rephrase/summarize/fact_check=0.5, "
649
+ "drop=0.0, over ASSISTANT rows only (the model's own turns)",
650
+ "triage labels come from a cheap model and are a judgement, not "
651
+ "ground truth; label_score is agreement with the pipeline's judge",
652
+ "similarity measures agreement with the reference, not whether "
653
+ "the task outcome was correct",
654
+ "pipeline framing markers are stripped from raw user rows before "
655
+ "comparison (the model never wrote them)",
656
+ "n < 30 traces cannot support a stable mean — treat the score as "
657
+ "a smoke signal, not a ranking",
658
+ ],
659
+ }
660
+ return report
661
+
662
+
663
+ # ------------------------------------------------------------------ render
664
+
665
+
666
+ def _fmt(v: Optional[float], nd: int = 2) -> str:
667
+ return "—" if v is None else f"{v:.{nd}f}"
668
+
669
+
670
+ def render_report(report: dict[str, Any]) -> str:
671
+ """Human-readable report. The JSON is the artifact; this is the glance."""
672
+ c = report["corpus"]
673
+ sc = report["score"]
674
+ comp = report["components"]
675
+ lines: list[str] = []
676
+ title = f"traceforge benchmark v{report['benchmark_version']}"
677
+ if report.get("label"):
678
+ title += f" — {report['label']}"
679
+ lines.append(title)
680
+ lines.append(
681
+ f"scored {c['scored']} trace pairs (model trace -> ideal rewrite) · "
682
+ f"{c['with_labels']} with triage labels · "
683
+ f"{c['with_gates']} with gate attempts"
684
+ )
685
+ lines.append("")
686
+ lines.append(f"score (0-100) mean {_fmt(sc['mean'], 1)} "
687
+ f"median {_fmt(sc['median'], 1)} "
688
+ f"p10 {_fmt(sc['p10'], 1)} p90 {_fmt(sc['p90'], 1)}")
689
+ lines.append(f" fidelity mean {_fmt(comp['similarity']['mean'], 3)} "
690
+ f"median {_fmt(comp['similarity']['median'], 3)} "
691
+ f"p10 {_fmt(comp['similarity']['p10'], 3)} "
692
+ f"p90 {_fmt(comp['similarity']['p90'], 3)}")
693
+ lines.append(f" label score mean {_fmt(comp['label_score']['mean'], 3)} "
694
+ f"median {_fmt(comp['label_score']['median'], 3)} "
695
+ f"p10 {_fmt(comp['label_score']['p10'], 3)} "
696
+ f"p90 {_fmt(comp['label_score']['p90'], 3)}")
697
+ lines.append(f" keep rate (asst) mean {_fmt(comp['assistant_keep_rate']['mean'], 3)} "
698
+ f"median {_fmt(comp['assistant_keep_rate']['median'], 3)} "
699
+ f"p10 {_fmt(comp['assistant_keep_rate']['p10'], 3)} "
700
+ f"p90 {_fmt(comp['assistant_keep_rate']['p90'], 3)}")
701
+ lines.append(f" negative (asst) mean {_fmt(comp['assistant_negative_rate']['mean'], 3)} "
702
+ f"median {_fmt(comp['assistant_negative_rate']['median'], 3)} "
703
+ f"p90 {_fmt(comp['assistant_negative_rate']['p90'], 3)}")
704
+ lines.append(f" tool sequence mean {_fmt(comp['tool_sequence_similarity']['mean'], 3)}")
705
+ lines.append("")
706
+ hist = sc["histogram"]
707
+ lines.append("score histogram " + " · ".join(
708
+ f"{k}: {v}" for k, v in hist.items()))
709
+ lines.append("")
710
+ e = report["edits"]
711
+ lines.append(f"edits per trace mean {_fmt(e['per_trace']['mean'], 1)} "
712
+ f"median {_fmt(e['per_trace']['median'], 1)} "
713
+ f"p90 {_fmt(e['per_trace']['p90'], 1)}")
714
+ lines.append(
715
+ " messages "
716
+ f"kept {e['totals']['kept_verbatim']} ({e['share_pct']['kept_verbatim']}%) · "
717
+ f"rephrased {e['totals']['rephrased']} ({e['share_pct']['rephrased']}%) · "
718
+ f"dropped {e['totals']['dropped']} ({e['share_pct']['dropped']}%) · "
719
+ f"added {e['totals']['added']} ({e['share_pct']['added']}%)")
720
+ lab = report["labels"]
721
+ if lab["total"]:
722
+ lines.append("labels " + " · ".join(
723
+ f"{k} {v}" for k, v in lab["totals"].items()))
724
+ idle = report["pipeline_idle"]
725
+ lines.append(f"pipeline idle {idle['traces']} traces "
726
+ f"({idle['pct']}%) — nothing to edit")
727
+ ag = report.get("agreement") or {}
728
+ if ag:
729
+ lines.append(f"instrument agree both perfect {ag['both_perfect']} · "
730
+ f"fidelity-only {ag['fidelity_perfect_labels_edit']} · "
731
+ f"labels-only {ag['labels_perfect_fidelity_edit']}")
732
+ g = report["gates"]
733
+ if g["traces_with_attempts"]:
734
+ lines.append(f"first-pass gates {g['first_pass']}/"
735
+ f"{g['traces_with_attempts']} ({g['first_pass_pct']}%) · "
736
+ f"mean attempts {g['attempts_mean']}")
737
+ lines.append("")
738
+ for name, key in (("by task", "by_task"), ("by source", "by_source"),
739
+ ("by size", "by_size")):
740
+ groups = report[key]
741
+ if not groups:
742
+ continue
743
+ parts = [f"{k} {v['n']} ({_fmt(v['score_mean'], 1)})"
744
+ for k, v in list(groups.items())[:8]]
745
+ lines.append(f"{name:<18} " + " · ".join(parts))
746
+ corr = report["correlation"]["similarity_vs_label_score"]
747
+ lines.append(f"correlation similarity vs label score: r={_fmt(corr, 3)}")
748
+ lines.append("")
749
+ lines.append("worst traces (inspect these first):")
750
+ for s in report["worst"][:5]:
751
+ lines.append(f" {s['session_id'][:44]:<44} score {s['score']:>5.1f} "
752
+ f"sim {s['similarity']:.2f} "
753
+ f"label {_fmt(s['assistant_label_score'], 2)} "
754
+ f"{s['task'] or '—'}")
755
+ return "\n".join(lines)
756
+
757
+
758
+ # -------------------------------------------------------------- leaderboard
759
+
760
+
761
+ def append_leaderboard(path: str, report: dict[str, Any]) -> None:
762
+ """Append one compact row per run; the leaderboard keeps the latest per label."""
763
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
764
+ row = {
765
+ "label": report.get("label") or "unlabeled",
766
+ "generated_at": report["generated_at"],
767
+ "benchmark_version": report["benchmark_version"],
768
+ "n": report["corpus"]["scored"],
769
+ "score_mean": report["score"]["mean"],
770
+ "score_median": report["score"]["median"],
771
+ "similarity_mean": report["components"]["similarity"]["mean"],
772
+ "label_score_mean": report["components"]["label_score"]["mean"],
773
+ "keep_rate_mean": report["components"]["assistant_keep_rate"]["mean"],
774
+ "pipeline_idle_pct": report["pipeline_idle"]["pct"],
775
+ "weights": report["weights"],
776
+ }
777
+ with open(path, "a", encoding="utf-8") as f:
778
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
779
+
780
+
781
+ def load_leaderboard(path: str) -> list[dict[str, Any]]:
782
+ rows: dict[str, dict[str, Any]] = {}
783
+ try:
784
+ with open(path, "r", encoding="utf-8") as f:
785
+ for line in f:
786
+ try:
787
+ r = json.loads(line)
788
+ except ValueError:
789
+ continue
790
+ if r.get("label"):
791
+ rows[r["label"]] = r # latest run wins
792
+ except OSError:
793
+ return []
794
+ return sorted(rows.values(), key=lambda r: -(r.get("score_mean") or 0))
795
+
796
+
797
+ def render_leaderboard(rows: list[dict[str, Any]]) -> str:
798
+ if not rows:
799
+ return "leaderboard: empty"
800
+ lines = ["leaderboard (latest run per label, sorted by mean score):",
801
+ f" {'label':<28} {'n':>5} {'score':>7} {'fidelity':>9} "
802
+ f"{'label':>6} {'idle%':>6} when"]
803
+ for r in rows:
804
+ lines.append(
805
+ f" {r['label'][:28]:<28} {r['n']:>5} "
806
+ f"{_fmt(r.get('score_mean'), 1):>7} "
807
+ f"{_fmt(r.get('similarity_mean'), 3):>9} "
808
+ f"{_fmt(r.get('label_score_mean'), 3):>6} "
809
+ f"{_fmt(r.get('pipeline_idle_pct'), 1):>6} "
810
+ f"{r.get('generated_at', '')[:19]}")
811
+ return "\n".join(lines)
traceforge/chunker.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Splits a Trace into LLM-digestible chunks at message boundaries.
2
+
3
+ A 10M-token session can never be rendered into a single prompt. Chunks are
4
+ cut at message boundaries under a char budget (≈4 chars/token), with a
5
+ small tail-overlap of previous-chunk messages prepended as continuity
6
+ context for the digester.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from traceforge.trace import Trace
13
+
14
+
15
+ @dataclass
16
+ class Chunk:
17
+ index: int # 1-based
18
+ start: int # first message index (0-based, inclusive)
19
+ end: int # last message index (exclusive)
20
+ rendered: str # compact rendering incl. overlap context header
21
+ approx_chars: int # size estimate of the chunk's own messages
22
+
23
+
24
+ def _msg_size(m) -> int:
25
+ return len(m.content or "") + sum(
26
+ len(tc.arguments) for tc in m.tool_calls
27
+ ) + 64
28
+
29
+
30
+ def chunk_trace(
31
+ trace: Trace,
32
+ *,
33
+ max_chars: int = 100_000,
34
+ overlap_messages: int = 2,
35
+ render_kwargs: dict | None = None,
36
+ ) -> list[Chunk]:
37
+ """Cut the trace into chunks of ≤ max_chars (single messages never split).
38
+
39
+ Returns chunks with per-chunk overlap context prepended (marked as
40
+ continuity-only so the digester does not double-count).
41
+ """
42
+ rk = render_kwargs or {}
43
+ msgs = trace.messages
44
+ n = len(msgs)
45
+ chunks: list[Chunk] = []
46
+
47
+ def render_slice(lo: int, hi: int) -> str:
48
+ sub = Trace(
49
+ session_id=trace.session_id,
50
+ model=trace.model,
51
+ messages=msgs[lo:hi],
52
+ )
53
+ out = sub.render_compact(**rk)
54
+ # drop the synthetic "=== TRACE ... ===" header line
55
+ return out.split("\n", 1)[1] if "\n" in out else out
56
+
57
+ i = 0
58
+ while i < n:
59
+ j = i
60
+ total = 0
61
+ while j < n and (j == i or total + _msg_size(msgs[j]) <= max_chars):
62
+ total += _msg_size(msgs[j])
63
+ j += 1
64
+ body = render_slice(i, j)
65
+ header = (
66
+ f"CHUNK {len(chunks) + 1} of ~N (messages {i + 1}-{j} of {n})"
67
+ )
68
+ block = f"{header}\n{body}"
69
+ if i > 0 and overlap_messages > 0:
70
+ ov = render_slice(max(0, i - overlap_messages), i)
71
+ block = (
72
+ "CONTEXT — tail of the previous chunk (continuity only;"
73
+ " already digested, do not re-report):\n"
74
+ f"{ov}\n\n{block}"
75
+ )
76
+ chunks.append(
77
+ Chunk(
78
+ index=len(chunks) + 1,
79
+ start=i,
80
+ end=j,
81
+ rendered=block,
82
+ approx_chars=total,
83
+ )
84
+ )
85
+ i = j
86
+ # fix the "of ~N" placeholders now that we know the count
87
+ for c in chunks:
88
+ if "~N" in c.rendered:
89
+ c.rendered = c.rendered.replace("~N", str(len(chunks)))
90
+ return chunks
traceforge/classify.py ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task/topic classification for traces.
2
+
3
+ Labels every trace with one task bucket so export can split the corpus by
4
+ topic (``train.coding.jsonl``, ``train.research.jsonl``, ...). Two tiers:
5
+
6
+ heuristic tool signature + first-task-message keywords. Free,
7
+ deterministic, whole corpus in seconds. Confidence is the
8
+ margin between the winning bucket and the runner-up.
9
+ llm one structured call per trace, used only where the heuristic
10
+ is unsure (``--llm``). Same endpoint/grammar ladder as
11
+ triage.py, so no new infrastructure.
12
+
13
+ The label is persisted on the trace (``Trace.task``) so export is
14
+ deterministic and every assignment is inspectable and reviewable.
15
+
16
+ Taxonomy is deliberately closed: a small set the trainer can weight
17
+ against, not free-form tags. Coding is split because the corpus is ~95%
18
+ coding and a single bucket would be useless for balancing.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ from typing import Any, Optional
25
+
26
+ from traceforge.trace import Trace
27
+
28
+ # --------------------------------------------------------------- taxonomy
29
+
30
+ CODING_TASKS = ("debug", "implement", "refactor", "test", "review")
31
+ NONCODING_TASKS = (
32
+ "research", "data-analysis", "office-docs", "creative", "ops", "other",
33
+ )
34
+ TASKS = CODING_TASKS + NONCODING_TASKS
35
+
36
+ TASK_DESCRIPTIONS = {
37
+ "debug": "diagnose and fix a failure: error, traceback, crash, regression",
38
+ "implement": "add or build a feature, file, endpoint, or capability",
39
+ "refactor": "restructure existing code without changing behaviour",
40
+ "test": "write or run tests, coverage, fixtures, CI test jobs",
41
+ "review": "read-only audit, code review, diff inspection, critique",
42
+ "research": "gather external information: web, papers, docs, comparison",
43
+ "data-analysis": "analyse data: metrics, SQL, CSV, statistics, dashboards",
44
+ "office-docs": "produce office-suite deliverables: .xlsx spreadsheets, "
45
+ ".pptx decks, .docx documents (NOT markdown docs or code docs)",
46
+ "creative": "creative writing or design: prose, lyrics, branding, visuals",
47
+ "ops": "deploy, configure, or operate infrastructure and services",
48
+ "other": "none of the above, or too ambiguous to call",
49
+ }
50
+
51
+ # ------------------------------------------------------------- heuristics
52
+
53
+ # Keyword rules over the first task message. Weight = how diagnostic the
54
+ # term is; a single strong term ("traceback") should beat several weak ones.
55
+ _KEYWORDS: dict[str, list[tuple[str, int]]] = {
56
+ "debug": [
57
+ (r"\btraceback\b", 4), (r"\bstack ?trace\b", 4),
58
+ (r"\bsegfault\b", 4), (r"\bpanic\b", 3), (r"\bexception\b", 3),
59
+ (r"\bregression\b", 3), (r"\bcrash(es|ed|ing)?\b", 3),
60
+ (r"\bbug\b", 3), (r"\bbroken\b", 3), (r"\bfail(s|ed|ing)?\b", 2),
61
+ (r"\berror\b", 2), (r"\bfix\b", 2), (r"\bwhy (does|is|did|do)\b", 2),
62
+ (r"\bdebug\b", 3), (r"\bhang(s|ing)?\b", 2), (r"\btimeout\b", 2),
63
+ ],
64
+ "test": [
65
+ (r"\bpytest\b", 4), (r"\bunittest\b", 4), (r"\bcoverage\b", 3),
66
+ (r"\bfixture\b", 3), (r"\bmock(s|ed|ing)?\b", 2),
67
+ (r"\bassert(ion)?s?\b", 2), (r"\be2e\b", 3),
68
+ (r"\bintegration test", 3), (r"\btest suite\b", 3),
69
+ (r"\bwrite (a |the )?tests?\b", 3), (r"\btests?\b", 1),
70
+ ],
71
+ "refactor": [
72
+ (r"\brefactor\b", 5), (r"\brestructur", 4), (r"\brename\b", 3),
73
+ (r"\bextract (a |the )?(function|method|class|module)", 4),
74
+ (r"\bdedup", 3), (r"\bsimplif", 3), (r"\bclean ?up\b", 2),
75
+ (r"\bmove .{0,30} into\b", 2), (r"\bsplit .{0,30} into\b", 2),
76
+ (r"\btidy\b", 2), (r"\bconsolidat", 3),
77
+ ],
78
+ "review": [
79
+ (r"\bcode review\b", 5), (r"\breview\b", 3), (r"\baudit\b", 4),
80
+ (r"\binspect\b", 3), (r"\bcritique\b", 3), (r"\bpull request\b", 3),
81
+ (r"\bdiff\b", 2), (r"\bcheck .{0,25} for\b", 2),
82
+ (r"\bread[- ]only\b", 3), (r"\bassess\b", 2),
83
+ ],
84
+ "implement": [
85
+ (r"\bimplement\b", 4), (r"\badd (a |an |the )?", 2),
86
+ (r"\bbuild\b", 2), (r"\bcreate\b", 2), (r"\bwrite (a |an |the )?", 2),
87
+ (r"\bfeature\b", 3), (r"\bsupport for\b", 3), (r"\bintroduce\b", 2),
88
+ (r"\bwire up\b", 3), (r"\bhook up\b", 3), (r"\bendpoint\b", 2),
89
+ (r"\bmake .{0,25} work\b", 2), (r"\bscaffold\b", 3),
90
+ ],
91
+ "research": [
92
+ (r"\bresearch\b", 4), (r"\binvestigate\b", 3), (r"\bcompare\b", 3),
93
+ (r"\bsurvey\b", 3), (r"\barxiv\b", 5), (r"\bpapers?\b", 3),
94
+ (r"\bliterature\b", 4), (r"\bfind out\b", 3), (r"\blook up\b", 3),
95
+ (r"\bwhat is\b", 2), (r"\bhow does\b", 2), (r"\bexplain\b", 2),
96
+ (r"\bsources?\b", 2), (r"\bstate of the art\b", 4),
97
+ ],
98
+ "data-analysis": [
99
+ (r"\banaly[sz]e\b", 3), (r"\banalysis\b", 3), (r"\bmetrics?\b", 3),
100
+ (r"\bstatistic", 3), (r"\bcsv\b", 4), (r"\bsql\b", 4),
101
+ (r"\bdashboard\b", 4), (r"\bdataset\b", 3),
102
+ (r"\baggregat", 3), (r"\bpivot\b", 4), (r"\bcorrelat", 3),
103
+ ],
104
+ "office-docs": [
105
+ (r"\bexcel\b", 5), (r"\bxlsx\b", 5), (r"\bspreadsheet\b", 5),
106
+ (r"\bpowerpoint\b", 5), (r"\bpptx\b", 5), (r"\bslide deck\b", 5),
107
+ (r"\bpresentation\b", 4),
108
+ (r"\bdocx\b", 5), (r"\bword document\b", 5), (r"\boutlook\b", 4),
109
+ (r"\bcalendar\b", 3), (r"\binvoice\b", 3),
110
+ ],
111
+ "creative": [
112
+ (r"\bpoem\b", 5), (r"\bstory\b", 4), (r"\bsong\b", 5),
113
+ (r"\blyrics\b", 5), (r"\bblog post\b", 4), (r"\bessay\b", 4),
114
+ (r"\bcreative\b", 4), (r"\blogo\b", 4), (r"\billustration\b", 4),
115
+ (r"\binfographic\b", 4), (r"\bbrand(ing)?\b", 3),
116
+ (r"\bnarrative\b", 3), (r"\bdesign a\b", 2),
117
+ ],
118
+ "ops": [
119
+ (r"\bdeploy(ment)?\b", 4), (r"\bdocker\b", 4), (r"\bkubernetes\b", 5),
120
+ (r"\bk8s\b", 5), (r"\bterraform\b", 5), (r"\bci/cd\b", 4),
121
+ (r"\binfra(structure)?\b", 4),
122
+ (r"\bnginx\b", 4), (r"\bsystemd\b", 4),
123
+ (r"\bmigrat", 2), (r"\bbackup\b", 3),
124
+ (r"\bmonitor(ing)?\b", 2), (r"\balert(s|ing)?\b", 2),
125
+ ],
126
+ }
127
+
128
+ _COMPILED: dict[str, list[tuple[re.Pattern[str], int]]] = {
129
+ task: [(re.compile(pat, re.IGNORECASE), w) for pat, w in rules]
130
+ for task, rules in _KEYWORDS.items()
131
+ }
132
+
133
+ # Tool-signature boosts. The corpus is coding-heavy, so tool evidence is
134
+ # what separates the coding sub-buckets from each other.
135
+ _EDIT_TOOLS = {"patch", "edit", "write_file", "write", "apply_patch",
136
+ "str_replace_editor", "multiedit", "notebookedit"}
137
+ _WEB_TOOLS = {"web_search", "web_extract", "webfetch", "websearch", "fetch",
138
+ "browser", "browser_exec"}
139
+ _SHELL_TOOLS = {"exec", "bash", "terminal", "exec_command", "powershell",
140
+ "shell", "run_command"}
141
+ _READ_TOOLS = {"read_file", "read", "search_files", "grep", "glob",
142
+ "list_dir", "ls"}
143
+ _TEST_RUNNER_RE = re.compile(
144
+ r"\b(pytest|py\.test|npm (run )?test|yarn test|pnpm test|jest|vitest|"
145
+ r"go test|cargo test|mvn test|gradle test|ctest|tox|nox)\b", re.IGNORECASE)
146
+ _OFFICE_TOOL_RE = re.compile(
147
+ r"\b(xlsx|pptx|docx|openpyxl|python-pptx|python-docx|excel|powerpoint)\b",
148
+ re.IGNORECASE)
149
+
150
+ # Splice/reader scaffolding prepended to the first user row. It names the
151
+ # subagent role ("root/whole_branch_review") and would otherwise dominate
152
+ # keyword scoring — strip it before classifying.
153
+ _SCAFFOLD_RE = re.compile(
154
+ r"^\s*(?:\[subagent brief[^\]]*\]"
155
+ r"|\[trace segment \d+ of \d+[^\]]*\]"
156
+ r"|\[continued[^\]]*\])\s*", re.IGNORECASE)
157
+
158
+ # German task vocabulary — the corpus carries ~8% German traces and the
159
+ # English keyword sets score them as "other".
160
+ _KEYWORDS_DE: dict[str, list[tuple[str, int]]] = {
161
+ "debug": [(r"\bfehler\b", 3), (r"\bbeheben\b", 3), (r"\bdebuggen\b", 4),
162
+ (r"\bkaputt\b", 3), (r"\bschlägt fehl\b", 3),
163
+ (r"\bwarum\b", 2), (r"\bproblem\b", 2)],
164
+ "test": [(r"\btests?\b", 2), (r"\btestabdeckung\b", 4),
165
+ (r"\bprüf(?:en|e|ung)\b", 1)],
166
+ "refactor": [(r"\bumbauen\b", 4), (r"\brefaktor", 5),
167
+ (r"\bvereinfach", 3), (r"\baufräum", 3)],
168
+ "review": [(r"\bprüf(?:en|e|ung)\b", 3), (r"\breview\b", 3),
169
+ (r"\baudit\b", 4), (r"\bbewert", 3)],
170
+ "implement": [(r"\beinbauen\b", 4), (r"\bimplementier", 4),
171
+ (r"\berstell", 3), (r"\bhinzufüg", 3), (r"\bumsetzen\b", 3)],
172
+ "research": [(r"\brecherch", 4), (r"\bvergleich", 3),
173
+ (r"\buntersuch", 3), (r"\banalysier", 2)],
174
+ "data-analysis": [(r"\banalysier", 3), (r"\bauswert", 4),
175
+ (r"\bkennzahlen\b", 4), (r"\bstatistik\b", 3)],
176
+ "office-docs": [(r"\bexcel\b", 5), (r"\bpräsentation\b", 5),
177
+ (r"\bfolien\b", 4), (r"\bbericht\b", 2),
178
+ (r"\btabelle\b", 3)],
179
+ "creative": [(r"\bgedicht\b", 5), (r"\bgeschichte\b", 4),
180
+ (r"\btexten\b", 3), (r"\bgestalt", 3)],
181
+ "ops": [(r"\bdeploy", 4), (r"\bbereitstell", 3), (r"\binfrastruktur\b", 4),
182
+ (r"\bserver\b", 2), (r"\bkonfigurier", 3)],
183
+ }
184
+ _COMPILED_DE: dict[str, list[tuple[re.Pattern[str], int]]] = {
185
+ task: [(re.compile(pat, re.IGNORECASE), w) for pat, w in rules]
186
+ for task, rules in _KEYWORDS_DE.items()
187
+ }
188
+
189
+ # --- language detection -------------------------------------------------
190
+ # The corpus is EN/DE only, so function-word density beats a real detector
191
+ # here: no model call, no dependency, and it is stable on short task texts.
192
+ # The two sets are deliberately disjoint ("war" is dropped: it is a common
193
+ # English noun and a common German verb, so it carries no signal).
194
+ _DE_WORDS = frozenset(
195
+ "der die das den dem des und oder nicht kein keine ist sind wird werden "
196
+ "wurde wurden bitte für mit auf von zu im ein eine einen einem einer auch "
197
+ "noch schon aber wenn dass wie wir ich du sie es hat haben kann können "
198
+ "muss müssen soll sollen nach über unter zwischen durch bei aus als nur "
199
+ "mehr sehr jetzt dann hier dort diese dieser dieses diesem sowie bereits "
200
+ "jeweils".split()
201
+ )
202
+ _EN_WORDS = frozenset(
203
+ "the and or not is are be been to of for with from by at this that these "
204
+ "those it its we you they he she i have has had can could should would "
205
+ "must may do does did but if then than only more very now here there what "
206
+ "which who how when where why all any each every some no yes will was were "
207
+ "so in on as an a".split()
208
+ )
209
+ _WORD_RE = re.compile(r"[A-Za-zÄÖÜäöüß]+")
210
+ _UMLAUT_RE = re.compile(r"[äöüß]", re.IGNORECASE)
211
+
212
+
213
+ def language_scores(text: str) -> tuple[int, int]:
214
+ """(de, en) function-word counts for a text, umlaut bonus included.
215
+
216
+ Public so the structural validator can require a CONFIDENT language
217
+ call (a margin and a ratio) instead of detect_language's tie-break.
218
+ """
219
+ words = _WORD_RE.findall(text.lower())
220
+ de = sum(1 for w in words if w in _DE_WORDS)
221
+ en = sum(1 for w in words if w in _EN_WORDS)
222
+ if _UMLAUT_RE.search(text):
223
+ de += 2 # strong signal that survives very short texts
224
+ return de, en
225
+
226
+
227
+ def detect_language(text: str) -> str:
228
+ """Return 'de' or 'en' for a task text. Defaults to 'en' on a tie."""
229
+ if not text:
230
+ return "en"
231
+ de, en = language_scores(text)
232
+ return "de" if de > en else "en"
233
+
234
+
235
+ LANGUAGE_NAMES = {"en": "English", "de": "German"}
236
+
237
+
238
+ def _tool_names(t: Trace) -> set[str]:
239
+ names: set[str] = set()
240
+ for m in t.messages:
241
+ for tc in m.tool_calls:
242
+ if tc.name:
243
+ names.add(tc.name.lower())
244
+ return names
245
+
246
+
247
+ def _tool_args_blob(t: Trace, limit: int = 20000) -> str:
248
+ parts: list[str] = []
249
+ total = 0
250
+ for m in t.messages:
251
+ for tc in m.tool_calls:
252
+ a = tc.arguments or ""
253
+ parts.append(a)
254
+ total += len(a)
255
+ if total > limit:
256
+ return " ".join(parts)
257
+ return " ".join(parts)
258
+
259
+
260
+ def heuristic_scores(t: Trace) -> dict[str, float]:
261
+ """Raw per-task scores from keywords + tool signature."""
262
+ scores = {task: 0.0 for task in TASKS}
263
+ raw = (t.first_task_message() or "")[:6000]
264
+ text = _SCAFFOLD_RE.sub("", raw)
265
+ for rules in (_COMPILED, _COMPILED_DE):
266
+ for task, task_rules in rules.items():
267
+ for rx, weight in task_rules:
268
+ hits = len(rx.findall(text))
269
+ if hits:
270
+ # diminishing returns: one strong term beats a keyword soup
271
+ scores[task] += weight * (1 + 0.25 * min(hits - 1, 4))
272
+
273
+ tools = _tool_names(t)
274
+ if tools & _EDIT_TOOLS:
275
+ scores["implement"] += 3
276
+ scores["refactor"] += 1.5
277
+ if tools & _WEB_TOOLS:
278
+ scores["research"] += 4
279
+ if tools & _SHELL_TOOLS:
280
+ scores["ops"] += 1
281
+ scores["debug"] += 1
282
+ if tools and not (tools & _EDIT_TOOLS) and (tools & _READ_TOOLS):
283
+ scores["review"] += 2
284
+ if _TEST_RUNNER_RE.search(_tool_args_blob(t)):
285
+ scores["test"] += 4
286
+ if _OFFICE_TOOL_RE.search(_tool_args_blob(t)):
287
+ scores["office-docs"] += 5
288
+ return scores
289
+
290
+
291
+ def classify_heuristic(t: Trace) -> dict[str, Any]:
292
+ """Deterministic label + confidence + the evidence that produced it."""
293
+ scores = heuristic_scores(t)
294
+ ranked = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
295
+ top_task, top = ranked[0]
296
+ second = ranked[1][1] if len(ranked) > 1 else 0.0
297
+ if top <= 0:
298
+ return {"task": "other", "confidence": 0.0, "scores": scores,
299
+ "method": "heuristic"}
300
+ if top == second:
301
+ # a tie is not a label — hand it to the LLM tier or "other"
302
+ return {"task": "other", "confidence": 0.0, "scores": scores,
303
+ "method": "heuristic", "tie": True}
304
+ # confidence: share of the top two, so a clear winner scores near 1.0
305
+ confidence = (top - second) / top
306
+ return {"task": top_task, "confidence": round(confidence, 3),
307
+ "scores": scores, "method": "heuristic"}
308
+
309
+
310
+ # ------------------------------------------------------------- LLM tier
311
+
312
+ _CLASSIFY_SCHEMA: dict[str, Any] = {
313
+ "type": "object",
314
+ "properties": {
315
+ "task": {"type": "string", "enum": list(TASKS)},
316
+ "reason": {"type": "string"},
317
+ },
318
+ "required": ["task"],
319
+ }
320
+
321
+
322
+ def _classify_prompt(t: Trace) -> str:
323
+ task_text = (t.first_task_message() or "")[:2500]
324
+ tools = sorted(_tool_names(t))
325
+ # a few assistant prose lines carry the intent when the user message is
326
+ # terse ("continue", "go on")
327
+ prose = [
328
+ (m.content or "").strip()[:200]
329
+ for m in t.messages
330
+ if m.role == "assistant" and (m.content or "").strip()
331
+ ][:4]
332
+ taxonomy = "\n".join(f"- {k}: {v}" for k, v in TASK_DESCRIPTIONS.items())
333
+ return (
334
+ "Classify this agent session into exactly one task bucket.\n\n"
335
+ f"Buckets:\n{taxonomy}\n\n"
336
+ f"Tools used: {', '.join(tools) or '(none)'}\n\n"
337
+ f"User task:\n{task_text}\n\n"
338
+ f"Assistant prose samples:\n" + "\n".join(f"- {p}" for p in prose) +
339
+ "\n\nAnswer with the single best bucket and a one-line reason."
340
+ )
341
+
342
+
343
+ def classify_llm(t: Trace, cfg) -> Optional[dict[str, Any]]:
344
+ """One structured call. Returns None when the endpoint is unavailable."""
345
+ from traceforge import triage
346
+
347
+ try:
348
+ base, key, model = triage._resolve_endpoint(cfg)
349
+ except Exception:
350
+ return None
351
+ payload = {
352
+ "model": model,
353
+ "messages": [
354
+ {"role": "system",
355
+ "content": "You label agent sessions. Answer with JSON only."},
356
+ {"role": "user", "content": _classify_prompt(t)},
357
+ ],
358
+ "temperature": 0.0,
359
+ "max_tokens": 200,
360
+ "response_format": {
361
+ "type": "json_schema",
362
+ "json_schema": {"name": "task_label",
363
+ "schema": _CLASSIFY_SCHEMA, "strict": True},
364
+ },
365
+ }
366
+ try:
367
+ data = triage._post_json(
368
+ triage._chat_url(base), payload, key, cfg.triage_timeout_s)
369
+ raw = triage._extract_content(data)
370
+ obj = json.loads(raw) if raw else None
371
+ except Exception:
372
+ return None
373
+ if not isinstance(obj, dict) or obj.get("task") not in TASKS:
374
+ return None
375
+ return {"task": obj["task"], "confidence": 1.0,
376
+ "reason": obj.get("reason", ""), "method": "llm"}
377
+
378
+
379
+ def classify_trace(
380
+ t: Trace, cfg=None, *, use_llm: bool = True, min_confidence: float = 0.35,
381
+ ) -> dict[str, Any]:
382
+ """Label one trace.
383
+
384
+ LLM first when an endpoint is configured (it reads intent, which the
385
+ keyword prior cannot); the heuristic is the fallback and is always
386
+ computed so the result carries the evidence for review.
387
+ """
388
+ heuristic = classify_heuristic(t)
389
+ if use_llm and cfg is not None:
390
+ llm = classify_llm(t, cfg)
391
+ if llm:
392
+ llm["heuristic"] = heuristic
393
+ return llm
394
+ return heuristic
traceforge/cli.py ADDED
The diff for this file is too large to render. See raw diff
 
traceforge/config.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """traceforge configuration: defaults + optional YAML overrides."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Optional
7
+
8
+ DEFAULT_HERMES_HOME = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
9
+
10
+
11
+ @dataclass
12
+ class Config:
13
+ # ---------------------------------------------------------------- paths
14
+ hermes_home: str = DEFAULT_HERMES_HOME
15
+ state_db: str = "" # derived from hermes_home when empty
16
+ data_dir: str = "" # derived from repo root when empty
17
+ guidelines_path: str = "" # derived from repo root when empty
18
+ # Seed stores (2026-10-13): the repo ships portable trace archives —
19
+ # harness-traces/{hermes,claude,codex} (a snapshot of all three
20
+ # live stores) and exported-seed-datas/* (codex rollouts from other
21
+ # machines). resolve() auto-appends them to the reader path lists
22
+ # below when the folders exist, so `traceforge generate` /
23
+ # scripts/generate.sh processes them with zero configuration.
24
+ # Lists are ADDITIVE to the live stores; empty = live store only.
25
+ hermes_state_dbs: list[str] = field(default_factory=list)
26
+ claude_projects_dirs: list[str] = field(default_factory=list)
27
+ # Published SFT corpora normalized into the pool by
28
+ # scripts/fetch_nemotron.py (one <subset>.jsonl per dataset subset).
29
+ # Auto-discovered from seed-datasets/nemotron/ when it exists.
30
+ nemotron_roots: list[str] = field(default_factory=list)
31
+
32
+ # -------------------------------------------------------- trace picking
33
+ # min_messages / min_tool_calls are HARD floors: they drop degenerate
34
+ # stubs (2-9 message probes), not long work.
35
+ # max_messages is a SOFT cap (2026-10-23): a session above it is still
36
+ # a candidate, flagged `oversize` in the listing row. The hard cap it
37
+ # replaced silently discarded 3,026 real agentic sessions (563 hermes,
38
+ # 1,246 codex, 1,217 claude — 35% of the viable pool) before they
39
+ # could reach split_long, which is the stage that actually bounds what
40
+ # the pipeline processes: by rendered size (split_max_chars), not by
41
+ # message count. A 200-message session is exactly the long tool-call
42
+ # chain the corpus wants.
43
+ # NOTE: `traceforge list|pull|generate` take these from argparse
44
+ # defaults in cli.py, not from this file — see the report's cli.py
45
+ # patch note.
46
+ min_messages: int = 10
47
+ max_messages: int = 120
48
+ min_tool_calls: int = 2
49
+
50
+ # ------------------------------------------------------- prompt budgets
51
+ user_chars: int = 4000
52
+ assistant_chars: int = 4000
53
+ tool_result_chars: int = 1200
54
+ tool_args_chars: int = 600
55
+ include_reasoning: bool = False
56
+
57
+ # ------------------------------------------------------ improver harness
58
+ model: Optional[str] = None # None = hermes default profile model
59
+ provider: Optional[str] = None
60
+ max_turns: Optional[int] = 16 # improver: read → write → validate → reply, with headroom
61
+ run_budget: Optional[int] = None
62
+ harness_timeout_s: int = 1200
63
+ improver_workdir: str = "" # derived: data_dir/work/ when empty
64
+ skills: list[str] = field(default_factory=list) # -s preloads for improver
65
+
66
+ # ------------------------------------------------- external verification
67
+ verify: bool = True # web-search fact-check stage (adversarial verifier)
68
+ verifier_max_turns: int = 20
69
+ verifier_timeout_s: int = 900
70
+
71
+ # ---------------------------------------------------- review rounds
72
+ # Never trust one reviewer: quality-pillar review runs independently
73
+ # this many times; ALL rounds must PASS for corpus admission.
74
+ review: bool = True
75
+ review_rounds: int = 2
76
+ review_max_turns: int = 16
77
+ review_timeout_s: int = 900 # deep-thinker reviewers verify byte-exact;
78
+ # 600s timed out in practice (2026-09-26)
79
+
80
+ # verification rounds: fact-check runs this many independent times
81
+ verify_rounds: int = 2
82
+
83
+ # ------------------------------------------------- cross-family confirm
84
+ # Second-family confirmation on promotion (residual-defect squeeze):
85
+ # after the primary judge PASSes, ONE selfhosted model from a
86
+ # different family re-judges the final window. VETO blocks promotion
87
+ # (the trace fails honestly and lands on the retry queue).
88
+ confirm_model: Optional[str] = None # e.g. "gemma4-31b-selfhosted"
89
+
90
+ # ---------------------------------------------------------- long traces
91
+ # Traces whose compact rendering exceeds chunk_max_chars go through the
92
+ # map-digest-write pipeline instead of single-shot improvement.
93
+ chunk_max_chars: int = 100_000 # ≈25k tokens per chunk prompt
94
+ chunk_overlap_messages: int = 2 # continuity tail between chunks
95
+ digest_workers: int = 3 # parallel digester sessions
96
+ digest_max_turns: int = 8
97
+ digest_timeout_s: int = 600
98
+ digest_output_clip: int = 6000 # max chars per verbatim output kept
99
+
100
+ # ------------------------------------------------------- live telemetry
101
+ # LiveStats polls state.db child-session counters while batch runs
102
+ # are in flight. TTY: per-trace sublines + tok/s postfix. Pipes: one
103
+ # heartbeat line every live_heartbeat_s. 0 disables the poller.
104
+ live_poll_s: float = 2.0
105
+ live_heartbeat_s: float = 30.0
106
+
107
+ # ------------------------------------------------------------- triage
108
+ # Cheap LLM pass (structured generation, NOT a hermes one-shot) that
109
+ # labels every message keep/drop/rephrase/fact_check/summarize BEFORE
110
+ # the expensive improver runs. Output: data/triage/<sid>.json.
111
+ triage: bool = False
112
+ triage_base_url: str = "" # empty = hermes active-profile endpoint
113
+ triage_model: str = "" # empty = server default
114
+ triage_max_chars: int = 24_000 # per-window char budget (≈6k tokens)
115
+ triage_overlap_messages: int = 4
116
+ triage_workers: int = 8 # parallel window requests
117
+ # Parallel traces in the pre-pass (2026-10-23). The pre-pass used to be
118
+ # serial over traces, so a doubled pool meant hours of silent log before
119
+ # the first promotion. Each trace is self-contained, so the outer loop
120
+ # runs this many traces at once; total in-flight requests ≈
121
+ # triage_prepass_workers × triage_workers.
122
+ triage_prepass_workers: int = 4
123
+ triage_timeout_s: int = 300
124
+ triage_temperature: float = 0.0
125
+
126
+ # ------------------------------------------------- windowed splice engine
127
+ # Direct-HTTP windowed improve path (traceforge/splice.py): no agent
128
+ # runtime, stdlib only. Triage labels drive a mechanical splice; only
129
+ # windows with rephrase/summarize/fact_check edits pay a rewrite call;
130
+ # admission is a windowed judge instead of agent verify/review rounds.
131
+ splice: bool = True # default improve path (False = hermes agents)
132
+ splice_max_chars: int = 24_000 # per-window char budget for rewrites/judge
133
+ splice_workers: int = 8 # parallel window HTTP requests
134
+ splice_temperature: float = 0.2
135
+ rewrite_max_tokens: int = 8000 # per-window rewrite emit ceiling
136
+ rewrite_timeout_s: int = 300 # per rewrite HTTP call
137
+ judge_model: str = "" # empty = same as cfg.model
138
+ judge_max_tokens: int = 3000
139
+ judge_timeout_s: int = 300
140
+ allow_task_reframe: bool = True # intent-only FAIL + good answer ->
141
+ # rewrite the task message once
142
+
143
+ # --------------------------------------------------------------- extras
144
+ raw_cache: bool = True # cache normalized raw traces under data/traces/raw
145
+ overwrite: bool = False
146
+ # Long-trace splitting (2026-10-07, owner directive): raws whose
147
+ # rendered size exceeds split_max_chars become N segment traces
148
+ # <sid>#p1..N at pull time — each ~≤120k tokens, each its own
149
+ # pipeline candidate (triage/splice/judge/promotion per segment).
150
+ split_long: bool = True
151
+ split_max_chars: int = 480_000 # ≈120k tokens per output trace
152
+ # Split TRIGGER (2026-10-24): only traces above this rendered size are
153
+ # split at all; 0 = same as split_max_chars (split everything over the
154
+ # segment ceiling, the pre-2026-10-24 behaviour).
155
+ #
156
+ # Measured on 300KB+ raws re-split three ways (same 3 parents, 19
157
+ # segments, scratch/exp): 480k segments promoted 0/2 at 650s and ~197k
158
+ # tokens each; 240k segments 1/6 (16.7%) at 418s; 120k segments 5/11
159
+ # (45.5%) at 364s and ~76k tokens. Cost per promotion: 802s at 120k vs
160
+ # 2507s at 240k vs never at 480k. The historical corpus agrees — last
161
+ # segments promote 47% at 60-120KB, 42% at 120-180KB, 4.8% at 420-480KB.
162
+ # Recommended pair for the 300KB+ backlog: split_max_chars=120_000 with
163
+ # split_trigger_chars=240_000 (leave the 60-240KB cohort whole: it
164
+ # already promotes at 27-32% and splitting it only doubles the attempts).
165
+ split_trigger_chars: int = 0
166
+ # A tail smaller than this merges into the previous segment instead of
167
+ # becoming its own trace (2026-10-23: the merge branch was a no-op, so
168
+ # 192 of 1,044 segments were under this floor, 31 under 5k, the
169
+ # smallest 714 chars / 2 messages).
170
+ min_segment_chars: int = 60_000
171
+ # Hard ceiling on segments per parent session (2026-10-23). The soft
172
+ # max_messages cap admits long sessions, and a few are pathological:
173
+ # measured, one codex rollout is 50,114 messages / 155M chars, which
174
+ # splits into 317 segments of the same task. At ~10-20 min per
175
+ # segment that is days of batch time for one session, and it makes
176
+ # the pool count and ETA meaningless. Above this ceiling the split
177
+ # keeps the FIRST max-1 segments (task setup + early work) and the
178
+ # LAST one (the real final answer), dropping the middle — the same
179
+ # standalone-window contract every later segment already has. Set 0
180
+ # to disable the ceiling.
181
+ max_segments_per_trace: int = 16
182
+ # Codex rollout stores to read (live store + extracted seed exports).
183
+ # Extra roots are merged into one multi-root index; thread ids are
184
+ # globally unique (uuid v7) so collisions cannot occur.
185
+ codex_roots: list[str] = field(default_factory=list)
186
+ # Claude subagent sidechains (<uuid>/subagents/agent-*.jsonl) as
187
+ # separate candidate traces (MASS arXiv 2610.12176: multi-agent
188
+ # trajectories are the densest training signal; see export._agent_role).
189
+ claude_sidechains: bool = True
190
+
191
+ def resolve(self, repo_root: str) -> "Config":
192
+ self.state_db = self.state_db or os.path.join(self.hermes_home, "state.db")
193
+ self.data_dir = self.data_dir or os.path.join(repo_root, "data")
194
+ self.guidelines_path = self.guidelines_path or os.path.join(
195
+ repo_root, "guidelines", "IDEAL_TRACE_GUIDELINES.md"
196
+ )
197
+ self.improver_workdir = self.improver_workdir or os.path.join(
198
+ self.data_dir, "work"
199
+ )
200
+ self.codex_roots = [os.path.expanduser(r) for r in self.codex_roots]
201
+ # ---- seed-store auto-discovery (see field docs above) --------
202
+ # harness-traces/<store> + exported-seed-datas/* are appended to
203
+ # the reader path lists when present; already-listed paths are
204
+ # never duplicated (YAML overrides + auto-discovery merge).
205
+ def _add_unique(lst: list[str], item: str) -> None:
206
+ if item not in lst:
207
+ lst.append(item)
208
+
209
+ seed_hermes = os.path.join(repo_root, "harness-traces", "hermes", "state.db")
210
+ if os.path.exists(seed_hermes):
211
+ _add_unique(self.hermes_state_dbs, seed_hermes)
212
+ seed_claude = os.path.join(repo_root, "harness-traces", "claude")
213
+ if os.path.isdir(seed_claude):
214
+ _add_unique(self.claude_projects_dirs, seed_claude)
215
+ seed_codex = os.path.join(repo_root, "harness-traces", "codex")
216
+ if os.path.isdir(seed_codex):
217
+ _add_unique(self.codex_roots, seed_codex)
218
+ seed_rollouts = os.path.join(repo_root, "exported-seed-datas")
219
+ if os.path.isdir(seed_rollouts):
220
+ _add_unique(self.codex_roots, seed_rollouts)
221
+ seed_nemotron = os.path.join(repo_root, "seed-datasets", "nemotron")
222
+ if os.path.isdir(seed_nemotron):
223
+ _add_unique(self.nemotron_roots, seed_nemotron)
224
+ self.hermes_state_dbs = [os.path.expanduser(d) for d in self.hermes_state_dbs]
225
+ self.claude_projects_dirs = [
226
+ os.path.expanduser(d) for d in self.claude_projects_dirs
227
+ ]
228
+ self.nemotron_roots = [os.path.expanduser(d) for d in self.nemotron_roots]
229
+ return self
230
+
231
+ @property
232
+ def raw_dir(self) -> str:
233
+ return os.path.join(self.data_dir, "traces", "raw")
234
+
235
+ @property
236
+ def improved_dir(self) -> str:
237
+ return os.path.join(self.data_dir, "traces", "improved")
238
+
239
+ @property
240
+ def prompts_dir(self) -> str:
241
+ return os.path.join(self.data_dir, "prompts")
242
+
243
+ @property
244
+ def triage_dir(self) -> str:
245
+ return os.path.join(self.data_dir, "triage")
246
+
247
+ @property
248
+ def manifest_path(self) -> str:
249
+ return os.path.join(self.data_dir, "manifest.jsonl")
250
+
251
+ # ------------------------------------------------------------------ load
252
+
253
+ @classmethod
254
+ def load(cls, path: Optional[str] = None, repo_root: str = ".") -> "Config":
255
+ cfg = cls()
256
+ if path:
257
+ if not os.path.exists(path):
258
+ raise FileNotFoundError(f"config file not found: {path}")
259
+ try:
260
+ import yaml # optional dependency
261
+ except ImportError as e:
262
+ raise RuntimeError(
263
+ "PyYAML is required for --config files: uv pip install pyyaml"
264
+ ) from e
265
+ with open(path, "r", encoding="utf-8") as f:
266
+ overrides: dict[str, Any] = yaml.safe_load(f) or {}
267
+ unknown = set(overrides) - {f for f in cls.__dataclass_fields__}
268
+ if unknown:
269
+ raise ValueError(f"unknown config keys: {sorted(unknown)}")
270
+ cfg = cls(**{k: v for k, v in overrides.items() if v is not None})
271
+ return cfg.resolve(os.path.abspath(repo_root))
272
+
273
+
274
+ def repo_root_from_pkg() -> str:
275
+ """Repo root as located relative to this package (traceforge/../)."""
276
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
traceforge/defects.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stable defect codes for a failed improve attempt.
2
+
3
+ Why this module exists
4
+ ----------------------
5
+ The pipeline used to record a failure as a free-text `stage` plus an `errors`
6
+ list. That is enough to read one trace and useless for a funnel: "repair 2" and
7
+ "gates fail (2)" are strings, so every report re-derived its own grouping and
8
+ two reports of the same corpus disagreed.
9
+
10
+ A defect code is the smallest thing that makes a loss countable. It is stable
11
+ across runs, it is what the yield table is broken down by, and it is what tells
12
+ you whether a bad week was the writer, the source, or the gate.
13
+
14
+ The codes follow the shared taxonomy in the agentic-sft-synth skill suite
15
+ (`agentic-verify` §"Defect codes") where a code already exists, and add the
16
+ repo-specific ones the suite has no name for. Do not invent a new code for a
17
+ failure that already has one; extend the mapping instead.
18
+
19
+ from traceforge.defects import classify, census
20
+ code = classify(stage="repair 2", errors=["max repair rounds exceeded"])
21
+ # -> "D-REP-01"
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import collections
27
+ import json
28
+ import os
29
+ import re
30
+ from typing import Iterable, Optional
31
+
32
+ # code -> (short name, layer, what it means, what to do about it)
33
+ DEFECTS: dict[str, tuple[str, str, str, str]] = {
34
+ # --- source / task ---------------------------------------------------
35
+ "D-TASK-04": ("no_task", "init",
36
+ "the source session has no user task message",
37
+ "not rewritable; the task lived in another session"),
38
+ "D-TASK-05": ("harness_brief", "init",
39
+ "the only opening user message is a harness dispatch brief",
40
+ "unwrap the brief to the real task, or drop"),
41
+ "D-TASK-02": ("reframe_failed", "init",
42
+ "the task could not be reframed into a single intent",
43
+ "usually several tasks in one session; split it"),
44
+ # --- writer / repair -------------------------------------------------
45
+ "D-REP-01": ("repair_exhausted", "repair",
46
+ "the repair budget ran out before the trace passed",
47
+ "the dominant loss; a third round rarely helps (R-85)"),
48
+ "D-REP-02": ("repair_round_1", "repair",
49
+ "failed after the first repair round",
50
+ "inspect the first violation; often a single bad window"),
51
+ "D-REP-03": ("no_writer_artifact", "writer",
52
+ "the writer produced nothing to verify",
53
+ "check the writer call, not the trace"),
54
+ # --- gates -----------------------------------------------------------
55
+ "D-GATE-01": ("gate_reject", "gate",
56
+ "a hard gate rejected the trace",
57
+ "read the violation; a gate with low precision is R-88"),
58
+ "D-VER-01": ("judge_violation", "judge",
59
+ "the judge found an unsupported claim or a broken pillar",
60
+ "the trace is fluent and wrong; do not ship it"),
61
+ "D-VER-02": ("fabricated_observation", "gate",
62
+ "a tool result shares no content with the call that produced it",
63
+ "fabrication; the trace is unusable, not repairable"),
64
+ # --- format (shared with the suite) ----------------------------------
65
+ "D-FMT-01": ("pairing", "format",
66
+ "tool call/result pairing or turn structure is broken",
67
+ "structural; cheap to detect, never ship"),
68
+ "D-FMT-03": ("final_blob", "format",
69
+ "the final answer is a dump of file contents, not a synthesis",
70
+ "rewrite the closing turn or drop"),
71
+ "D-FMT-04": ("self_talk", "format",
72
+ "the assistant narrates a plan and never acts on it",
73
+ "drop the step; it teaches narration"),
74
+ "D-FMT-05": ("judge_residue", "format",
75
+ "verifier text leaked into the trace",
76
+ "strip it; it is not the model's output"),
77
+ "D-FMT-06": ("clip_marker", "format",
78
+ "a truncation marker sits inside a message",
79
+ "the trace was clipped; drop or re-pull"),
80
+ "D-SAFE-03": ("identity_burn", "safety",
81
+ "the assistant claims a harness identity in a trainable span",
82
+ "strip the claim mechanically before the gates"),
83
+ # --- infra -----------------------------------------------------------
84
+ # 2026-10-26: 191 traces failed triage inside a 37-minute window
85
+ # (01:50-02:27) and every one was recorded as D-UNK-01, because the
86
+ # per-window error text was discarded and no code existed for "the
87
+ # endpoint failed". An infra loss is not a trace defect: it must be
88
+ # retried, never counted against the pool's yield.
89
+ "D-INF-01": ("llm_unavailable", "infra",
90
+ "the model endpoint failed every call for this trace",
91
+ "not a trace defect; retry it, do not count it as a loss"),
92
+ # --- unknown ---------------------------------------------------------
93
+ "D-UNK-01": ("unclassified", "unknown",
94
+ "a failure with no matching rule",
95
+ "extend classify(); an unclassified loss is a reporting bug"),
96
+ }
97
+
98
+ # Ordered rules: first match wins. Order matters — the specific before the
99
+ # general, because "max repair rounds exceeded" also contains "repair".
100
+ _RULES: list[tuple[str, re.Pattern[str]]] = [
101
+ # infra first: when a record carries both a network error and a repair
102
+ # verdict, the network error is the cause and the verdict is its shadow.
103
+ ("D-INF-01", re.compile(
104
+ r"network:|urlopen|WinError 10054|connection (?:reset|refused|aborted)"
105
+ r"|infra error|failed for every window|rate limit", re.I)),
106
+ ("D-TASK-05", re.compile(r"harness dispatch brief|dispatch brief", re.I)),
107
+ ("D-TASK-04", re.compile(r"no user task message|no user task", re.I)),
108
+ ("D-TASK-02", re.compile(r"reframe", re.I)),
109
+ ("D-VER-02", re.compile(r"fabricated tool result|shares no content", re.I)),
110
+ ("D-FMT-01", re.compile(r"multi-turn:.*dropped|pairing|orphan", re.I)),
111
+ ("D-FMT-03", re.compile(r"final_blob|final answer is a (?:file )?dump", re.I)),
112
+ ("D-FMT-04", re.compile(r"self_talk|self-talk", re.I)),
113
+ ("D-FMT-05", re.compile(r"judge_residue|judge residue", re.I)),
114
+ ("D-FMT-06", re.compile(r"clip_marker|truncated", re.I)),
115
+ ("D-SAFE-03", re.compile(r"identity_burn|co-authored-by", re.I)),
116
+ ("D-REP-01", re.compile(r"max repair rounds|repair budget", re.I)),
117
+ ("D-REP-03", re.compile(r"no writer artifact|writer produced nothing", re.I)),
118
+ ("D-VER-01", re.compile(r"judge violations?|unsupported claim", re.I)),
119
+ ("D-GATE-01", re.compile(r"^gate:|gate fail", re.I)),
120
+ ]
121
+
122
+
123
+ def classify(stage: Optional[str] = None,
124
+ errors: Optional[Iterable[str]] = None,
125
+ violations: Optional[Iterable[dict]] = None) -> str:
126
+ """Map a failure record to one stable code.
127
+
128
+ Errors are checked before the stage, because the error text is the specific
129
+ signal and the stage is the bucket. A record with neither is D-UNK-01 —
130
+ which is a bug in the caller, not a property of the trace.
131
+ """
132
+ text = " | ".join(str(e) for e in (errors or []))
133
+ for code, pat in _RULES:
134
+ if pat.search(text):
135
+ return code
136
+
137
+ # violations carry a pillar; a judge violation is the only one we can name
138
+ for v in (violations or []):
139
+ if str(v.get("pillar", "")).upper() in {"P2", "ANSWER"}:
140
+ return "D-VER-01"
141
+
142
+ st = (stage or "").strip().lower()
143
+ if st.startswith("repair 2"):
144
+ return "D-REP-01"
145
+ if st.startswith("repair 1"):
146
+ return "D-REP-02"
147
+ if st.startswith("gates fail"):
148
+ return "D-GATE-01"
149
+ if st.startswith("init"):
150
+ return "D-TASK-04"
151
+ if st.startswith("reframe"):
152
+ return "D-TASK-02"
153
+ return "D-UNK-01"
154
+
155
+
156
+ def census(workdir: str) -> dict[str, int]:
157
+ """Count failure.json records by defect code, newest verdict per trace.
158
+
159
+ Reads only `failure.json` (the latest verdict), not the append-only log, so
160
+ a trace that failed three times counts once.
161
+ """
162
+ counts: collections.Counter[str] = collections.Counter()
163
+ try:
164
+ entries = os.listdir(workdir)
165
+ except OSError:
166
+ return {}
167
+ for name in entries:
168
+ path = os.path.join(workdir, name, "failure.json")
169
+ if not os.path.exists(path):
170
+ continue
171
+ try:
172
+ with open(path, encoding="utf-8") as f:
173
+ row = json.load(f)
174
+ except (OSError, ValueError):
175
+ counts["D-UNK-01"] += 1
176
+ continue
177
+ code = row.get("defect") or classify(
178
+ row.get("stage"), row.get("errors"), row.get("violations"))
179
+ counts[code] += 1
180
+ return dict(counts.most_common())
181
+
182
+
183
+ def describe(code: str) -> str:
184
+ """One line for a report: code, name, meaning."""
185
+ name, layer, meaning, _ = DEFECTS.get(code, DEFECTS["D-UNK-01"])
186
+ return f"{code} {name} [{layer}] — {meaning}"
traceforge/difficulty.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Difficulty bands, measured from the trace itself.
2
+
3
+ The card reported topic and language but not difficulty, so a reader could not
4
+ tell whether the corpus was 2,000 one-shot edits or 2,000 twenty-call debugging
5
+ sessions. Those are different datasets.
6
+
7
+ The bands are the shared ones from the agentic-sft-synth suite
8
+ (`agentic-task-design` §"Difficulty"): the number of tool calls in the ideal
9
+ path, which is the only difficulty signal that is cheap, deterministic, and
10
+ already in the record. It is a proxy — a 20-call mechanical rename is easier
11
+ than a 6-call race-condition hunt — so the card reports it as a distribution,
12
+ never as a score.
13
+
14
+ from traceforge.difficulty import band, census
15
+ band(7) # -> "D3"
16
+ census(traces) # -> {"D1": 12, "D2": 88, ...}
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import collections
22
+ from typing import Iterable
23
+
24
+ # (code, lo, hi, label) — hi is inclusive; the last band is open-ended.
25
+ BANDS: list[tuple[str, int, int, str]] = [
26
+ ("D1", 0, 2, "one-shot / single edit"),
27
+ ("D2", 3, 5, "short loop"),
28
+ ("D3", 6, 10, "multi-step"),
29
+ ("D4", 11, 20, "long session"),
30
+ ("D5", 21, 10**9, "very long session"),
31
+ ]
32
+
33
+ # A trace below this is not a task worth training on: one call and an answer is
34
+ # a lookup, and it teaches the model to stop early.
35
+ MIN_CALLS = 1
36
+
37
+
38
+ def band(n_calls: int) -> str:
39
+ """Map a tool-call count to its band code."""
40
+ for code, lo, hi, _ in BANDS:
41
+ if lo <= n_calls <= hi:
42
+ return code
43
+ return "D5"
44
+
45
+
46
+ def label(code: str) -> str:
47
+ for c, _, _, lab in BANDS:
48
+ if c == code:
49
+ return lab
50
+ return "?"
51
+
52
+
53
+ def count_calls(trace: dict) -> int:
54
+ """Tool calls in an exported record (`{"messages": [...], "meta": ...}`)."""
55
+ n = 0
56
+ for m in trace.get("messages") or []:
57
+ n += len(m.get("tool_calls") or [])
58
+ return n
59
+
60
+
61
+ def census(traces: Iterable[dict]) -> dict[str, int]:
62
+ """Count exported records by band, in band order."""
63
+ counts: collections.Counter[str] = collections.Counter()
64
+ for t in traces:
65
+ counts[band(count_calls(t))] += 1
66
+ return {code: counts.get(code, 0) for code, _, _, _ in BANDS}
traceforge/enrich.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Recover the system prompt and tool definitions the harness actually sent.
2
+
3
+ The readers drop both, so an exported row has tool_calls naming tools nothing
4
+ defines — a model trained on it learns to emit `exec` without ever seeing what
5
+ `exec` accepts. The data is not lost, it is just not in the Trace:
6
+
7
+ - claude ``~/.claude/projects/*/<sid>.jsonl`` records ``api-request-shape``
8
+ whose ``shape`` holds the full ``system`` blocks AND the ``tools``
9
+ array the request was made with.
10
+ - codex ``~/.codex/sessions/**/rollout-*-<sid>.jsonl`` opens with
11
+ ``session_meta.payload.base_instructions.text`` — the prompt, but
12
+ not the schemas (those live in the codex binary).
13
+ - hermes ``state.db``: ``sessions.system_prompt_hash`` -> ``system_prompts``.
14
+ - nemotron already embeds a ``## Available tools`` block in its system row.
15
+
16
+ Where the harness did not record schemas, the export falls back to
17
+ ``data/toolspec.json`` — schemas derived from the arguments the corpus
18
+ actually passes, with per-property evidence counts. That is evidence, not
19
+ invention, and ``meta.tools_source`` says which one a row got.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import glob
24
+ import json
25
+ import os
26
+ import sqlite3
27
+ from typing import Any, Optional
28
+
29
+ CLAUDE_GLOB = os.path.expanduser("~/.claude/projects/*/*.jsonl")
30
+ CODEX_GLOB = os.path.expanduser("~/.codex/sessions/**/*.jsonl")
31
+ CODEX_ZST_GLOB = os.path.expanduser("~/.codex/sessions/**/*.jsonl.zst")
32
+ HERMES_DB = os.path.expanduser("~/AppData/Local/hermes/state.db")
33
+ TOOLSPEC = "data/toolspec.json"
34
+ RAW_DIR = "data/traces/raw"
35
+
36
+
37
+ def _sid_from_codex(path: str) -> Optional[str]:
38
+ """rollout-<iso>-<uuid>.jsonl -> <uuid>."""
39
+ base = os.path.basename(path)
40
+ if not base.startswith("rollout-"):
41
+ return None
42
+ stem = base
43
+ for suf in (".jsonl.zst", ".jsonl"):
44
+ if stem.endswith(suf):
45
+ stem = stem[: -len(suf)]
46
+ break
47
+ parts = stem.split("-")
48
+ # uuid is the last 5 dash groups
49
+ if len(parts) < 5:
50
+ return None
51
+ return "-".join(parts[-5:])
52
+
53
+
54
+ def _read_claude_shape(path: str) -> tuple[Optional[str], Optional[list]]:
55
+ """(system_text, tools) from the first api-request-shape in the file."""
56
+ try:
57
+ with open(path, encoding="utf-8") as f:
58
+ for line in f:
59
+ if "api-request-shape" not in line:
60
+ continue
61
+ try:
62
+ r = json.loads(line)
63
+ except Exception:
64
+ continue
65
+ if r.get("type") != "api-request-shape":
66
+ continue
67
+ shape = r.get("shape") or {}
68
+ blocks = shape.get("system") or []
69
+ text = "\n\n".join(
70
+ b.get("text", "") for b in blocks
71
+ if isinstance(b, dict) and b.get("text"))
72
+ tools = shape.get("tools") or None
73
+ return (text or None), tools
74
+ except OSError:
75
+ pass
76
+ return None, None
77
+
78
+
79
+ def _read_codex_prompt(path: str) -> Optional[str]:
80
+ # 587 of the rollouts on this host are zstd-compressed in place; the codex
81
+ # reader already knows how to open them, so reuse it rather than
82
+ # reimplementing the decompression here.
83
+ try:
84
+ from traceforge.readers.codex import _open_rollout
85
+ opener = _open_rollout
86
+ except Exception:
87
+ opener = lambda p: open(p, encoding="utf-8")
88
+ try:
89
+ with opener(path) as f:
90
+ for line in f:
91
+ if "session_meta" not in line:
92
+ continue
93
+ try:
94
+ r = json.loads(line)
95
+ except Exception:
96
+ continue
97
+ if r.get("type") != "session_meta":
98
+ continue
99
+ bi = (r.get("payload") or {}).get("base_instructions")
100
+ if isinstance(bi, dict):
101
+ return bi.get("text") or None
102
+ if isinstance(bi, str):
103
+ return bi or None
104
+ return None
105
+ except OSError:
106
+ pass
107
+ return None
108
+
109
+
110
+ def _hermes_prompts() -> dict[str, str]:
111
+ """session_id -> system prompt, via the hash join."""
112
+ out: dict[str, str] = {}
113
+ if not os.path.exists(HERMES_DB):
114
+ return out
115
+ try:
116
+ c = sqlite3.connect(f"file:{HERMES_DB}?mode=ro", uri=True)
117
+ c.row_factory = sqlite3.Row
118
+ prompts = {r["hash"]: r["prompt"] for r in
119
+ c.execute("select hash, prompt from system_prompts")}
120
+ for r in c.execute(
121
+ "select id, system_prompt_hash from sessions "
122
+ "where system_prompt_hash is not null"):
123
+ p = prompts.get(r["system_prompt_hash"])
124
+ if p:
125
+ out[r["id"]] = p
126
+ c.close()
127
+ except sqlite3.Error:
128
+ pass
129
+ return out
130
+
131
+
132
+ _INDEX_CACHE: Optional[dict] = None
133
+
134
+
135
+ def load_index(verbose: bool = False) -> dict[str, dict]:
136
+ """session_id -> {system, tools, source}.
137
+
138
+ Memoized: `export --by-topic` calls export_chat_jsonl once per topic, and
139
+ rebuilding this index each time re-reads 587 compressed rollouts and 401
140
+ claude transcripts — ten times the I/O for one export.
141
+ """
142
+ global _INDEX_CACHE
143
+ if _INDEX_CACHE is not None:
144
+ return _INDEX_CACHE
145
+ idx: dict[str, dict] = {}
146
+
147
+ for p in glob.glob(CLAUDE_GLOB):
148
+ sid = os.path.basename(p)[:-6] # strip ".jsonl" (6 chars)
149
+ sys_text, tools = _read_claude_shape(p)
150
+ if sys_text or tools:
151
+ idx[sid] = {"system": sys_text, "tools": tools,
152
+ "source": "claude-request-shape"}
153
+
154
+ codex_files = (glob.glob(CODEX_GLOB, recursive=True)
155
+ + glob.glob(CODEX_ZST_GLOB, recursive=True))
156
+ for p in codex_files:
157
+ sid = _sid_from_codex(p)
158
+ if not sid or sid in idx:
159
+ continue
160
+ sys_text = _read_codex_prompt(p)
161
+ if sys_text:
162
+ idx[sid] = {"system": sys_text, "tools": None,
163
+ "source": "codex-base-instructions"}
164
+
165
+ for sid, prompt in _hermes_prompts().items():
166
+ if sid in idx:
167
+ continue
168
+ idx[sid] = {"system": prompt, "tools": None,
169
+ "source": "hermes-system-prompts"}
170
+
171
+ if verbose:
172
+ print(f"enrich index: {len(idx)} sessions "
173
+ f"({sum(1 for v in idx.values() if v['tools'])} with real tools)")
174
+ _INDEX_CACHE = idx
175
+ return idx
176
+
177
+
178
+ _SPEC_CACHE: Optional[dict] = None
179
+
180
+
181
+ def load_toolspec(path: str = TOOLSPEC) -> dict[str, dict]:
182
+ global _SPEC_CACHE
183
+ if _SPEC_CACHE is not None:
184
+ return _SPEC_CACHE
185
+ if not os.path.exists(path):
186
+ _SPEC_CACHE = {}
187
+ return _SPEC_CACHE
188
+ try:
189
+ _SPEC_CACHE = (json.load(open(path, encoding="utf-8")) or {}).get("tools") or {}
190
+ except Exception:
191
+ _SPEC_CACHE = {}
192
+ return _SPEC_CACHE
193
+
194
+
195
+ _RAW_SYS_CACHE: dict[str, Optional[str]] = {}
196
+
197
+
198
+ def _raw_system(sid: str, raw_dir: str = RAW_DIR) -> Optional[str]:
199
+ """The system row from the RAW trace, if it has one.
200
+
201
+ The improver drops the system message, so a nemotron trace arrives with no
202
+ prompt even though its raw carries a `## Available tools` block (12,878
203
+ raws on this host have one). Recover it here rather than re-generating.
204
+ """
205
+ if sid in _RAW_SYS_CACHE:
206
+ return _RAW_SYS_CACHE[sid]
207
+ out = None
208
+ path = os.path.join(raw_dir, sid + ".json")
209
+ if os.path.exists(path):
210
+ try:
211
+ t = json.load(open(path, encoding="utf-8"))
212
+ for m in t.get("messages") or []:
213
+ if m.get("role") == "system" and (m.get("content") or "").strip():
214
+ out = m["content"]
215
+ break
216
+ except Exception:
217
+ out = None
218
+ _RAW_SYS_CACHE[sid] = out
219
+ return out
220
+
221
+
222
+ def _called_names(msgs: list[dict]) -> list[str]:
223
+ seen: list[str] = []
224
+ for m in msgs:
225
+ for tc in (m.get("tool_calls") or []):
226
+ fn = tc.get("function") or {}
227
+ name = fn.get("name") or tc.get("name")
228
+ if name and name not in seen:
229
+ seen.append(name)
230
+ return seen
231
+
232
+
233
+ def _derived_tool(name: str, spec: dict) -> Optional[dict]:
234
+ """OpenAI-format tool definition from the observed-call evidence."""
235
+ s = spec.get(name)
236
+ if not s:
237
+ return None
238
+ props = {}
239
+ for k, v in (s.get("properties") or {}).items():
240
+ props[k] = {"type": v.get("type", "string")}
241
+ ex = v.get("examples") or []
242
+ if ex:
243
+ props[k]["description"] = f"e.g. {ex[0][:120]}"
244
+ return {
245
+ "type": "function",
246
+ "function": {
247
+ "name": name,
248
+ "description": (f"Derived from {s.get('calls', 0)} observed calls "
249
+ f"in this corpus; property types and required "
250
+ f"list come from the arguments actually passed."),
251
+ "parameters": {
252
+ "type": "object",
253
+ "properties": props,
254
+ "required": list(s.get("required") or []),
255
+ },
256
+ },
257
+ }
258
+
259
+
260
+ def _to_openai_tool(t: dict) -> Optional[dict]:
261
+ """Normalize one recorded tool schema to OpenAI `{type, function}`.
262
+
263
+ The claude reader keeps the request's own shape, which is Anthropic:
264
+ `{name, description, input_schema}`. Every other schema in the export
265
+ (derived ones, the nemotron block) is OpenAI chat format, so a trainer
266
+ applying the chat template would meet two incompatible shapes in one
267
+ file. Convert at export time rather than at capture: the raw store
268
+ should keep what the harness actually sent.
269
+ """
270
+ if not isinstance(t, dict):
271
+ return None
272
+ if t.get("type") == "function" and isinstance(t.get("function"), dict):
273
+ return t # already OpenAI
274
+ name = t.get("name")
275
+ if not name:
276
+ return None
277
+ params = t.get("input_schema") or t.get("parameters")
278
+ if not isinstance(params, dict):
279
+ params = {"type": "object", "properties": {}}
280
+ return {
281
+ "type": "function",
282
+ "function": {
283
+ "name": name,
284
+ "description": t.get("description") or "",
285
+ "parameters": params,
286
+ },
287
+ }
288
+
289
+
290
+ def enrich(rec: dict, sid: str, index: dict, spec: dict) -> dict:
291
+ """Attach `tools` and a leading system message to one export record."""
292
+ msgs = rec.get("messages") or []
293
+ rec.setdefault("meta", {})
294
+ entry = index.get(sid) or {}
295
+
296
+ # ---- tools: real schemas if the harness recorded them, else derived
297
+ tools = entry.get("tools")
298
+ if tools:
299
+ norm = [x for x in (_to_openai_tool(t) for t in tools) if x]
300
+ if norm:
301
+ # The recorded request can list fewer tools than the trace
302
+ # actually called (a deferred tool, or one the harness added
303
+ # after the request was captured). Fill the gap from the
304
+ # observed-call evidence so no call names a tool nothing
305
+ # defines, and mark the additions so a derived schema is never
306
+ # mistaken for a recorded one.
307
+ have = {(t.get("function") or {}).get("name") for t in norm}
308
+ extra = [d for d in (_derived_tool(n, spec)
309
+ for n in _called_names(msgs))
310
+ if d and (d.get("function") or {}).get("name") not in have]
311
+ rec["tools"] = norm + extra
312
+ rec["meta"]["tools_source"] = entry.get("source")
313
+ if extra:
314
+ rec["meta"]["tools_derived_extra"] = [
315
+ (d.get("function") or {}).get("name") for d in extra]
316
+ else:
317
+ names = _called_names(msgs)
318
+ derived = [d for d in (_derived_tool(n, spec) for n in names) if d]
319
+ if derived:
320
+ rec["tools"] = derived
321
+ rec["meta"]["tools_source"] = "derived-from-observed-calls"
322
+ rec["meta"]["tools_undefined"] = [
323
+ n for n in names if n not in spec]
324
+
325
+ # ---- system prompt: prepend unless the trace already opens with one
326
+ sys_text = entry.get("system")
327
+ if not sys_text:
328
+ sys_text = _raw_system(sid)
329
+ if sys_text:
330
+ entry = dict(entry, source="raw-system-row")
331
+ if sys_text and not (msgs and msgs[0].get("role") == "system"):
332
+ rec["messages"] = [{"role": "system", "content": sys_text}] + msgs
333
+ rec["meta"]["system_prompt_source"] = entry.get("source")
334
+ return rec
traceforge/export.py ADDED
@@ -0,0 +1,564 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export improved traces to training formats.
2
+
3
+ chat_jsonl: OpenAI-style chat fine-tune JSONL — one line per trace:
4
+ {"messages": [{"role","content"[,"tool_calls"][,"tool_call_id"...]}]}
5
+ Reasoning is dropped by default (keep_reasoning=False): the corpus trains on
6
+ the assistant's visible turns, and the reasoning channel is not part of the
7
+ served format. Pass keep_reasoning=True to retain it under "reasoning".
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import difflib
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ from typing import Optional
17
+
18
+ from traceforge.trace import Trace
19
+
20
+ # Progress narration: the agent announcing what it is about to do instead of
21
+ # doing it. Same shape the SFT reframe gate rejects (_AGENT_NARRATION_RE in
22
+ # splice.py); duplicated here so export does not import the splice module.
23
+ _NARRATION_RE = re.compile(
24
+ r"\b(?:I'll|I will|Let me|Next,? I|Now I"
25
+ r"|Ich (?:prüfe|führe|werde|setze|beginne|starte|nehme))\b",
26
+ re.I,
27
+ )
28
+
29
+
30
+ def _msg_to_chat(m, keep_reasoning: bool = False) -> Optional[dict]:
31
+ d: dict = {"role": m.role}
32
+ if m.content is not None:
33
+ d["content"] = m.content
34
+ if keep_reasoning and m.reasoning:
35
+ d["reasoning"] = m.reasoning
36
+ if m.tool_calls:
37
+ d["tool_calls"] = [
38
+ {
39
+ "id": tc.id,
40
+ "type": "function",
41
+ "function": {"name": tc.name, "arguments": tc.arguments},
42
+ }
43
+ for tc in m.tool_calls
44
+ ]
45
+ if m.role == "tool":
46
+ d["tool_call_id"] = m.tool_call_id or ""
47
+ if m.tool_name:
48
+ d["name"] = m.tool_name
49
+ return d
50
+
51
+
52
+ # Delegation tool names across the reader corpus (Claude 'Task', codex
53
+ # thread_spawn, hermes delegate_task): a trace calling one of these is
54
+ # an ORCHESTRATOR — it divides work across subagents. MASS (arXiv
55
+ # 2610.12176) trains on orchestrator AND subagent conversations as
56
+ # separate sequences; tagging lets the trainer balance the two roles
57
+ # instead of sampling blind.
58
+ _DELEGATION_TOOLS = {
59
+ "task", "delegate_task", "launchsubagent", "spawn_subagent",
60
+ "thread_spawn", "agent",
61
+ }
62
+
63
+
64
+ def _agent_role(t: Trace) -> Optional[str]:
65
+ """'orchestrator' | 'subagent' | None, derived mechanically.
66
+
67
+ subagent: the sid carries the reader's sidechain namespace
68
+ ('<parent>#sa-<agent>'), or provenance says the trace IS a
69
+ subagent's own thread (source claude-sidechain, or a codex child
70
+ rollout). The sid check matters because the improve stage rewrites
71
+ traces and overwrites source with 'traceforge-splice', while the
72
+ namespaced sid is preserved verbatim.
73
+ orchestrator: the trace issues a delegation tool call.
74
+ """
75
+ if "#sa-" in (t.session_id or ""):
76
+ return "subagent"
77
+ src = (t.source or "").lower()
78
+ if "sidechain" in src:
79
+ return "subagent"
80
+ for m in t.messages:
81
+ for tc in m.tool_calls:
82
+ if (tc.name or "").lower() in _DELEGATION_TOOLS:
83
+ return "orchestrator"
84
+ return None
85
+
86
+
87
+ def export_chat_jsonl(
88
+ traces: list[Trace], out_path: str, *, append: bool = False,
89
+ keep_reasoning: bool = False, enrich: bool = True,
90
+ ) -> int:
91
+ """Write improved traces as chat-format JSONL. Returns lines written.
92
+
93
+ keep_reasoning=False (default) drops per-message reasoning: harness
94
+ self-talk is not training signal. Flip on when the training target
95
+ includes a reasoning channel.
96
+
97
+ enrich=True (default) attaches the `tools` array and, where the harness
98
+ recorded one, the system prompt. Without it a row names tools nothing
99
+ defines — see traceforge/enrich.py.
100
+ """
101
+ os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
102
+ mode = "a" if append else "w"
103
+ n = 0
104
+ _idx = None
105
+ _spec = None
106
+ if enrich:
107
+ from traceforge.enrich import load_index, load_toolspec
108
+ _idx = load_index()
109
+ _spec = load_toolspec()
110
+ with open(out_path, mode, encoding="utf-8") as f:
111
+ for t in traces:
112
+ msgs = [ _msg_to_chat(m, keep_reasoning) for m in t.messages ]
113
+ msgs = [m for m in msgs if m and (m.get("content") or m.get("tool_calls") or m.get("tool_call_id"))]
114
+ if not msgs:
115
+ continue
116
+ meta = {
117
+ "traceforge_session_id": t.session_id,
118
+ "parent_session_id": t.parent_session_id,
119
+ "source": t.source,
120
+ }
121
+ role = _agent_role(t)
122
+ if role:
123
+ meta["agent_role"] = role
124
+ # docs/DATA_QUALITY.md §4.1: a retained recovery is labelled so a
125
+ # trainer can filter it out and the corpus can report density.
126
+ if recovery_label(t):
127
+ meta["recovered"] = True
128
+ rec = {"messages": msgs, "meta": meta}
129
+ if enrich:
130
+ from traceforge.enrich import enrich as _enrich
131
+ sid = t.session_id
132
+ if sid.startswith("ideal-"):
133
+ sid = sid[6:]
134
+ sid = sid.split("#")[0]
135
+ rec = _enrich(rec, sid, _idx or {}, _spec or {})
136
+ # Time sensitivity: measured on the RECORD, because the enriched
137
+ # row carries the `tools` array and "did the agent have a
138
+ # retrieval tool" is what separates a decaying answer from a
139
+ # durable procedure. Absent = not time-sensitive.
140
+ try:
141
+ from traceforge.timesensitivity import detect as _ts_detect
142
+ ts = _ts_detect(rec)
143
+ if ts["class"] != "not_time_sensitive":
144
+ rec["meta"]["time_sensitivity"] = ts["class"]
145
+ if ts.get("temporal_facts"):
146
+ rec["meta"]["time_sensitivity_facts"] = ts["temporal_facts"]
147
+ if ts.get("needs_rewrite"):
148
+ rec["meta"]["time_sensitivity_needs_rewrite"] = True
149
+ except Exception:
150
+ pass
151
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
152
+ n += 1
153
+ return n
154
+
155
+
156
+ # --------------------------------------------------------------------------
157
+ # Corpus-level obligations (docs/DATA_QUALITY.md §4.3, rules 6 and 9)
158
+ # --------------------------------------------------------------------------
159
+
160
+ # An ENVIRONMENT-caused failure is one the ideal agent could not have
161
+ # foreseen: a flaky endpoint, a missing dependency, an exhausted resource.
162
+ # A MODEL-caused failure (wrong tool, wrong args, wrong path, bad reasoning)
163
+ # is never eligible for the §4.1 carve-out — the ideal path routes around it.
164
+ #
165
+ # The match is anchored to the HEAD of the tool output, because a harness
166
+ # reports status there. Scanning the whole output matches error text quoted
167
+ # inside source code and file listings: measured on this corpus, a loose
168
+ # pattern labelled 32% of traces "recovered" where the real figure is ~12%,
169
+ # with `502`/`429` matching line numbers (`502- capture_output=True`) and
170
+ # `timeout` matching documentation prose. Bare status codes are therefore
171
+ # excluded; only an HTTP-context form counts.
172
+ _ENV_ERROR_RE = re.compile(
173
+ r"(?i)(command not found"
174
+ r"|modulenotfounderror|cannot find module"
175
+ r"|connection (refused|reset|timed out|aborted)"
176
+ r"|(?:error|fatal|wait|request|operation|connection)[^\n]{0,40}"
177
+ r"(?:timed out|timeout)"
178
+ r"|temporarily unavailable|service unavailable"
179
+ r"|bad gateway|too many requests"
180
+ r"|network is unreachable|name or service not known"
181
+ r"|no space left on device|resource temporarily unavailable"
182
+ r"|too many open files)"
183
+ )
184
+ _MODEL_ERROR_RE = re.compile(
185
+ r"(?i)(invalid (argument|json|parameter|request)|unknown tool"
186
+ r"|tool not found|jsondecodeerror|unexpected token"
187
+ r"|missing required|schema validation|permission denied"
188
+ r"|operation not permitted|not a git repository"
189
+ r"|is a directory|no such file or directory)"
190
+ )
191
+ # How much of a tool output counts as its status head.
192
+ _ERR_HEAD = 200
193
+ # A grep/nl/`cat -n` listing starts with a line number. Its text is file
194
+ # content, not a status line — `39:const RESOLVE_TIMEOUT_DETAIL: &str =
195
+ # "Timed out opening chat event stream"` is a string literal, not a failure.
196
+ _LISTING_RE = re.compile(r"^\s*\d+[:-]")
197
+
198
+
199
+ def _call_sig(tc) -> tuple[str, str]:
200
+ return ((tc.name or "").lower(),
201
+ re.sub(r"\s+", " ", (tc.arguments or "")).strip()[:300])
202
+
203
+
204
+ def recovery_label(t: Trace) -> bool:
205
+ """True when the trace retains exactly one environment-caused failure
206
+ followed by exactly one corrective step (docs/DATA_QUALITY.md §4.1).
207
+
208
+ Disqualified by a model-caused error, by a repeated identical failed call
209
+ (a retry loop, not a correction), or by an error with no corrective step
210
+ after it. Conservative by design: the carve-out is an exception, not a
211
+ license, so more than one retained error also disqualifies.
212
+ """
213
+ msgs = t.messages
214
+ env_hits = 0
215
+ for i, m in enumerate(msgs):
216
+ if m.role != "tool":
217
+ continue
218
+ c = (m.content or "")[:_ERR_HEAD]
219
+ if _LISTING_RE.match(c):
220
+ continue # file content, not a status line
221
+ if not _ENV_ERROR_RE.search(c) or _MODEL_ERROR_RE.search(c):
222
+ continue
223
+ call = None
224
+ for j in range(i - 1, -1, -1):
225
+ if msgs[j].role == "assistant" and msgs[j].tool_calls:
226
+ call = msgs[j].tool_calls[-1]
227
+ break
228
+ if call is None:
229
+ continue
230
+ nxt = None
231
+ for j in range(i + 1, len(msgs)):
232
+ if msgs[j].role == "assistant" and msgs[j].tool_calls:
233
+ nxt = msgs[j].tool_calls[0]
234
+ break
235
+ if nxt is None:
236
+ continue
237
+ if _call_sig(nxt) == _call_sig(call):
238
+ return False # retry loop: disqualified outright
239
+ env_hits += 1
240
+ return env_hits == 1
241
+
242
+
243
+ def dedup_key(t: Trace) -> tuple[str, str]:
244
+ """(task, tool-sequence) identity — docs/DATA_QUALITY.md rule 9.
245
+
246
+ Task text alone over-merges (one brief solved two ways is two examples);
247
+ the tool sequence alone over-merges (unrelated tasks share exec>exec).
248
+ The pair is what repeats when a session is re-pulled or a split segment
249
+ is re-improved.
250
+
251
+ The sequence carries a digest of the call ARGUMENTS, not just the tool
252
+ names (2026-10-23). Name-only was measurably destructive: on 1,246
253
+ improved traces it removed 18, and every one was a DISTINCT session —
254
+ 0 with an identical body, 0 with an identical first assistant turn.
255
+ Nine separate "watch the CI and report" sessions all reduce to
256
+ ``exec>exec>exec`` and were collapsed into one; the arguments are what
257
+ distinguishes them (a different job id per session). Arguments survive
258
+ re-improvement verbatim except on the splice engine's malformed-args
259
+ repair path, so a genuine re-pull still matches; a repaired duplicate
260
+ that slips through is one extra near-duplicate, which is far cheaper
261
+ than deleting real sessions.
262
+ """
263
+ task = ""
264
+ for m in t.messages:
265
+ if m.role == "user":
266
+ task = re.sub(r"\s+", " ", (m.content or "")).strip().lower()[:400]
267
+ break
268
+ names: list[str] = []
269
+ digest = hashlib.sha1()
270
+ for m in t.messages:
271
+ for c in (m.tool_calls or []):
272
+ name = (c.name or "").lower()
273
+ names.append(name)
274
+ digest.update(name.encode("utf-8", "replace"))
275
+ digest.update(b"\x00")
276
+ digest.update((c.arguments or "").encode("utf-8", "replace"))
277
+ digest.update(b"\x00")
278
+ return task, ">".join(names) + "#" + digest.hexdigest()[:16]
279
+
280
+
281
+ def dedup_traces(traces: list[Trace]) -> tuple[list[Trace], list[Trace]]:
282
+ """Keep the most complete trace per (task, tool-sequence).
283
+
284
+ Returns (kept, removed). Deterministic — more messages wins, then more
285
+ tool calls, then session id — so re-exporting a grown corpus keeps the
286
+ same survivor instead of reshuffling the split.
287
+ """
288
+ best: dict[tuple[str, str], Trace] = {}
289
+ for t in traces:
290
+ k = dedup_key(t)
291
+ cur = best.get(k)
292
+ if cur is None:
293
+ best[k] = t
294
+ continue
295
+ a = (len(t.messages), sum(len(m.tool_calls or []) for m in t.messages))
296
+ b = (len(cur.messages), sum(len(m.tool_calls or []) for m in cur.messages))
297
+ if a > b or (a == b and (t.session_id or "") < (cur.session_id or "")):
298
+ best[k] = t
299
+ kept = list(best.values())
300
+ removed = [t for t in traces if best.get(dedup_key(t)) is not t]
301
+ return kept, removed
302
+
303
+
304
+ # --------------------------------------------------------------------------
305
+ # Preference corpus (KTO) — docs/DATA_QUALITY.md §4.4, rule 10
306
+ # --------------------------------------------------------------------------
307
+ # This is NOT a relabelling of the SFT corpus. KTO needs rejected samples
308
+ # from the policy being aligned: a negative the policy would never produce is
309
+ # a length/style gradient, not a preference signal. So the flow is staged:
310
+ #
311
+ # 1. extract_prompts() -> kto_prompts.jsonl: rollout seeds taken from the
312
+ # SFT corpus (the cleaned prefix at each assistant turn boundary).
313
+ # 2. after SFT, sample N completions per prompt from the SFT model, verify
314
+ # each against the trace's own tool evidence, and label it.
315
+ # 3. build_kto() -> kto.jsonl: length-matched, bounded-loss contract.
316
+ #
317
+ # Stage 2 needs a served model and is deliberately not in this module.
318
+
319
+ def extract_prompts(traces, *, max_prompts: int = 0, turns: str = "final",
320
+ max_prefix_chars: int = 0) -> list[dict]:
321
+ """Rollout seeds: (cleaned prefix, reference completion).
322
+
323
+ The prefix is the ideal trace's own context, so a rollout starts from the
324
+ distribution the model sees at inference — not from the raw trace's
325
+ messier prefix.
326
+
327
+ turns="final" (default) emits one seed per trace: the final assistant
328
+ turn, whose outcome the trace's own verification already covers. That is
329
+ the turn a KTO label can be trusted on. turns="all" emits every assistant
330
+ turn — 14k seeds and ~940 MB of prefixes on the current corpus — for
331
+ step-level preference work where each step has its own verifier.
332
+
333
+ max_prefix_chars > 0 keeps the opening task message and the most recent
334
+ turns, with an explicit elision marker, so a seed stays a bounded size.
335
+ """
336
+ out: list[dict] = []
337
+ for t in traces:
338
+ msgs = t.messages
339
+ idx = [i for i, m in enumerate(msgs)
340
+ if m.role == "assistant" and (m.content or m.tool_calls)]
341
+ if turns == "final":
342
+ idx = idx[-1:]
343
+ for i in idx:
344
+ prefix = msgs[:i]
345
+ if not any(p.role == "user" for p in prefix):
346
+ continue
347
+ chat = [m for m in (_msg_to_chat(p) for p in prefix) if m]
348
+ if max_prefix_chars:
349
+ chat = _clip_prefix(chat, max_prefix_chars)
350
+ out.append({
351
+ "session_id": t.session_id,
352
+ "turn": i,
353
+ "prompt": chat,
354
+ "reference": _msg_to_chat(msgs[i]),
355
+ })
356
+ if max_prompts and len(out) >= max_prompts:
357
+ return out
358
+ return out
359
+
360
+
361
+ def _clip_prefix(chat: list[dict], limit: int) -> list[dict]:
362
+ """Keep the opening task and the most recent turns under `limit` chars."""
363
+ def size(m: dict) -> int:
364
+ return len(json.dumps(m, ensure_ascii=False))
365
+ if sum(size(m) for m in chat) <= limit:
366
+ return chat
367
+ head = chat[:1]
368
+ used = size(head[0])
369
+ tail: list[dict] = []
370
+ for m in reversed(chat[1:]):
371
+ n = size(m)
372
+ if used + n > limit:
373
+ break
374
+ tail.append(m)
375
+ used += n
376
+ tail.reverse()
377
+ return head + [{"role": "system",
378
+ "content": "[earlier turns elided for length]"}] + tail
379
+
380
+
381
+ def build_kto(rollouts, *, max_len_ratio: float = 1.5) -> tuple[list[dict], dict]:
382
+ """Turn labelled rollouts into KTO rows, length-matched.
383
+
384
+ Each rollout: {"prompt": [...], "completion": {...}, "label": bool}.
385
+ A negative whose completion is more than `max_len_ratio` times the
386
+ shortest desirable completion for the same prompt is dropped — otherwise
387
+ the only signal is length (arXiv:2403.19159). A prompt with no desirable
388
+ sample contributes nothing: an unpaired negative is noise, not a label.
389
+ """
390
+ by_prompt: dict[str, list[dict]] = {}
391
+ for r in rollouts:
392
+ key = json.dumps(r.get("prompt"), sort_keys=True, ensure_ascii=False)
393
+ by_prompt.setdefault(key, []).append(r)
394
+ rows: list[dict] = []
395
+ dropped_len = dropped_unpaired = 0
396
+ for group in by_prompt.values():
397
+ pos = [r for r in group if r.get("label")]
398
+ neg = [r for r in group if not r.get("label")]
399
+ if not pos:
400
+ dropped_unpaired += len(neg)
401
+ continue
402
+ ref_len = min(len(json.dumps(r["completion"], ensure_ascii=False))
403
+ for r in pos)
404
+ for r in pos:
405
+ rows.append({"prompt": r["prompt"],
406
+ "completion": r["completion"], "label": True})
407
+ for r in neg:
408
+ n = len(json.dumps(r["completion"], ensure_ascii=False))
409
+ if n > max_len_ratio * ref_len:
410
+ dropped_len += 1
411
+ continue
412
+ rows.append({"prompt": r["prompt"],
413
+ "completion": r["completion"], "label": False})
414
+ stats = {
415
+ "rows": len(rows),
416
+ "desirable": sum(1 for r in rows if r["label"]),
417
+ "undesirable": sum(1 for r in rows if not r["label"]),
418
+ "prompts": len(by_prompt),
419
+ "dropped_length_mismatch": dropped_len,
420
+ "dropped_unpaired": dropped_unpaired,
421
+ }
422
+ return rows, stats
423
+
424
+
425
+ def _defect_shape(text: str, task: str) -> Optional[str]:
426
+ """Name the defect a dropped assistant turn actually exhibits, or None.
427
+
428
+ The triage judge drops a turn for two very different reasons: it is
429
+ *wrong* (narration instead of work, a restatement of the brief, a bare
430
+ acknowledgement) or merely *redundant* (it repeats something already
431
+ said). Only the first is an instructive negative — training against
432
+ redundancy teaches the model to be terse, not to be correct. Measured on
433
+ the 1,456 dropped turns available 2026-10-22: 37% are defect shapes,
434
+ 63% are redundancy.
435
+ """
436
+ body = (text or "").strip()
437
+ if not body:
438
+ return None
439
+ head = body[:200]
440
+ if _NARRATION_RE.match(body) or _NARRATION_RE.search(head):
441
+ return "narration"
442
+ if task:
443
+ a = re.sub(r"\s+", " ", task).strip().lower()[:400]
444
+ b = re.sub(r"\s+", " ", body).strip().lower()[:400]
445
+ if a and b and difflib.SequenceMatcher(None, a, b).ratio() > 0.6:
446
+ return "restates_task"
447
+ if len(body) < 120:
448
+ return "short_ack"
449
+ return None
450
+
451
+
452
+ def build_kto_provisional(traces, raw_dir: str, triage_dir: str, *,
453
+ max_rows: int = 0) -> tuple[list[dict], dict]:
454
+ """Provisional KTO rows for exercising the trainer — NOT on-policy.
455
+
456
+ Positives: each improved trace's final assistant turn, prompt = the
457
+ cleaned prefix. Negatives: assistant turns the triage judge labelled
458
+ ``drop``, prompt = that raw trace's own prefix.
459
+
460
+ ``docs/DATA_QUALITY.md`` §4.4 requires negatives sampled from the policy
461
+ being aligned. These come from the original harness agent, so this
462
+ artifact is a smoke-test set and a baseline, not the corpus a real KTO
463
+ run trains on — that one is built from SFT-model rollouts via
464
+ ``build_kto``. The negatives are at least *verified* bad (the triage
465
+ judge dropped them) rather than merely different, which is why this is
466
+ preferable to relabelling raw final answers: measured 2026-10-22, raw and
467
+ improved finals differ materially in 229 of 991 traces but are usually
468
+ just differently worded, not worse.
469
+ """
470
+ rows: list[dict] = []
471
+ seen: set[str] = set()
472
+
473
+ def _key(prompt, completion) -> str:
474
+ return json.dumps([prompt, completion], sort_keys=True, ensure_ascii=False)
475
+
476
+ for t in traces:
477
+ msgs = t.messages
478
+ idx = [i for i, m in enumerate(msgs)
479
+ if m.role == "assistant" and (m.content or m.tool_calls)]
480
+ if not idx:
481
+ continue
482
+ i = idx[-1]
483
+ prefix = msgs[:i]
484
+ if not any(p.role == "user" for p in prefix):
485
+ continue
486
+ prompt = [m for m in (_msg_to_chat(p) for p in prefix) if m]
487
+ comp = _msg_to_chat(msgs[i])
488
+ if not comp:
489
+ continue
490
+ k = _key(prompt, comp)
491
+ if k in seen:
492
+ continue
493
+ seen.add(k)
494
+ rows.append({"prompt": prompt, "completion": comp, "label": True,
495
+ "session_id": t.session_id, "source": "improved"})
496
+ if max_rows and len(rows) >= max_rows:
497
+ return rows, _provisional_stats(rows, 0, 0, 0)
498
+
499
+ raws = {f[:-5]: os.path.join(raw_dir, f)
500
+ for f in os.listdir(raw_dir) if f.endswith(".json")}
501
+ n_neg = n_skipped = n_redundant = 0
502
+ for tf in sorted(os.listdir(triage_dir)):
503
+ if not tf.endswith(".json"):
504
+ continue
505
+ try:
506
+ with open(os.path.join(triage_dir, tf), encoding="utf-8") as f:
507
+ d = json.load(f)
508
+ except (OSError, json.JSONDecodeError):
509
+ continue
510
+ rp = raws.get(d.get("session_id"))
511
+ if not rp:
512
+ continue
513
+ try:
514
+ rt = Trace.load(rp)
515
+ except Exception:
516
+ continue
517
+ for lab in d.get("labels") or []:
518
+ if lab.get("action") != "drop":
519
+ continue
520
+ i = lab.get("i")
521
+ if not isinstance(i, int) or i >= len(rt.messages):
522
+ continue
523
+ m = rt.messages[i]
524
+ if m.role != "assistant" or not m.content or len(m.content) < 20:
525
+ continue
526
+ prefix = rt.messages[:i]
527
+ if not any(p.role == "user" for p in prefix):
528
+ continue
529
+ prompt = [c for c in (_msg_to_chat(p) for p in prefix) if c]
530
+ comp = _msg_to_chat(m)
531
+ if not comp:
532
+ continue
533
+ task = next((p.content or "" for p in prefix if p.role == "user"), "")
534
+ if not _defect_shape(m.content, task):
535
+ n_redundant += 1
536
+ continue
537
+ k = _key(prompt, comp)
538
+ if k in seen:
539
+ n_skipped += 1
540
+ continue
541
+ seen.add(k)
542
+ rows.append({"prompt": prompt, "completion": comp, "label": False,
543
+ "session_id": rt.session_id, "source": "triage_drop"})
544
+ n_neg += 1
545
+ if max_rows and len(rows) >= max_rows:
546
+ return rows, _provisional_stats(rows, n_neg, n_skipped, n_redundant)
547
+ return rows, _provisional_stats(rows, n_neg, n_skipped, n_redundant)
548
+
549
+
550
+ def _provisional_stats(rows: list[dict], n_neg: int, n_skipped: int,
551
+ n_redundant: int = 0) -> dict:
552
+ pos = sum(1 for r in rows if r["label"])
553
+ neg = len(rows) - pos
554
+ return {
555
+ "rows": len(rows),
556
+ "desirable": pos,
557
+ "undesirable": neg,
558
+ "ratio": round(pos / neg, 2) if neg else None,
559
+ "duplicates_skipped": n_skipped,
560
+ "skipped_redundant": n_redundant,
561
+ "on_policy": False,
562
+ "note": ("negatives come from the original harness agent, not the SFT "
563
+ "model — smoke-test artifact only (DATA_QUALITY.md §4.4)"),
564
+ }
traceforge/grounding.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grounding validation: every salient fact in an improved trace must exist
2
+ in its source trace. This is the mechanical no-hallucination gate (P4).
3
+
4
+ Salient facts = numbers-with-units, file paths, hex ids, quoted technical
5
+ strings. Sound but cheap: a trace that invents paths or counts fails here.
6
+
7
+ Tool-result fidelity: an ideal trace that makes the same tool call must
8
+ NOT carry a fabricated result. For each ideal tool row whose call signature
9
+ (tool name + argument keys) matches a real call, the result must share
10
+ content with the real output — exact line match first, then 25-char window
11
+ containment (the improver sees a clipped rendering and may compress, so a
12
+ wholly-unfamiliar output is fabrication, but paraphrase/reformat is not).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+
19
+ from traceforge.trace import Trace
20
+
21
+ FACT_PATTERNS = [
22
+ # counts with units (verdicts, row/line counts, repeats)
23
+ re.compile(r"\b\d{1,6}\s+(?:PASS|SUSPECT|FAIL|CLEAN|lines|rows|files|chars|bytes|errors)\b"),
24
+ # file paths
25
+ re.compile(r"(?:[\w./-]+/)?[\w.-]+\.(?:py|ts|tsx|js|rs|go|svelte|json|jsonl|yaml|yml|toml|md|txt|csv|sql|sh)\b"),
26
+ # hex ids (commit shas, call ids)
27
+ re.compile(r"\b[0-9a-f]{7,40}\b"),
28
+ ]
29
+
30
+ # facts the improver may legitimately introduce (its own bookkeeping)
31
+ ALLOWLIST_PATTERN = re.compile(r"^(?:call_|ideal-)")
32
+
33
+ # rendering artifacts, not world claims: the compact renderer's clip
34
+ # markers (also matches the "+N chars clipped" tail of read_file-style
35
+ # outputs). Stripped from the ideal blob before fact extraction.
36
+ CLIP_MARKER_RE = re.compile(r"…?\[\+[\d,.]+ ?(?:chars|bytes) clipped\]")
37
+
38
+ # unit claims that describe a rendering's compression, not the world.
39
+ # "48613 chars" in an ideal trace is the improver mirroring its own
40
+ # input rendering — never a salient fact.
41
+ NON_WORLD_UNIT_RE = re.compile(r"^\d{1,6}\s+(?:chars|bytes)$")
42
+
43
+ # fuzzy-containment window for tool-result fidelity
44
+ _CONTAIN_WINDOW = 25
45
+
46
+ # Verified on live promoted/attempted corpora (2026-09-28): fact patterns
47
+ # extract GLUED tokens from ideal traces whose underlying path IS in the
48
+ # source corpus — the top grounding false-kill shapes:
49
+ # 'a///Users/x/y.py' — unified-diff prefix "a/" glued onto a path that
50
+ # sits after a literal \n in a JSON-embedded diff
51
+ # 'nbackend/x/y.py' — a literal \n escape's 'n' glued onto the next
52
+ # path segment of a JSON-embedded tool output
53
+ # The real corpus stores the same output with REAL newlines, so it
54
+ # extracts the clean path and the glued variant looks invented. Before a
55
+ # fact is declared missing, glue-stripped variants are re-tested; only
56
+ # variants long enough to remain distinctive are considered.
57
+ def _glue_variants(fact: str) -> list[str]:
58
+ """The fact itself plus glue-stripped spelling variants.
59
+
60
+ The BASE fact is always included (it is self-grounding when present
61
+ in the corpus); only the stripped variants must be distinctive
62
+ (len >= 8) to count, so short facts are never grounded by a
63
+ meaninglessly-short fragment.
64
+ """
65
+ out = [fact]
66
+ if "/" in fact:
67
+ stripped = re.sub(r"^[a-z]?/+", "", fact) # glue char + slash run
68
+ if stripped != fact and len(stripped) >= 8:
69
+ out.append(stripped)
70
+ if len(fact) > 1 and len(fact) - 1 >= 8:
71
+ out.append(fact[1:]) # single leading char (n-glue, a/, b/)
72
+ # count+unit facts: the corpus may spell the same claim with the
73
+ # unit BEFORE the numbers ("lines old/new: 12875 12875" — verified
74
+ # false-kill 1fa6a3e5: identical bytes in ideal and raw, the
75
+ # NUMBER+UNIT pattern simply creates a token order the corpus lacks).
76
+ m = re.match(r"^(\d{1,6})\s+(lines|rows|files|errors)$", fact)
77
+ if m:
78
+ out.append(f"{m.group(2)} old/new: {m.group(1)} {m.group(1)}")
79
+ out.append(f"{m.group(2)}: {m.group(1)}")
80
+ return out
81
+
82
+
83
+ def _ideal_blob(ideal: Trace) -> str:
84
+ """Content + tool args of the ideal trace, clip markers stripped."""
85
+ blob = "\n".join(
86
+ (m.content or "") for m in ideal.messages
87
+ ) + "\n" + "\n".join(
88
+ tc.arguments for m in ideal.messages for tc in m.tool_calls
89
+ )
90
+ return CLIP_MARKER_RE.sub("", blob)
91
+
92
+
93
+ def _real_corpus(real: Trace) -> str:
94
+ parts = [m.content or "" for m in real.messages]
95
+ parts += [tc.arguments for m in real.messages for tc in m.tool_calls]
96
+ return "\n".join(parts)
97
+
98
+
99
+ def grounding_coverage(ideal: Trace, real: Trace) -> tuple[float, list[str]]:
100
+ """Return (fraction_grounded, missing_facts). 1.0 == fully grounded."""
101
+ corpus = _real_corpus(real)
102
+ ideal_blob = _ideal_blob(ideal)
103
+ missing: list[str] = []
104
+ total = 0
105
+ grounded = 0
106
+ for pat in FACT_PATTERNS:
107
+ for f in set(pat.findall(ideal_blob)):
108
+ if ALLOWLIST_PATTERN.match(f):
109
+ continue
110
+ if NON_WORLD_UNIT_RE.match(f):
111
+ continue # rendering artifact (mirrored clip marker)
112
+ total += 1
113
+ # JSON-embedded outputs glue a stray char onto the head of a
114
+ # real path (verified false-kill shapes, see _glue_variants).
115
+ # A fact is grounded when IT or a glue-stripped variant sits
116
+ # in the real corpus.
117
+ if any(v in corpus for v in _glue_variants(f)):
118
+ grounded += 1
119
+ else:
120
+ missing.append(f)
121
+ if total == 0:
122
+ return 1.0, []
123
+ return grounded / total, missing
124
+
125
+
126
+ def validate_grounding(
127
+ ideal: Trace, real: Trace, min_coverage: float = 0.95
128
+ ) -> list[str]:
129
+ """Gate: fail when salient-fact grounding is below the threshold."""
130
+ errs: list[str] = []
131
+ cov, missing = grounding_coverage(ideal, real)
132
+ if cov < min_coverage:
133
+ shown = ", ".join(m[:80] for m in missing[:8])
134
+ errs.append(
135
+ f"fact grounding {cov:.0%} < {min_coverage:.0%}; "
136
+ f"facts not in source trace: {shown}"
137
+ )
138
+ errs.extend(tool_result_fidelity_errors(ideal, real))
139
+ return errs
140
+
141
+
142
+ def _tool_output_lines(raw: str) -> list[str]:
143
+ """Substantive lines of a tool output; unwraps execute_code-style JSON."""
144
+ try:
145
+ d = json.loads(raw)
146
+ if isinstance(d, dict) and isinstance(d.get("output"), str):
147
+ raw = d["output"]
148
+ except (json.JSONDecodeError, TypeError):
149
+ pass
150
+ return [l.strip() for l in raw.split("\n") if len(l.strip()) >= 25]
151
+
152
+
153
+ def _call_signature(name: str, arguments: str) -> str:
154
+ """Stable-ish call fingerprint: tool name + sorted argument string."""
155
+ try:
156
+ a = json.loads(arguments or "{}")
157
+ keys = ",".join(sorted(a)) if isinstance(a, dict) else str(sorted(a))
158
+ except json.JSONDecodeError:
159
+ keys = (arguments or "")[:80]
160
+ return f"{name}({keys})"
161
+
162
+
163
+ def _windows(line: str, size: int = _CONTAIN_WINDOW):
164
+ """All size-length windows of a line, stepped every quarter-window."""
165
+ if len(line) < size:
166
+ return [line]
167
+ step = max(1, size // 4)
168
+ return [line[i:i + size] for i in range(0, len(line) - size + 1, step)]
169
+
170
+
171
+ def _result_grounded(lines: list[str], rlines: list[str]) -> bool:
172
+ """Do the ideal result's lines share content with the real output?
173
+
174
+ Two tiers: exact stripped-line match (verbatim reuse), then 25-char
175
+ window containment (compressed/paraphrased reuse of real evidence).
176
+ """
177
+ rset = set(rlines)
178
+ if any(l in rset for l in lines):
179
+ return True
180
+ rjoin = "\n".join(rlines)
181
+ for l in lines:
182
+ for w in _windows(l):
183
+ if w in rjoin:
184
+ return True
185
+ return False
186
+
187
+
188
+ def _real_output_index(real: Trace) -> dict[str, list[tuple[str, list[str]]]]:
189
+ """Per tool name: [(call signature, substantive output lines)]."""
190
+ idx: dict[str, list[tuple[str, list[str]]]] = {}
191
+ for m in real.messages:
192
+ for tc in m.tool_calls:
193
+ res = next(
194
+ (r for r in real.messages
195
+ if r.role == "tool" and r.tool_call_id == tc.id),
196
+ None,
197
+ )
198
+ if res is not None and res.content:
199
+ sig = _call_signature(tc.name, tc.arguments)
200
+ idx.setdefault(tc.name, []).append(
201
+ (sig, _tool_output_lines(res.content))
202
+ )
203
+ return idx
204
+
205
+
206
+ def _ideal_call_sigs(ideal: Trace) -> dict[str, str]:
207
+ """tool_call_id -> signature, for the ideal trace's own calls."""
208
+ out: dict[str, str] = {}
209
+ for m in ideal.messages:
210
+ for tc in m.tool_calls:
211
+ out[tc.id] = _call_signature(tc.name, tc.arguments)
212
+ return out
213
+
214
+
215
+ def tool_result_fidelity_errors(ideal: Trace, real: Trace) -> list[str]:
216
+ """Fabricated tool results: same-call results sharing no content.
217
+
218
+ An ideal tool row whose tool was really called fails when its result
219
+ shares neither an exact substantive line nor a 25-char window with
220
+ any real output of that tool. The error names the ideal call's
221
+ signature, shows what the real trace's output actually looked like,
222
+ and gives a concrete rewrite target — a vague "be more grounded"
223
+ never repairs anything.
224
+ """
225
+ idx = _real_output_index(real)
226
+ sigs = _ideal_call_sigs(ideal)
227
+ errs: list[str] = []
228
+ for m in ideal.messages:
229
+ if m.role != "tool":
230
+ continue
231
+ cands = idx.get(m.tool_name or "", [])
232
+ if not cands:
233
+ continue # tool never called in real trace: coverage handles it
234
+ lines = _tool_output_lines(m.content or "")
235
+ if not lines:
236
+ continue
237
+ for _sig, rlines in cands:
238
+ if _result_grounded(lines, rlines):
239
+ break # grounded against (at least) one real result
240
+ else:
241
+ sig = sigs.get(m.tool_call_id or "", "?")
242
+ # prefer the real output of the SAME call when one exists,
243
+ # else the tool's first real output — concrete repair target
244
+ same = [rl for s, rl in cands if s == sig]
245
+ sample = (same or [rl for _s, rl in cands])[0]
246
+ head_txt = " | ".join(l[:70] for l in sample[:2]) or "(empty)"
247
+ head = lines[0][:90]
248
+ errs.append(
249
+ f"fabricated tool result [{m.tool_name}]: result shares no "
250
+ f"content with any real {m.tool_name} output "
251
+ f"(e.g. {head!r}). The ideal call is {sig}; the real trace's "
252
+ f"output looks like: {head_txt!r}. Rewrite this result to "
253
+ "quote the real trace's actual output (compressed is fine, "
254
+ "but every substantive line must come from the real output)."
255
+ )
256
+ return errs
traceforge/identity.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Identity-neutrality and refusal gates.
2
+
3
+ The reference must not burn in an assistant identity ("I am Claude",
4
+ "as an AI made by …") and must not teach refusals — a model scored against
5
+ it should neither claim a vendor self-identity nor reproduce refusal
6
+ phrasings. These gates are deterministic regex checks over ASSISTANT
7
+ prose (system/user rows are never rewritten by the pipeline anyway and
8
+ tool results are verbatim facts, so only assistant content can carry a
9
+ learnable self-claim).
10
+
11
+ Domain content rule: a trace whose TASK is about a model/vendor (e.g.
12
+ "compare chatgpt vs claude APIs", "debug Claude-Code traces") may
13
+ LEGITIMATELY contain those names — that is content, not identity. Only
14
+ SELF-referential claims are gated: first-person identity patterns
15
+ ("I am Claude", "I'm an AI", "trained by Anthropic", …) and refusal
16
+ patterns ("I can't help with that", "against my guidelines", …).
17
+ Mentions like "CLAUDE.md exists in the repo" are never touched.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ from typing import Optional
24
+
25
+ # First-person identity self-claims. Anchored on first-person verbs so
26
+ # "the trace contains Claude-Code traces" (third person) never matches.
27
+ IDENTITY_RE = re.compile(
28
+ r"("
29
+ r"co-?authored-?by:\s*"
30
+ r"(claude|chatgpt|gpt-?[45o]|glm|gemini|qwen|gemma|llama|"
31
+ r"deepseek|copilot|sonnet|opus|anthropic|openai)\b"
32
+ r"|\b(i am|i'?m|my name is|call me)\s+"
33
+ r"(claude|chatgpt|gpt-?[45o]|glm|gemini|qwen|gemma|llama|"
34
+ r"deepseek|copilot|sonnet|opus)\b"
35
+ r"|\b(i am|i'?m)\s+(an?|the)\s+"
36
+ r"(ai|llm|large language model|language model|chatbot|assistant "
37
+ r"(?:model|developed|made|trained|created)\b[^.]{0,40})"
38
+ r"|\b(trained|made|created|developed|built)\s+(by|at)\s+"
39
+ r"(anthropic|openai|google|deepmind|meta|mistral|alibaba|z\.ai|"
40
+ r"zhipu|nvidia|x\.?ai)\b[^.]{0,30}\bby me\b"
41
+ r"|\bi\s+(was\s+)?(trained|fine-?tuned)\s+(by|on|with)\b"
42
+ r"|\bas an?\s+(ai|llm|language model)(?=[,.])"
43
+ r"|\bclaude\b\s+(here|speaking|at your service)"
44
+ r"|\boperated by\s+(anthropic|openai|z\.ai)\b"
45
+ r"|\bich\s+(bin|heiße|werde genannt)\s+"
46
+ r"(claude|chatgpt|gpt-?[45o]?|glm|gemini|qwen|gemma|llama|"
47
+ r"deepseek)\b"
48
+ r"|\bals\s+ki\b[^.]{0,40}\bich\b"
49
+ r"|\bich\s+(bin|werde)\s+(eine?|ein)\s+"
50
+ r"(ki|künstliche?\s+intelligenz|sprachmodell)\b"
51
+ r")",
52
+ re.IGNORECASE,
53
+ )
54
+
55
+ # Refusal phrasings that would teach the model to refuse. Anchored on
56
+ # first-person refusal verbs; a task ABOUT moderation policy may quote
57
+ # refusals third-person — those don't match.
58
+ REFUSAL_RE = re.compile(
59
+ r"("
60
+ r"\bi\s+(can'?t|cannot|can not|won'?t|will not|am unable to|"
61
+ r"'?m not able to)\s+"
62
+ r"(help|assist|comply|provide|create|generate|write|build)\b"
63
+ r"(\s+(that|this|with (that|this)))?"
64
+ r"|\bi\s+(don'?t|do not)\s+feel\s+comfortable\b"
65
+ r"|\b(against|outside)\s+my\s+(guidelines|principles|"
66
+ r"programming|policies)\b"
67
+ r"|\bi'?m\s+sorry,?\s+but\s+i\s+(can'?t|cannot|won'?t)\b"
68
+ r"|\bi\s+must\s+(respect|follow)\s+my\s+"
69
+ r"(guidelines|principles|programming)\b"
70
+ r"|\bich\s+(kann|kann nicht|werde)\s+.*(nicht helfen|"
71
+ r"nicht unterstützen|nicht unterstützen bei)\b"
72
+ r"|\bich\s+(kann|mag)\s+das\s+nicht\b"
73
+ r"|\bdas\s+verstößt\s+gegen\s+meine\b"
74
+ r"|\bmeine\s+richtlinien\b[^.]{0,20}\bverbieten\b"
75
+ r")",
76
+ re.IGNORECASE,
77
+ )
78
+
79
+ # Refusals in the opening task (user explicitly asks for something
80
+ # harmful and the source model refused) are visible in USER rows too —
81
+ # but we never edit user rows, so a harmful-request trace is not
82
+ # promotable at all; that class dies at triage/judge level instead.
83
+
84
+
85
+ def _find_hits(content: str, rx: re.Pattern) -> list[str]:
86
+ return [m.group(0)[:100] for m in rx.finditer(content)]
87
+
88
+
89
+ def validate_identity(
90
+ messages: list,
91
+ *,
92
+ identity_rx: Optional[re.Pattern] = None,
93
+ refusal_rx: Optional[re.Pattern] = None,
94
+ ) -> list[str]:
95
+ """Return gate errors for assistant identity/refusal text ([] = ok).
96
+
97
+ ``messages`` may be Trace.messages (Message objects with .role/
98
+ .content) or plain dicts (role/content keys) — the audit calls this
99
+ with dicts, the improver with Messages.
100
+ """
101
+ irx = identity_rx or IDENTITY_RE
102
+ rrx = refusal_rx or REFUSAL_RE
103
+ errors: list[str] = []
104
+ for i, m in enumerate(messages):
105
+ role = m.get("role") if isinstance(m, dict) else m.role
106
+ if role != "assistant":
107
+ continue
108
+ c = (m.get("content") if isinstance(m, dict) else m.content) or ""
109
+ tc = m.get("tool_calls") if isinstance(m, dict) else m.tool_calls
110
+ texts = [c] if c else []
111
+ for call in tc or []:
112
+ if isinstance(call, dict):
113
+ args = call.get("arguments", call.get("args"))
114
+ else:
115
+ args = getattr(call, "arguments", None)
116
+ if args is None:
117
+ args = getattr(call, "args", None)
118
+ if isinstance(args, str):
119
+ texts.append(args)
120
+ elif args:
121
+ texts.append(json.dumps(args, ensure_ascii=False))
122
+ for c2 in texts:
123
+ if not c2:
124
+ continue
125
+ for hit in _find_hits(c2, irx):
126
+ errors.append(
127
+ f"[{i}] identity self-claim in assistant text/tool args: {hit!r}")
128
+ for hit in _find_hits(c2, rrx):
129
+ errors.append(
130
+ f"[{i}] refusal phrasing in assistant text/tool args: {hit!r}")
131
+ return errors
traceforge/improver/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from traceforge.improver.harness import ImproverResult, improve_trace
2
+ from traceforge.improver.prompt import build_prompt
3
+
4
+ __all__ = ["ImproverResult", "improve_trace", "build_prompt"]
traceforge/improver/digest_prompts.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompts for the long-trace (map-digest-write) pipeline.
2
+
3
+ Heavy sessions (up to ~10M tokens) can never fit into one context. The
4
+ pipeline: digest each chunk in parallel -> merge digests into a chunk map
5
+ -> writer builds the ideal trace from the map, looking up exact verbatim
6
+ tool outputs from on-disk chunk files by reference.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ CHUNK_DIGEST_HEADER = """You are a trace-digest agent. You see ONE chunk of a
11
+ long real agent session. boil it down for a downstream writer agent that
12
+ will rewrite the whole session as a flawless "ideal trace".
13
+
14
+ Produce JSON with EXACTLY these keys, written to: {digest_path}
15
+ {{
16
+ "chunk": <chunk number>,
17
+ "task_scope": "<what part of the overall task this chunk covers>",
18
+ "narrative": "<compact ordered summary of what happened: decisions,
19
+ tool calls made (name + key args), results seen,
20
+ user feedback received>",
21
+ "mistakes": [
22
+ {{"kind": "failed_tool_call|retry_loop|wrong_turn|redundant|user_feedback",
23
+ "detail": "<what went wrong>", "turn_ref": "<[chunk-local msg idx]>"}}
24
+ ],
25
+ "key_outputs": [
26
+ {{"ref": "chunk{n}-{i}", "tool": "<name>", "significance": "<why it
27
+ matters>", "loc": "<file/section the output is about>"}}
28
+ ],
29
+ "claim_worthy": ["<external-world facts asserted (versions, prices,
30
+ paper ids) worth fact-checking>"]
31
+ }}
32
+
33
+ RULES:
34
+ - Story-first: the downstream writer only sees your digest, not the chunk.
35
+ Everything load-bearing must appear in narrative/ key_outputs.
36
+ - Mistakes matter most: every failed call, retry, wrong turn, and user
37
+ correction must be listed — those are what the ideal path must avoid.
38
+ - Do NOT include raw tool outputs in digest. Reference them as refs
39
+ (chunk{n}-{i}); the writer resolves refs against on-disk chunk files.
40
+ - ideal_path: describe in "narrative" what SHOULD have happened in this
41
+ chunk's scope (perfect first-time route per guidelines).
42
+ - No preamble: write the JSON, validate it parses when re-read, then reply
43
+ "DIGEST DONE chunk {n}".
44
+ """
45
+
46
+ MAP_MERGE_HEADER = """You are a trace-map merge agent. You receive digests
47
+ of ALL chunks of one long real agent session, in order. Build the unified
48
+ session map for the ideal-trace writer.
49
+
50
+ Write JSON to: {map_path}
51
+ {{
52
+ "task": "<the one-sentence task the user wanted, verbatim from the
53
+ first user message>",
54
+ "first_user_message": "<the EXACT, COMPLETE first user message text>",
55
+ "structure": [ "<ordered story beats: what the ideal path needs to do,
56
+ step by step, referencing key_outputs refs>" ],
57
+ "mistakes": [ "<ALL mistakes across chunks, deduplicated, in order>" ],
58
+ "key_outputs": [ "<all key_output refs, deduplicated, with tool+loc>" ],
59
+ "claim_worthy": [ "<all claim-worthy external facts>" ],
60
+ "chunk_index": [ "<per-chunk digest files, in order>" ]
61
+ }}
62
+
63
+ RULES:
64
+ - Deduplicate conservative: keep every load-bearing item; drop only
65
+ literal repeats introduced by chunk overlap.
66
+ - first_user_message must be VERBATIM and COMPLETE — the writer will
67
+ reproduce it byte-exact in the ideal trace.
68
+ - ideal story: structure = the perfect-first-time route through the whole
69
+ session (per guidelines: mistakes removed, minimal necessary calls).
70
+ - Write the JSON, validate it parses, reply "MAP DONE".
71
+ """
72
+
73
+ WRITER_LONG_HEADER = """You are the ideal-trace writer for a LONG session.
74
+ You get: (1) the session map (digests merged), (2) access to on-disk chunk
75
+ files that hold the raw rendered chunks, (3) the guidelines.
76
+
77
+ Your job: write the ideal-world version of the whole session as JSON to:
78
+ {output_path}
79
+
80
+ Procedure:
81
+ 1. Read the guidelines file: {guidelines_path}
82
+ 2. Read the session map at: {map_path}
83
+ 3. Read guidelines §3 pillars. Then look up chunk data on demand:
84
+ - The chunk files live at: {chunk_dir} (files chunk_001.txt, ...).
85
+ - Use refs from the map's key_outputs to locate the exact tool outputs
86
+ you want to reuse VERBATIM (search the chunk files).
87
+ - Read a chunk file only when you need exact content from it. Do not
88
+ read all chunk files — only what refs point you to.
89
+ 4. Write the ideal trace (same JSON schema as the improver contract:
90
+ messages array with role/content/tool_calls/tool_call_id/tool_name).
91
+ Apply ALL quality pillars. Reuse tool outputs verbatim when the ideal
92
+ path makes the same call (P4); resolve via refs.
93
+ 5. Verify: re-read what you wrote; check JSON; check tool pairing; check
94
+ the first user message matches the map's first_user_message byte-exact.
95
+ 6. Reply "WRITER DONE" + a summary paragraph.
96
+
97
+ Scope limits (unchanged): no environment archaeology, no subagents —
98
+ chunk files and guidelines are your only inputs.
99
+ """
100
+
101
+ DIGEST_SUFFIX = """
102
+
103
+ REAL TRACE CHUNK {n}:
104
+ {chunk}
105
+ """
traceforge/improver/harness.py ADDED
@@ -0,0 +1,850 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runs the improver as a Hermes Agent one-shot session (the harness).
2
+
3
+ Design: for each trace we spawn
4
+
5
+ hermes chat --query-file <prompt.txt> --in <workdir> -Q
6
+ [--improver-model M] [--max-turns N] [--pass-session-id]
7
+
8
+ The improver is a full agent: it can read the guidelines file, inspect the
9
+ workdir, write its output with a tool, and re-validate it — agentic
10
+ self-improvement inside the generation loop. The harness never trusts the
11
+ improver's chat reply; it reads back the JSON file the improver wrote,
12
+ then structural validation + repair loop run outside the model.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ import json
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import time
22
+ from typing import Optional
23
+
24
+ from traceforge import chunker
25
+ from traceforge.config import Config
26
+ from traceforge.grounding import CLIP_MARKER_RE as CLIP_MARKER_PROBE
27
+ from traceforge.improver.prompt import build_prompt, write_prompt_file
28
+ from traceforge.trace import Message, Trace, json_loads_lenient
29
+
30
+ _TOOL_TAIL_ERROR = "last message role is 'tool' (expected assistant)"
31
+ _EMPTY_ROW_RE = "message with empty content and no tool calls"
32
+
33
+
34
+ def _only_tool_tail(errs: list[str]) -> bool:
35
+ """True when the artifact's ONLY defect is that it ends on a tool row."""
36
+ return bool(errs) and all(_TOOL_TAIL_ERROR in e for e in errs)
37
+
38
+
39
+ def _drop_empty_assistant_rows(improved: Trace) -> bool:
40
+ """Remove assistant rows with no content and no calls. Returns changed.
41
+
42
+ The writer emits these as separators when it loses the thread. They
43
+ carry zero signal and a trainee must never learn to produce them, so
44
+ dropping is strictly better than paying a repair round to rewrite the
45
+ whole trace around one blank row.
46
+ """
47
+ kept = [m for m in improved.messages
48
+ if not (m.role == "assistant" and not m.tool_calls
49
+ and not (m.content or "").strip())]
50
+ if len(kept) == len(improved.messages):
51
+ return False
52
+ improved.messages = kept
53
+ return True
54
+
55
+
56
+ def _append_synth_final(cfg: Config, improved: Trace,
57
+ source: Trace) -> Optional[Trace]:
58
+ """Close a trace that the writer left mid-call with a synthesized answer.
59
+
60
+ The writer stops after a tool call in ~3% of traces (output cap or turn
61
+ cap), leaving an artifact that is one row short of valid. A full repair
62
+ round rewrites the whole trace and usually reproduces the same cut, so
63
+ the cheap fix is the targeted one: ask for the closing answer only,
64
+ grounded in the tail that already exists. Same helper the splice path
65
+ uses for raw traces that were cut off mid-run.
66
+ """
67
+ from traceforge.splice import _resolve_endpoint, _synthesize_final_answer
68
+
69
+ try:
70
+ base, key = _resolve_endpoint(cfg)
71
+ except Exception as e:
72
+ print(f" tool-tail synth skipped: endpoint {type(e).__name__}: {e}")
73
+ return None
74
+ ans, err = _synthesize_final_answer(
75
+ cfg, improved, source.first_task_message() or "", base, key)
76
+ if not ans:
77
+ print(f" tool-tail synth failed: {(err or 'empty answer')[:120]}")
78
+ return None
79
+ improved.messages.append(Message(role="assistant", content=ans))
80
+ return improved
81
+
82
+ CLIP_MARKER_ERROR = (
83
+ "artifact contains rendering clip markers (…[+N chars clipped]) "
84
+ "with no verifiable counts — reviewers reject these. Rewrite the tool "
85
+ "results verbatim from outputs.jsonl (contiguous portions fine)."
86
+ )
87
+
88
+ # Per-workdir file recording which gate rounds already PASSED for which
89
+ # artifact content digest (see _load_gate_cache).
90
+ GATE_CACHE_FILE = "gate_cache.json"
91
+
92
+ # Bump whenever a JUDGE/GATE STANDARD changes (new pillar rule, ANSWER
93
+ # note wording that changes verdicts, regex gate changes) — cached
94
+ # passes recorded under an older standard must not replay. The value
95
+ # rides in the cache file; _load_gate_cache discards stale files.
96
+ GATE_CACHE_STANDARDS = "2026-10-08.1" # +answer-focus rule (P1 FOCUS, over-answering = poor) # +write-JSON deliverable forms, tool-tail carry, exact-args dup reads # +mixed-turn surgical drops, usage-error detect, planner >=2 + write-fix chains # +identity scans tool args (Co-Authored-By), degenerate prose gate # +judge sees reasoning channel
97
+
98
+
99
+ def _clip_markers_present(candidate) -> bool:
100
+ """True if any message content carries a rendering clip marker."""
101
+ blob = "\n".join((m.content or "") for m in candidate.messages)
102
+ return bool(CLIP_MARKER_PROBE.search(blob))
103
+
104
+
105
+ def _artifact_digest(candidate) -> str:
106
+ """Stable content hash of an improved artifact.
107
+
108
+ Gate passes are keyed on this: structural/grounding/verify/review
109
+ PASSes are only replayed from cache when the artifact byte-content is
110
+ unchanged (the file may still have been rewritten with identical
111
+ bytes — that's fine, it's the same artifact).
112
+ """
113
+ import hashlib
114
+
115
+ h = hashlib.sha256()
116
+ for m in candidate.messages:
117
+ h.update((m.role or "").encode("utf-8"))
118
+ h.update(b"\x00")
119
+ h.update((m.content or "").encode("utf-8"))
120
+ h.update(b"\x01")
121
+ if m.tool_call_id:
122
+ h.update(m.tool_call_id.encode("utf-8"))
123
+ h.update(b"\x01")
124
+ if m.tool_name:
125
+ h.update(m.tool_name.encode("utf-8"))
126
+ h.update(b"\x02")
127
+ if m.tool_calls:
128
+ for tc in m.tool_calls:
129
+ h.update((tc.id or "").encode("utf-8"))
130
+ h.update((tc.name or "").encode("utf-8"))
131
+ h.update((tc.arguments or "").encode("utf-8"))
132
+ h.update(b"\x03")
133
+ return h.hexdigest()
134
+
135
+
136
+ class ImproverResult:
137
+ def __init__(
138
+ self,
139
+ *,
140
+ session_id: str,
141
+ ok: bool,
142
+ improved: Optional[Trace],
143
+ reply: str,
144
+ hermes_session_id: Optional[str] = None,
145
+ errors: Optional[list[str]] = None,
146
+ attempts: int = 0,
147
+ duration_s: float = 0.0,
148
+ ):
149
+ self.session_id = session_id # source trace session id
150
+ self.ok = ok
151
+ self.improved = improved
152
+ self.reply = reply
153
+ self.hermes_session_id = hermes_session_id
154
+ self.errors = errors or []
155
+ self.attempts = attempts
156
+ self.duration_s = duration_s
157
+
158
+ def to_dict(self) -> dict:
159
+ return {
160
+ "session_id": self.session_id,
161
+ "ok": self.ok,
162
+ "errors": self.errors,
163
+ "hermes_session_id": self.hermes_session_id,
164
+ "attempts": self.attempts,
165
+ "duration_s": round(self.duration_s, 1),
166
+ }
167
+
168
+
169
+ def _run_hermes(
170
+ cfg: Config, prompt_path: str, workdir: str
171
+ ) -> tuple[int, str, Optional[str], str]:
172
+ """Invoke hermes chat one-shot; returns (exit_code, stdout, session_id)."""
173
+ cmd = [
174
+ "hermes", "chat",
175
+ "--query-file", prompt_path,
176
+ "--in", workdir,
177
+ "-Q",
178
+ "--format", "text",
179
+ "--pass-session-id",
180
+ ]
181
+ if cfg.model:
182
+ cmd += ["-m", cfg.model]
183
+ if cfg.provider:
184
+ cmd += ["--provider", cfg.provider]
185
+ if cfg.max_turns:
186
+ cmd += ["--max-turns", str(cfg.max_turns)]
187
+ if cfg.run_budget:
188
+ cmd += ["--run-budget", str(cfg.run_budget)]
189
+ if cfg.skills:
190
+ cmd += ["-s", ",".join(cfg.skills)]
191
+ proc = subprocess.run(
192
+ cmd,
193
+ capture_output=True,
194
+ text=True,
195
+ timeout=cfg.harness_timeout_s,
196
+ cwd=workdir,
197
+ )
198
+ session_id = None
199
+ for line in (proc.stdout or "").splitlines():
200
+ if line.strip().startswith("session_id:"):
201
+ session_id = line.split(":", 1)[1].strip() or None
202
+ return proc.returncode, proc.stdout, session_id, proc.stderr
203
+
204
+
205
+ def _parse_improved(path: str) -> Optional[Trace]:
206
+ """Parse the improver-written improved.json into a Trace, leniently."""
207
+ if not os.path.exists(path):
208
+ return None
209
+ with open(path, "r", encoding="utf-8") as f:
210
+ raw = f.read()
211
+ # tolerate fenced code blocks from chatty models
212
+ raw = raw.strip()
213
+ if raw.startswith("```"):
214
+ raw = raw.split("\n", 1)[1] if "\n" in raw else ""
215
+ if raw.rstrip().endswith("```"):
216
+ raw = raw.rstrip()[: -3]
217
+ data = json_loads_lenient(raw)
218
+ if not isinstance(data, dict):
219
+ return None
220
+ msgs = data.get("messages")
221
+ if not isinstance(msgs, list):
222
+ return None
223
+ from traceforge.trace import Message, ToolCall
224
+
225
+ out = []
226
+ for d in msgs:
227
+ if not isinstance(d, dict) or "role" not in d:
228
+ return None
229
+ m = {
230
+ "role": d["role"],
231
+ "content": d.get("content"),
232
+ "reasoning": d.get("reasoning"),
233
+ "tool_call_id": d.get("tool_call_id"),
234
+ "tool_name": d.get("tool_name"),
235
+ }
236
+ m = {k: v for k, v in m.items() if v is not None}
237
+ if "tool_calls" in d and isinstance(d["tool_calls"], list):
238
+ m["tool_calls"] = [
239
+ {
240
+ "id": str(tc.get("id", "")),
241
+ "name": str(tc.get("name", "")),
242
+ "arguments": tc.get("arguments") or "{}",
243
+ }
244
+ for tc in d["tool_calls"]
245
+ if isinstance(tc, dict)
246
+ ]
247
+ out.append(Message.from_dict(m))
248
+ return Trace(
249
+ session_id="improved-placeholder", # set by caller
250
+ messages=out,
251
+ improved=True,
252
+ )
253
+
254
+
255
+ def _append_status(cfg: Config, sid: str, status: dict) -> None:
256
+ """Append a stage-level status row to the manifest."""
257
+ import json as _json
258
+
259
+ os.makedirs(os.path.dirname(cfg.manifest_path), exist_ok=True)
260
+ row = {"session_id": sid, **status}
261
+ with open(cfg.manifest_path, "a", encoding="utf-8") as f:
262
+ f.write(_json.dumps(row, ensure_ascii=False) + "\n")
263
+
264
+
265
+ def _load_gate_cache(workdir: str) -> dict:
266
+ """Read the workdir's gate-pass cache (digest -> list of gate rows).
267
+
268
+ Batches die at arbitrary points (turn interruptions, terminal close,
269
+ SIGTERM to the worker pool); without this, every verify/review round
270
+ already PAID FOR on an unchanged artifact is re-run from scratch on
271
+ the next resume. The cache is keyed by the artifact's content digest
272
+ so a rewritten (different) artifact never inherits stale passes.
273
+ Cached passes from an OLDER standards version are discarded — judge
274
+ rules evolve, and a verdict recorded under old rules must not
275
+ replay against new ones.
276
+ """
277
+ path = os.path.join(workdir, GATE_CACHE_FILE)
278
+ try:
279
+ with open(path, "r", encoding="utf-8") as f:
280
+ data = json.load(f)
281
+ if not isinstance(data, dict):
282
+ return {}
283
+ if data.get("standards") != GATE_CACHE_STANDARDS:
284
+ return {}
285
+ return data
286
+ except (OSError, json.JSONDecodeError):
287
+ return {}
288
+
289
+
290
+ def _save_gate_cache(workdir: str, cache: dict) -> None:
291
+ """Persist the gate-pass cache atomically (workdir, per trace)."""
292
+ path = os.path.join(workdir, GATE_CACHE_FILE)
293
+ tmp = path + ".tmp"
294
+ cache = dict(cache)
295
+ cache["standards"] = GATE_CACHE_STANDARDS
296
+ with open(tmp, "w", encoding="utf-8") as f:
297
+ json.dump(cache, f, ensure_ascii=False)
298
+ os.replace(tmp, path)
299
+
300
+
301
+ def _cache_gate_pass(cache: dict, digest: str, stage: str, round_no: int) -> None:
302
+ """Record a gate PASS for one artifact digest."""
303
+ rows = cache.setdefault(digest, [])
304
+ key = f"{stage}:{round_no}"
305
+ if key not in rows:
306
+ rows.append(key)
307
+
308
+
309
+ def _acquire_workdir_lock(workdir: str) -> bool:
310
+ """O_EXCL pid lockfile; locks left by dead processes are stolen.
311
+
312
+ Two concurrent improve batches working the same sid corrupt each
313
+ other (shared improved.json / verdict.json — the verifier unlinks its
314
+ verdict file before each run) and burn double model time. The lock
315
+ makes one-improver-per-workdir structural, not a user discipline.
316
+ """
317
+ lock_path = os.path.join(workdir, ".lock")
318
+ my_pid = str(os.getpid())
319
+ for _ in range(3):
320
+ try:
321
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
322
+ try:
323
+ os.write(fd, my_pid.encode())
324
+ finally:
325
+ os.close(fd)
326
+ return True
327
+ except FileExistsError:
328
+ try:
329
+ other = open(lock_path, encoding="utf-8").read().strip()
330
+ except OSError:
331
+ continue
332
+ pid = int(other) if other.isdigit() else -1
333
+ alive = False
334
+ if pid > 0:
335
+ try:
336
+ os.kill(pid, 0)
337
+ alive = True
338
+ except ProcessLookupError:
339
+ alive = False
340
+ except PermissionError:
341
+ alive = True # exists, not ours: assume alive
342
+ except OverflowError:
343
+ alive = False
344
+ if alive:
345
+ return False
346
+ try:
347
+ os.unlink(lock_path) # stale lock from a dead run: steal
348
+ except OSError:
349
+ pass
350
+ return False
351
+
352
+
353
+ def _release_workdir_lock(workdir: str) -> None:
354
+ """Drop our own lock (never another live run's)."""
355
+ lock_path = os.path.join(workdir, ".lock")
356
+ try:
357
+ with open(lock_path, encoding="utf-8") as f:
358
+ if f.read().strip() == str(os.getpid()):
359
+ os.unlink(lock_path)
360
+ except OSError:
361
+ pass
362
+
363
+
364
+ def _write_outputs_index(
365
+ cfg: Config, trace: Trace, workdir: str, max_chars: int = 250_000
366
+ ) -> str:
367
+ """Full real tool outputs as outputs.jsonl, keyed by rendering index.
368
+
369
+ The compact rendering clips each tool result to a short head; P4
370
+ demands verbatim reuse, so the improver needs the unclipped text.
371
+ One JSON object per line: {"i": N, "tool", "args", "output"} where N
372
+ is the same [N] message index the rendering shows — the improver can
373
+ correlate a clipped result in its prompt with the full text here.
374
+
375
+ max_chars is a byte-blow safety valve (measured corpus max real
376
+ output: ~111KB), NOT a compression budget: the 20k cap this used to
377
+ apply hid exactly the mid-tail evidence (p95 = 20k) that P4 demands
378
+ the improver quote — on exactly the big traces where review kills
379
+ artifacts — forcing composed pseudo-outputs. Outputs beyond the
380
+ safety valve carry an honest total-marker, never a fake count.
381
+ """
382
+ path = os.path.join(workdir, "outputs.jsonl")
383
+ arg_of: dict[str, str] = {}
384
+ for m in trace.messages:
385
+ for tc in m.tool_calls:
386
+ arg_of[tc.id] = tc.arguments or "{}"
387
+ with open(path, "w", encoding="utf-8") as f:
388
+ for i, m in enumerate(trace.messages, 1):
389
+ if m.role != "tool" or not m.content:
390
+ continue
391
+ out = m.content
392
+ if len(out) > max_chars:
393
+ out = out[:max_chars] + f" …[+{len(out) - max_chars} chars total]"
394
+ f.write(json.dumps({
395
+ "i": i,
396
+ "tool": m.tool_name or "?",
397
+ "args": arg_of.get(m.tool_call_id or "", "")[:400],
398
+ "output": out,
399
+ }, ensure_ascii=False) + "\n")
400
+ return path
401
+
402
+
403
+ def _generate_single(cfg, trace, rendered, workdir, output_path, errors, prev_path, sid):
404
+ """One single-shot improver invocation. Returns (candidate, reply, hsid, code, stderr) or raises Timeout."""
405
+ from traceforge.classify import detect_language
406
+ prompt = build_prompt(
407
+ rendered_trace=rendered,
408
+ guidelines_path=cfg.guidelines_path,
409
+ output_path=output_path,
410
+ outputs_path=os.path.join(workdir, "outputs.jsonl"),
411
+ errors=errors,
412
+ prev_path=prev_path,
413
+ language=detect_language(trace.first_task_message() or ""),
414
+ )
415
+ prompt_path = write_prompt_file(cfg.prompts_dir, sid, prompt)
416
+ try:
417
+ code, stdout, hsid, stderr = _run_hermes(cfg, prompt_path, workdir)
418
+ except subprocess.TimeoutExpired as e:
419
+ raise e
420
+ reply = (stdout or "").strip()
421
+ return _parse_improved(output_path), reply, hsid, code, stderr
422
+
423
+
424
+ def improve_trace(
425
+ cfg: Config,
426
+ trace: Trace,
427
+ *,
428
+ max_attempts: int = 2,
429
+ validator=None,
430
+ status_cb=None,
431
+ ) -> ImproverResult:
432
+ """Run the agentic improver on one trace, with a validation repair loop.
433
+
434
+ Long traces (rendered size > cfg.chunk_max_chars) go through the staged
435
+ map-digest-write pipeline instead of the single shot. Every admitted
436
+ trace passes: N independent verification rounds (web fact-check) and
437
+ M independent review rounds (quality-pillar review) — all must PASS.
438
+
439
+ ``status_cb`` (optional) receives short human stage strings
440
+ ("improving", "digest", "verify 1/2", ...) for live progress display.
441
+ """
442
+ from traceforge.trace import Trace as _T
443
+ from traceforge.validate import validate_structural
444
+ from traceforge.grounding import validate_grounding
445
+ from traceforge.improver.verify import run_verifier
446
+ from traceforge.improver.review import run_review
447
+
448
+ def _status(s: str) -> None:
449
+ if status_cb is not None:
450
+ status_cb(s)
451
+
452
+ started = time.time()
453
+ sid = trace.session_id
454
+ workdir = os.path.join(cfg.improver_workdir, sid)
455
+ os.makedirs(workdir, exist_ok=True)
456
+ # One improver per workdir, structurally: concurrent batches on the
457
+ # same sid race on improved.json/verdict.json and pay the model twice.
458
+ if not _acquire_workdir_lock(workdir):
459
+ return ImproverResult(
460
+ session_id=sid, ok=False, improved=None, reply="",
461
+ hermes_session_id=None,
462
+ errors=["workdir locked by a live improver run — skipping"],
463
+ attempts=0, duration_s=0.0,
464
+ )
465
+ try:
466
+ return _improve_locked(cfg, trace, max_attempts, status_cb)
467
+ finally:
468
+ _release_workdir_lock(workdir)
469
+
470
+
471
+ def _improve_locked(
472
+ cfg: Config, trace: Trace, max_attempts: int, status_cb
473
+ ) -> ImproverResult:
474
+ from traceforge.trace import Trace as _T
475
+ from traceforge.validate import validate_structural
476
+ from traceforge.grounding import validate_grounding
477
+ from traceforge.improver.verify import run_verifier
478
+ from traceforge.improver.review import run_review
479
+
480
+ def _status(s: str) -> None:
481
+ if status_cb is not None:
482
+ status_cb(s)
483
+
484
+ started = time.time()
485
+ sid = trace.session_id
486
+ workdir = os.path.join(cfg.improver_workdir, sid)
487
+ rendered = trace.render_compact(
488
+ user_chars=cfg.user_chars,
489
+ assistant_chars=cfg.assistant_chars,
490
+ tool_result_chars=cfg.tool_result_chars,
491
+ tool_args_chars=cfg.tool_args_chars,
492
+ include_reasoning=cfg.include_reasoning,
493
+ )
494
+ output_path = os.path.join(workdir, "improved.json")
495
+ # Full unclipped tool outputs on disk: P4's verbatim-reuse rule is
496
+ # only satisfiable when the improver can actually see the full text.
497
+ outputs_path = _write_outputs_index(cfg, trace, workdir)
498
+
499
+ errors: list[str] = []
500
+ prev_path: Optional[str] = None
501
+ attempts = 0
502
+ improved: Optional[Trace] = None
503
+ reply = ""
504
+ hermes_session_id: Optional[str] = None
505
+ # long-trace attempts don't run hermes chat directly (stages do); the
506
+ # loop below must not touch single-shot navigation vars on that path
507
+ code = 0
508
+ stderr = ""
509
+
510
+ for attempt in range(1, max_attempts + 1):
511
+ attempts = attempt
512
+ candidate = None
513
+ # Fast-path: when a previous run already left an artifact in this
514
+ # workdir, re-adjudicate it first (regate). Only pay the model for
515
+ # a fresh write when nothing usable exists on disk. This makes
516
+ # "rerun after a gate change" nearly free instead of a full
517
+ # re-write of every trace.
518
+ if attempt == 1 and os.path.exists(output_path):
519
+ _status("regate")
520
+ candidate = _parse_improved(output_path)
521
+ # Static pre-flight: an artifact that still carries rendering
522
+ # clip markers is dead on arrival at review (reviewers kill
523
+ # invented counts; grounding silently strips them). Fail it
524
+ # here — one static check instead of 4 deep-thinker
525
+ # sessions re-discovering it.
526
+ if candidate is not None and _clip_markers_present(candidate):
527
+ candidate = None
528
+ errors = [CLIP_MARKER_ERROR]
529
+ # keep the failed artifact as the repair reference: the
530
+ # next attempt must FIX it (REPAIR_HEADER + errors), not
531
+ # blind-rewrite from a fresh TASK_HEADER that would
532
+ # silently drop the specific clip-marker error.
533
+ prev_path = output_path
534
+ if candidate is not None:
535
+ reply = "REGATE (existing artifact)"
536
+ code = 0
537
+ stderr = ""
538
+ elif len(rendered) <= cfg.chunk_max_chars:
539
+ _status("improving" if attempt == 1 else f"repair {attempt}")
540
+ try:
541
+ candidate, reply_one, hsid, code, stderr = _generate_single(
542
+ cfg, trace, rendered, workdir, output_path, errors,
543
+ prev_path, sid,
544
+ )
545
+ except subprocess.TimeoutExpired:
546
+ errors = [f"harness timeout after {cfg.harness_timeout_s}s"]
547
+ continue
548
+ reply = reply_one
549
+ hermes_session_id = hsid
550
+ if reply.startswith("REJECTED"):
551
+ errors = [f"rejected by improver: {reply[:300]}"]
552
+ break
553
+ else:
554
+ # long-trace staged pipeline: digest -> map -> writer
555
+ from traceforge.improver import longtrace
556
+
557
+ chunk_dir = os.path.join(workdir, "chunks")
558
+ if prev_path is None:
559
+ _status("digest+map")
560
+ chunks = chunker.chunk_trace(
561
+ trace,
562
+ max_chars=cfg.chunk_max_chars,
563
+ overlap_messages=cfg.chunk_overlap_messages,
564
+ render_kwargs={
565
+ "user_chars": cfg.user_chars,
566
+ "assistant_chars": cfg.assistant_chars,
567
+ "tool_result_chars": cfg.tool_result_chars,
568
+ "tool_args_chars": cfg.tool_args_chars,
569
+ "include_reasoning": cfg.include_reasoning,
570
+ },
571
+ )
572
+ if not chunks:
573
+ errors = ["chunking produced no chunks"]
574
+ continue
575
+ longtrace.write_chunks(chunk_dir, chunks, trace)
576
+ _status(f"digest 0/{len(chunks)}")
577
+ digests, derrs = longtrace.digest_all(
578
+ cfg, trace, chunks, workdir,
579
+ progress_cb=lambda done, total: _status(
580
+ f"digest {done}/{total}"
581
+ ),
582
+ )
583
+ if derrs:
584
+ errors = [f"digest failures: {derrs}"]
585
+ continue
586
+ _status(f"map ({len(chunks)} chunks)")
587
+ map_d, merr = longtrace.build_map(cfg, trace, digests, workdir)
588
+ if map_d is None:
589
+ errors = [f"map merge failed: {merr}"]
590
+ continue
591
+ _append_status(cfg, sid, {
592
+ "stage": "map", "chunks": len(chunks),
593
+ "approx_tokens": sum(c.approx_chars for c in chunks) // 4,
594
+ })
595
+ # writer (also used for repair attempts on long traces)
596
+ map_d = longtrace._json_file(os.path.join(workdir, "session_map.json"))
597
+ if map_d is None:
598
+ errors = ["session map missing after long-trace stage"]
599
+ continue
600
+ _status("writer")
601
+ candidate, werr = longtrace.run_writer(
602
+ cfg, trace, map_d, chunk_dir, workdir, output_path,
603
+ repair_errors=errors if prev_path else None,
604
+ )
605
+ if candidate is None:
606
+ errors = [f"long-trace writer failed: {werr}"]
607
+ continue
608
+ reply = "WRITER DONE"
609
+ # The artifact on disk is the source of truth — NOT the exit code.
610
+ # The improver may hit its turn cap after writing a perfectly valid
611
+ # file (e.g. right after a repair); validate what exists regardless.
612
+ candidate = _parse_improved(output_path)
613
+ if candidate is None:
614
+ if code != 0:
615
+ stderr_tail = f"; stderr: {stderr.strip()[-500:]}" if stderr else ""
616
+ errors = [
617
+ f"hermes chat exited with code {code} and "
618
+ f"improved.json missing/unparseable{stderr_tail}"
619
+ ]
620
+ else:
621
+ errors = [
622
+ "improved.json missing, unparseable, or missing 'messages'"
623
+ ]
624
+ continue
625
+ # Static clip-marker gate on EVERY path, not just regate: writers
626
+ # and repairs can still copy markers out of the clipped rendering.
627
+ # A marker artifact is dead on arrival at review (and grounding
628
+ # silently forgives it) — fail fast here instead of burning
629
+ # verifier + reviewer sessions on it.
630
+ if _clip_markers_present(candidate):
631
+ errors = [f"clip-marker gate: {CLIP_MARKER_ERROR}"]
632
+ # repair must fix THIS artifact (REPAIR_HEADER + errors), not
633
+ # blind-rewrite from a TASK_HEADER that drops the specifics
634
+ prev_path = output_path
635
+ continue
636
+ prev_path = output_path
637
+ improved = candidate
638
+ # Pipeline framing markers ("[trace segment k of n]", "[subagent
639
+ # brief — role: ...]") belong to the improver's INPUT; strip them
640
+ # before the gates so the echo check compares against the real task
641
+ # and the promoted artifact carries no scaffolding.
642
+ from traceforge.validate import strip_scaffold_markers
643
+ if strip_scaffold_markers(improved):
644
+ improved.save(output_path)
645
+ errs = validate_structural(improved)
646
+ if errs and any(_EMPTY_ROW_RE in e for e in errs):
647
+ # blank assistant rows are pure noise — drop them and re-check
648
+ # rather than paying a full rewrite to remove one empty row.
649
+ # Runs even when other errors are present: a trace with a blank
650
+ # row AND a tool tail must get both fixes, not neither.
651
+ if _drop_empty_assistant_rows(improved):
652
+ improved.save(output_path) # repair must start from the fix
653
+ errs = validate_structural(improved)
654
+ if not errs:
655
+ _status("dropped empty assistant rows")
656
+ if _only_tool_tail(errs):
657
+ # writer stopped mid-call: one row short of valid. Synthesize
658
+ # the closing answer instead of paying a full repair round
659
+ # that reproduces the same cut (measured: 49/1535 traces).
660
+ fixed = _append_synth_final(cfg, improved, trace)
661
+ if fixed is not None:
662
+ improved = fixed
663
+ # persist BEFORE re-validating: a later repair round reads
664
+ # prev_path from disk, so an in-memory-only fix is thrown
665
+ # away and the writer reproduces the same tool tail.
666
+ improved.save(output_path)
667
+ errs = validate_structural(improved)
668
+ if not errs:
669
+ _status("final answer (tool-tail synth)")
670
+ if not errs:
671
+ errs = validate_grounding(improved, trace)
672
+ if not errs and (cfg.verify or cfg.review):
673
+ # Gate-pass cache: rounds that already PASSED for THIS exact
674
+ # artifact content are replayed instead of re-paid. Survives
675
+ # batch interruptions (turn cap, SIGTERM, terminal close); a
676
+ # rewritten artifact has a different digest and re-runs all
677
+ # rounds fresh.
678
+ digest = _artifact_digest(improved)
679
+ gate_cache = _load_gate_cache(workdir)
680
+ gate_pass_keys = set(gate_cache.get(digest, []))
681
+
682
+ def _gate_pass(stage: str, round_no: int) -> None:
683
+ _cache_gate_pass(gate_cache, digest, stage, round_no)
684
+ _save_gate_cache(workdir, gate_cache)
685
+
686
+ verify_failures: list[str] = []
687
+ # ---- N independent verification rounds (web fact-check)
688
+ # Strict admission: every round must end PASS. A transient ERROR
689
+ # (timeout, unparseable verdict) gets ONE in-place retry; a
690
+ # second ERROR surfaces as an error row — never silent
691
+ # under-verification, never a model repair for infra issues.
692
+ if cfg.verify:
693
+ passes = 0
694
+ vr = 1
695
+ while vr <= cfg.verify_rounds:
696
+ if f"verify:{vr}" in gate_pass_keys:
697
+ passes += 1 # already adjudicated on identical bytes
698
+ _status(f"verify {vr}/{cfg.verify_rounds} (cached)")
699
+ _append_status(cfg, sid, {
700
+ "stage": "verify", "round": vr,
701
+ "verdict": "PASS", "cached": True,
702
+ })
703
+ vr += 1
704
+ continue
705
+ _status(f"verify {vr}/{cfg.verify_rounds}")
706
+ vres = run_verifier(cfg, output_path, workdir)
707
+ if vres.verdict == "PASS":
708
+ passes += 1
709
+ _gate_pass("verify", vr)
710
+ _append_status(cfg, sid, {
711
+ "stage": "verify", "round": vr,
712
+ "verdict": "PASS",
713
+ "claims_checked": vres.claims_checked,
714
+ })
715
+ vr += 1
716
+ continue
717
+ if vres.verdict == "FAIL":
718
+ claims = "\n".join(
719
+ f'- "{u.get("claim", "?")}" — {u.get("why", "?")}'
720
+ for u in vres.unsupported[:10]
721
+ )
722
+ verify_failures.append(
723
+ f"[verify r{vr}] external claims unsupported:\n{claims}"
724
+ )
725
+ _append_status(cfg, sid, {
726
+ "stage": "verify", "round": vr, "verdict": "FAIL",
727
+ "claims_checked": vres.claims_checked,
728
+ })
729
+ break # concrete FAIL: repair next attempt
730
+ # transient ERROR: one in-place retry of the same round
731
+ retry = run_verifier(cfg, output_path, workdir)
732
+ if retry.verdict == "PASS":
733
+ passes += 1
734
+ _gate_pass("verify", vr)
735
+ _append_status(cfg, sid, {
736
+ "stage": "verify", "round": vr,
737
+ "verdict": "PASS", "retried": True,
738
+ "claims_checked": retry.claims_checked,
739
+ })
740
+ vr += 1
741
+ continue
742
+ if retry.verdict == "FAIL":
743
+ claims = "\n".join(
744
+ f'- "{u.get("claim", "?")}" — {u.get("why", "?")}'
745
+ for u in retry.unsupported[:10]
746
+ )
747
+ verify_failures.append(
748
+ f"[verify r{vr}] external claims unsupported:\n{claims}"
749
+ )
750
+ _append_status(cfg, sid, {
751
+ "stage": "verify", "round": vr, "verdict": "FAIL",
752
+ "retried": True,
753
+ "claims_checked": retry.claims_checked,
754
+ })
755
+ break
756
+ verify_failures.append(
757
+ f"[verify r{vr}] infra error x2: "
758
+ f"{vres.error or 'verifier error'} / "
759
+ f"{retry.error or 'verifier error'}"
760
+ )
761
+ _append_status(cfg, sid, {
762
+ "stage": "verify", "round": vr, "verdict": "ERROR",
763
+ "error": (
764
+ f"{vres.error or 'verifier error'} / "
765
+ f"{retry.error or 'verifier error'}"
766
+ )[:300],
767
+ })
768
+ break
769
+ # ---- M independent review rounds (quality pillars)
770
+ review_failures: list[str] = []
771
+ task_snip = trace.first_task_message() or "(no user message)"
772
+ if not verify_failures and cfg.review:
773
+ for rr in range(1, cfg.review_rounds + 1):
774
+ if f"review:{rr}" in gate_pass_keys:
775
+ _status(f"review {rr}/{cfg.review_rounds} (cached)")
776
+ _append_status(cfg, sid, {
777
+ "stage": "review", "round": rr, "verdict": "PASS",
778
+ "cached": True,
779
+ })
780
+ continue
781
+ _status(f"review {rr}/{cfg.review_rounds}")
782
+ rres = run_review(
783
+ cfg, output_path, task_snip, workdir, rr
784
+ )
785
+ if not rres.ok() and rres.verdict == "ERROR":
786
+ rres = run_review(
787
+ cfg, output_path, task_snip, workdir, rr
788
+ ) # one in-place retry on transient infra error
789
+ if rres.ok():
790
+ _gate_pass("review", rr)
791
+ _append_status(cfg, sid, {
792
+ "stage": "review", "round": rr, "verdict": "PASS",
793
+ })
794
+ continue
795
+ if rres.verdict == "FAIL":
796
+ review_failures.append(
797
+ "[review r%d] pillar violations:\n%s"
798
+ % (rr, "\n".join(rres.violations[:10]))
799
+ )
800
+ _append_status(cfg, sid, {
801
+ "stage": "review", "round": rr, "verdict": "FAIL",
802
+ })
803
+ break # concrete FAIL: repair next attempt
804
+ review_failures.append(
805
+ f"[review r{rr}] infra error x2: {rres.error or 'review error'}"
806
+ )
807
+ _append_status(cfg, sid, {
808
+ "stage": "review", "round": rr, "verdict": "ERROR",
809
+ "error": (rres.error or "review error")[:300],
810
+ })
811
+ break
812
+ errs = verify_failures + review_failures
813
+ if errs:
814
+ # Infra failures (verifier/reviewer down) are NOT content errors:
815
+ # a model repair cannot fix them. Fail fast without re-running
816
+ # the improver so no model time is wasted on infrastructure.
817
+ if any("infra error x2" in e for e in errs):
818
+ return ImproverResult(
819
+ session_id=sid, ok=False, improved=None, reply=reply,
820
+ hermes_session_id=hermes_session_id,
821
+ errors=errs, attempts=attempts,
822
+ duration_s=time.time() - started,
823
+ )
824
+ errors = errs + (
825
+ [f"note: hermes exit code {code}"] if code != 0 else []
826
+ )
827
+ continue
828
+ improved.session_id = f"ideal-{sid}"
829
+ improved.parent_session_id = sid
830
+ # cfg.model None = default profile; the exact model is recoverable
831
+ # from hermes_session_id (recorded in the manifest).
832
+ improved.model = cfg.model or "hermes-default"
833
+ improved.source = "hermes-one-shot"
834
+ return ImproverResult(
835
+ session_id=sid, ok=True, improved=improved, reply=reply,
836
+ hermes_session_id=hermes_session_id, errors=[],
837
+ attempts=attempts, duration_s=time.time() - started,
838
+ )
839
+
840
+ return ImproverResult(
841
+ session_id=sid, ok=False, improved=None, reply=reply,
842
+ hermes_session_id=hermes_session_id, errors=errors,
843
+ attempts=attempts, duration_s=time.time() - started,
844
+ )
845
+
846
+
847
+ def cleanup_workdir(cfg: Config, sid: str, keep: bool = False) -> None:
848
+ wd = os.path.join(cfg.improver_workdir, sid)
849
+ if not keep and os.path.isdir(wd):
850
+ shutil.rmtree(wd, ignore_errors=True)
traceforge/improver/longtrace.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Long-trace pipeline: chunk -> parallel digests -> map -> writer.
2
+
3
+ For sessions whose compact rendering exceeds cfg.chunk_max_chars, the
4
+ single-shot improver is replaced by this staged pipeline. The trace's raw
5
+ chunks are ALSO written to disk (chunk_dir/chunk_NNN.txt) so the writer
6
+ can look up exact tool outputs by reference, keeping each agent context
7
+ small even for ~10M-token sessions.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import concurrent.futures as cf
12
+ import json
13
+ import os
14
+ import re
15
+ import subprocess
16
+ import threading
17
+ import time
18
+ from typing import Optional
19
+
20
+ from traceforge import chunker
21
+ from traceforge.config import Config
22
+ from traceforge.trace import Message, Trace, json_loads_lenient
23
+ from traceforge.improver.digest_prompts import (
24
+ CHUNK_DIGEST_HEADER,
25
+ MAP_MERGE_HEADER,
26
+ WRITER_LONG_HEADER,
27
+ )
28
+
29
+
30
+ def _hermes_run(
31
+ prompt_path: str, workdir: str, max_turns: int, timeout: int
32
+ ) -> tuple[int, str, str]:
33
+ cmd = [
34
+ "hermes", "chat",
35
+ "--query-file", prompt_path,
36
+ "--in", workdir,
37
+ "-Q",
38
+ "--max-turns", str(max_turns),
39
+ ]
40
+ proc = subprocess.run(
41
+ cmd, capture_output=True, text=True, timeout=timeout, cwd=workdir
42
+ )
43
+ return proc.returncode, proc.stdout or "", proc.stderr or ""
44
+
45
+
46
+ def _json_file(path: str) -> Optional[dict]:
47
+ if not os.path.exists(path):
48
+ return None
49
+ return json_loads_lenient(open(path, encoding="utf-8").read())
50
+
51
+
52
+ def _put_prompt(cfg: Config, name: str, text: str) -> str:
53
+ os.makedirs(cfg.prompts_dir, exist_ok=True)
54
+ p = os.path.join(cfg.prompts_dir, name)
55
+ with open(p, "w", encoding="utf-8") as f:
56
+ f.write(text)
57
+ return p
58
+
59
+
60
+ def write_chunks(
61
+ chunk_dir: str, chunks: "list[chunker.Chunk]", trace: Trace
62
+ ) -> None:
63
+ """Persist raw chunks + a ref->output index for verbatim lookup."""
64
+ os.makedirs(chunk_dir, exist_ok=True)
65
+ for c in chunks:
66
+ with open(
67
+ os.path.join(chunk_dir, f"chunk_{c.index:03d}.txt"), "w",
68
+ encoding="utf-8",
69
+ ) as f:
70
+ f.write(c.rendered)
71
+
72
+
73
+ def _digest_one(
74
+ cfg: Config, trace: Trace, c: chunker.Chunk, workdir: str
75
+ ) -> tuple[Optional[dict], str]:
76
+ """Run one digester session over one chunk. Returns (digest, error)."""
77
+ digest_path = os.path.join(workdir, f"digest_{c.index:03d}.json")
78
+ prompt = CHUNK_DIGEST_HEADER.format(
79
+ digest_path=digest_path, n=c.index
80
+ ) + c.rendered
81
+ pp = _put_prompt(cfg, f"digest_{trace.session_id}_c{c.index:03d}.txt", prompt)
82
+ try:
83
+ code, _out, _err = _hermes_run(
84
+ pp, workdir, cfg.digest_max_turns, cfg.digest_timeout_s
85
+ )
86
+ except subprocess.TimeoutExpired:
87
+ return None, f"chunk {c.index} digest timeout"
88
+ d = _json_file(digest_path)
89
+ if d is None:
90
+ return None, f"chunk {c.index} digest unparseable (exit {code})"
91
+ return d, ""
92
+
93
+
94
+ def digest_all(
95
+ cfg: Config, trace: Trace, chunks: list, workdir: str,
96
+ progress_cb=None,
97
+ ) -> tuple[list[dict], list[str]]:
98
+ """Digest all chunks, cfg.digest_workers in parallel.
99
+
100
+ progress_cb(done, total) fires after each chunk completes (from the
101
+ worker threads) — used for live progress display.
102
+ """
103
+ errors: list[str] = []
104
+ digests: list[dict] = []
105
+ done = 0
106
+ lock = threading.Lock()
107
+ with cf.ThreadPoolExecutor(max_workers=cfg.digest_workers) as ex:
108
+ futs = {
109
+ ex.submit(_digest_one, cfg, trace, c, workdir): c.index
110
+ for c in chunks
111
+ }
112
+ for fut in cf.as_completed(futs):
113
+ idx = futs[fut]
114
+ d, err = fut.result()
115
+ with lock:
116
+ done += 1
117
+ if progress_cb is not None:
118
+ try:
119
+ progress_cb(done, len(chunks))
120
+ except Exception:
121
+ pass # display callback must never break the stage
122
+ if err:
123
+ errors.append(err)
124
+ elif d is not None:
125
+ digests.append(d)
126
+ digests.sort(key=lambda d: int(d.get("chunk", 0)))
127
+ return digests, errors
128
+
129
+
130
+ def build_map(
131
+ cfg: Config, trace: Trace, digests: list[dict], workdir: str
132
+ ) -> tuple[Optional[dict], str]:
133
+ """Merge digests into the unified session map."""
134
+ map_path = os.path.join(workdir, "session_map.json")
135
+ blob = "\n\n".join(json.dumps(d, ensure_ascii=False, indent=1) for d in digests)
136
+ prompt = MAP_MERGE_HEADER.format(map_path=map_path) + "\nDIGESTS:\n" + blob
137
+ pp = _put_prompt(cfg, f"map_{trace.session_id}.txt", prompt)
138
+ try:
139
+ code, _out, _err = _hermes_run(
140
+ pp, workdir, cfg.digest_max_turns, cfg.digest_timeout_s
141
+ )
142
+ except subprocess.TimeoutExpired:
143
+ return None, "map merge timeout"
144
+ m = _json_file(map_path)
145
+ if m is None:
146
+ return None, f"session map unparseable (exit {code})"
147
+ return m, ""
148
+
149
+
150
+ def run_writer(
151
+ cfg: Config, trace: Trace, map_d: dict, chunk_dir: str,
152
+ workdir: str, output_path: str,
153
+ repair_errors: Optional[list[str]] = None,
154
+ ) -> tuple[Optional[Trace], str]:
155
+ """Run the ideal-trace writer over the session map."""
156
+ map_path = os.path.join(workdir, "session_map.json")
157
+ head = WRITER_LONG_HEADER.format(
158
+ output_path=output_path,
159
+ guidelines_path=cfg.guidelines_path,
160
+ map_path=map_path,
161
+ chunk_dir=chunk_dir,
162
+ )
163
+ if repair_errors:
164
+ head = (
165
+ "Your previous ideal trace FAILED validation. Fix these exact "
166
+ f"errors:\n- " + "\n- ".join(repair_errors[:20]) +
167
+ "\n\nThe previous attempt is at " + output_path +
168
+ " — read it, fix it in place.\n\n" + head
169
+ )
170
+ pp = _put_prompt(cfg, f"writer_{trace.session_id}.txt", head)
171
+ try:
172
+ code, _out, _err = _hermes_run(
173
+ pp, workdir, cfg.max_turns or 16, cfg.harness_timeout_s
174
+ )
175
+ except subprocess.TimeoutExpired:
176
+ return None, "writer timeout"
177
+ from traceforge.improver.harness import _parse_improved
178
+
179
+ t = _parse_improved(output_path)
180
+ if t is None:
181
+ return None, f"writer output unparseable (exit {code})"
182
+ return t, ""
traceforge/improver/prompt.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Builds the improver prompt: task spec + guidelines + compact trace.
2
+
3
+ The improver is a Hermes one-shot session with tools. It is told to
4
+ 1) read the guidelines file itself (agentic grounding),
5
+ 2) study the rendered real trace,
6
+ 3) write the ideal-world rewrite to <workdir>/improved.json,
7
+ 4) self-check JSON validity before finishing.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ TASK_HEADER = """You are a reference improver agent. Your job: rewrite the
14
+ REAL agent trace below into the IDEAL-WORLD version of the same task — what a
15
+ perfect execution would have looked like, following the guidelines exactly.
16
+ The rewrite is the REFERENCE a benchmark scores the source model against: the
17
+ less you have to change, the better that model is. Change only what the
18
+ guidelines require.
19
+
20
+ SCOPE — this is a REWRITE, not a re-execution. Hard limits:
21
+ - The rendered trace below is your ONLY source of truth. Do NOT investigate
22
+ the original environment: no reading the actual project files, no git
23
+ history, no running commands from the trace, no browsing. You cannot and
24
+ must not verify against the real world.
25
+ - Work alone. Do not spawn subagents or delegate.
26
+ - Tool budget (this is all you need): (1) read the guidelines file,
27
+ (2) write improved.json, (3) re-read it to validate, (4) reply.
28
+ - Go straight to writing. Analysis of the trace happens in your reply
29
+ reasoning, not in exploratory tool calls.
30
+
31
+ QUALITY PILLARS (binding — the guidelines file is the full spec):
32
+ - P1 Efficient but deep reasoning: high-signal decision logic per turn
33
+ (why this call, what the result implies, what to verify next); no filler.
34
+ FOCUS: answer exactly what was asked — for a follow-up question,
35
+ deliver the new piece only; never re-present facts the reader already
36
+ got in an earlier turn of the same session.
37
+ SUBSTANCE: the final answer presents the concrete result the task asked
38
+ for — the actual numbers, names, paths, values or code from the tool
39
+ evidence — never a description of what was retrieved. "I pulled the
40
+ nutrition data and the recipe" is poor; the data and the recipe ARE the
41
+ answer. Naming the work instead of showing its result fails the task
42
+ even when every tool call was correct.
43
+ - P2 No committed mistakes: FIRST list the real trace's mistakes (failed
44
+ tool calls, retry loops, wrong turns, user feedback/corrections), then
45
+ route the ideal path around every single one — correct action the first
46
+ time; never show the failure or the repair. WHY: the assistant turns are
47
+ the loss-bearing target; a mistake in the target is imitated, a mistake in
48
+ the context is not. ONE exception (guidelines §4.1): an environment-caused
49
+ failure the ideal agent could not foresee may stay as a byte-verbatim tool
50
+ result, with exactly one corrective step in the next assistant turn and
51
+ recorded as a retained recovery by the harness. Model-caused errors (wrong
52
+ tool, wrong args, wrong path, bad reasoning) are never eligible.
53
+ - P3 Perfect tool calls: every call necessary and correctly parameterized;
54
+ batch independent calls in parallel; no exploratory flailing; no call
55
+ whose result the narrative doesn't use. BATCHING IS STRUCTURAL: when the
56
+ real trace issues two calls in separate assistant turns but the second
57
+ does not consume the first's output, put BOTH tool_calls in ONE assistant
58
+ turn and let the two results follow in order. Do not preserve the source's
59
+ one-call-per-turn rhythm — that rhythm is the defect the judge rejects.
60
+ A duplicate call (same tool, same arguments) is dropped, never batched.
61
+ - P4 Fact-driven: reuse real tool outputs verbatim where the ideal path
62
+ makes the same call; never invent files, commands, or results; no fake
63
+ success — the final answer is grounded in the trace's real evidence.
64
+ The FULL unclipped text of every real tool output is in {outputs_path}
65
+ (one JSON object per line; "i" = the [N] message index in the rendered
66
+ trace). When the ideal path makes the same call as the real trace, take
67
+ the result from there (a contiguous portion is fine). NEVER copy the
68
+ rendering's clip markers ("…[+N chars clipped]") into the ideal trace —
69
+ they are display artifacts of THIS prompt, not content. If you compress
70
+ a tool result, every substantive line must still be quoted from the
71
+ real output.
72
+ CONSTRUCTION ORDER (do this in this exact order): (1) decide the ideal
73
+ call path; (2) for every tool call on it, COPY the result content from
74
+ the matching outputs.jsonl row FIRST — byte-verbatim or a contiguous
75
+ slice; (3) only then write the reasoning and final answer AROUND those
76
+ real results. A tool result you cannot quote from outputs.jsonl is NOT
77
+ writable: either reroute the ideal path so that call is unnecessary, or
78
+ drop the claim. NEVER assemble a tool result from fragments you believe
79
+ are true — stitching individually-plausible lines is fabrication, the
80
+ single most common rejection reason.
81
+ External-world claims (versions, API behavior, dates, technical facts)
82
+ only when the trace itself proves them — otherwise leave them out.
83
+ A web-searching fact-checker reviews every external claim AFTER you
84
+ finish; unsupported claims send the trace back for repair.
85
+ - P5 Language: one language per trace (the first user message's language);
86
+ perfect grammar in assistant prose; keep the user message verbatim.
87
+ NEVER open by restating the brief. The first assistant turn must do
88
+ work — a tool call, a decision, a finding — not re-present the user's
89
+ own text back to them. A message that is a near-copy of the opening
90
+ brief (>=85% similar) is rejected mechanically before the judge sees it.
91
+ - P6 No refusals: complete the task. If it is genuinely un-completable,
92
+ reply starting "REJECTED: <one-line reason>" and write no output file.
93
+
94
+ TRAINING-SIGNAL CONTRACT (why the pillars are shaped this way): this corpus
95
+ is SFT — loss is computed on assistant tokens only; tool results, user turns
96
+ and system prompts are masked context. Never put a failure, a fabricated
97
+ error, or a "try again" narrative in an assistant turn. Never fabricate a
98
+ tool output to make a recovery work. This corpus is not preference or RL
99
+ data: do not invent rejected samples, preference pairs, or reward labels.
100
+
101
+ PROCEDURE: (1) read the guidelines, (2) mine the mistakes, (3) write
102
+ {output_path} in one go, (4) re-read it and validate (JSON, pairing,
103
+ pillars), (5) summarize the changes.
104
+
105
+ Ground rules:
106
+ - Read the guidelines file first: {guidelines_path}
107
+ Treat it as the authoritative spec. If it has no rule for a case you hit,
108
+ follow the six pillars and the training-signal contract, and use your best
109
+ judgment — never invent a rule that contradicts them.
110
+ - CRITICAL — intent fidelity: the ideal trace's FIRST user message must be
111
+ the real trace's first USER TASK message, verbatim, byte-exact (skip
112
+ handoff artifacts — `[CONTEXT COMPACTION]` Hermes rows and "This
113
+ session is being continued…" Claude Code rows; they carry no task).
114
+ If the real trace contains no user task message, do not invent
115
+ one: reply REJECTED: no user task in source trace and write no file.
116
+ You may merge subsequent real user messages into a shorter, cleaner
117
+ dialogue, but the OPENING task must stay the same task.
118
+ - Preserve the original task intent (first user message) and any real context
119
+ (file paths, commands, tool results) that a correct execution would also
120
+ have produced. You MAY compress and clean the trace's own tool results,
121
+ but you must NOT invent facts the trace does not support.
122
+ - Keep realistic tool_io. The improved trace must still read like a genuine
123
+ agent session — just a flawless one: efficient tool use, clear reasoning,
124
+ no flailing, no exploration dead-ends, no policy violations, precise
125
+ final answer.
126
+
127
+ Output contract (CRITICAL):
128
+ - Write the improved trace as JSON to exactly this path: {output_path}
129
+ - Schema:
130
+ {{
131
+ "messages": [
132
+ {{"role": "user"|"assistant"|"tool",
133
+ "content": "...",
134
+ "tool_calls": [{{"id": "...", "name": "...",
135
+ "arguments": "<json string>"}}] // assistant only
136
+ "tool_call_id": "...", "tool_name": "..." // tool rows only
137
+ }}
138
+ ]
139
+ }}
140
+ Optional per-message key: "reasoning" (string) for thinking text.
141
+ - tool_calls[].arguments must be a JSON-encoded STRING (OpenAI style).
142
+ - Every assistant tool_call id must have exactly one matching tool row
143
+ (tool_call_id), and vice versa.
144
+ - Validate your output before finishing: re-read the file you wrote with a
145
+ tool, confirm it parses as JSON and the schema + pairing rules hold.
146
+ - Then reply with a one-paragraph summary of what you changed and why.
147
+ """
148
+
149
+ REPAIR_HEADER = """Your previous attempt at improving this trace FAILED
150
+ validation. Fix it.
151
+
152
+ Validation errors:
153
+ {errors}
154
+
155
+ Previous attempt: {prev_path}
156
+ (Rendered real trace and guidelines still apply, restated below.)
157
+
158
+ The FULL unclipped text of every real tool output is in {outputs_path}
159
+ (one JSON object per line; "i" = the [N] message index in the rendered
160
+ trace). Grounding failures mean: take the ideal tool results from there
161
+ verbatim (contiguous portion fine) — do not paraphrase real outputs.
162
+ NEVER replace a bad result by assembling plausible lines: copy byte-
163
+ verbatim from outputs.jsonl, or drop the call/claim. Clip markers
164
+ ("…[+N chars clipped]") are display artifacts — never reproduce them,
165
+ and remove any that the previous attempt copied in.
166
+
167
+ SCOPE (same hard limits as before): this is a REWRITE, not an
168
+ investigation. Do NOT read project files, repo source, gate code, or git
169
+ history. Do NOT spawn subagents. Budget: (1) read the previous
170
+ improved.json once, (2) read the specific outputs.jsonl rows cited in
171
+ the errors above, (3) write the corrected file, (4) re-read it once to
172
+ validate, (5) reply. NO other tool calls — every forensic check the
173
+ reviewer already did for you is spelled out in the errors.
174
+
175
+ WRITE MECHANICS: to overwrite an existing file, read the ENTIRE current
176
+ file first (not a page of it), or write via a Python script
177
+ (execute_code / terminal) that builds the new JSON with json.dumps —
178
+ never hand-type large JSON. If a write is refused, re-read the full file
179
+ and retry the write immediately; do not spend turns analyzing the
180
+ refusal.
181
+
182
+ Write the corrected improved trace JSON to: {output_path}
183
+ Validate the file yourself before finishing, then summarize the fixes.
184
+ """
185
+
186
+
187
+ def build_prompt(
188
+ *,
189
+ rendered_trace: str,
190
+ guidelines_path: str,
191
+ output_path: str,
192
+ outputs_path: "str | None" = None,
193
+ repair: "os._Environ | None" = None,
194
+ errors: list[str] | None = None,
195
+ prev_path: str | None = None,
196
+ language: str = "en",
197
+ ) -> str:
198
+ """Compose the full improver prompt for one trace."""
199
+ if errors and prev_path:
200
+ head = REPAIR_HEADER.format(
201
+ errors="\n".join(f"- {e}" for e in errors),
202
+ prev_path=prev_path,
203
+ output_path=output_path,
204
+ outputs_path=outputs_path or "(not provided)",
205
+ )
206
+ else:
207
+ head = TASK_HEADER.format(
208
+ guidelines_path=guidelines_path,
209
+ output_path=output_path,
210
+ outputs_path=outputs_path or "(not provided)",
211
+ )
212
+ # P5 is stated in the guidelines, but the writer still mirrors the
213
+ # language of the source material (German briefs, German tool output)
214
+ # instead of the task's. Naming the language explicitly, next to the
215
+ # trace, is what actually holds it.
216
+ from traceforge.classify import LANGUAGE_NAMES
217
+ lang = LANGUAGE_NAMES.get(language, "English")
218
+ lang_line = (
219
+ f"\nLANGUAGE (hard requirement): this trace's task is in {lang}. "
220
+ f"Write EVERY assistant message — prose, plan steps, final answer — "
221
+ f"in {lang}. The first user message stays byte-verbatim in its "
222
+ f"original language. Source material in another language (briefs, "
223
+ f"tool output, file contents) is quoted as-is, never mirrored: "
224
+ f"quoting German evidence inside an English trace is correct, "
225
+ f"answering in German is not.\n"
226
+ )
227
+ return (
228
+ f"{head}\n"
229
+ f"{lang_line}"
230
+ f"{'=' * 70}\n"
231
+ f"REAL TRACE TO IMPROVE (compact rendering, clipped tool output):\n\n"
232
+ f"{rendered_trace}\n"
233
+ )
234
+
235
+
236
+ def write_prompt_file(prompts_dir: str, session_id: str, prompt: str) -> str:
237
+ os.makedirs(prompts_dir, exist_ok=True)
238
+ path = os.path.join(prompts_dir, f"improve_{session_id}.txt")
239
+ with open(path, "w", encoding="utf-8") as f:
240
+ f.write(prompt)
241
+ return path
traceforge/improver/review.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quality-pillar reviewer: independent adversarial review rounds.
2
+
3
+ The improver and verifier already self-check and web-check; this stage is
4
+ the final human-dream-quality gate: independent reviewer sessions read
5
+ the ideal trace against the six pillars and the ORIGINAL user intent,
6
+ with NO knowledge of the improver's reasoning, and must unanimously PASS.
7
+ cfg.review_rounds runs independently (default 2); ALL must PASS.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import time
16
+ from typing import Any, Optional
17
+
18
+ from traceforge.config import Config
19
+ from traceforge.trace import json_loads_lenient
20
+
21
+ REVIEW_HEADER = """You are an independent, adversarial quality reviewer for
22
+ a synthetic agent trace. You had NO part in creating it. Judge ONLY what
23
+ you see, against the quality pillars below.
24
+
25
+ The ideal trace under review: {improved_path}
26
+ The ORIGINAL task (for intent comparison): {task_snippet}
27
+ Quality pillars (the spec): {guidelines_path} — read it first.
28
+
29
+ Judge the trace against every pillar. Typical failure shapes:
30
+ - P1 violated: bloated reasoning, filler, restating the obvious
31
+ - P2 violated: a mistake survives (failed call, retry loop, wrong turn,
32
+ user-correction absorbed nowhere)
33
+ - P3 violated: unnecessary/exploratory tool calls, missing parallel
34
+ batching, wrong parameters
35
+ - P4 violated: statements without basis in the trace's tool evidence;
36
+ fake success; invented paths/commands/results
37
+ - P5 violated: mixed languages, grammar errors, sloppy prose
38
+ - P6 violated: refusal or task abandonment
39
+ - INTENT violated: the ideal trace solves a different task than the
40
+ original user asked
41
+
42
+ Verdict JSON to {verdict_path}:
43
+ {{
44
+ "verdict": "PASS" | "FAIL",
45
+ "pillar_findings": [
46
+ {{"pillar": "P1", "status": "ok|violated", "detail": "<if violated>"}}
47
+ ],
48
+ "violations": ["<every violation, one line each, concrete>"],
49
+ "notes": "<one paragraph overall judgment>"
50
+ }}
51
+ All pillars must be ok for PASS. Then reply "REVIEW: PASS" or
52
+ "REVIEW: FAIL".
53
+
54
+ DISCIPLINE — you have a LIMITED tool-call budget. Spend it wisely:
55
+ - Read the guidelines once, then the improved.json once.
56
+ - Do NOT re-read the same file repeatedly or re-verify what you already
57
+ checked. Batch related checks into one execute_code/terminal call.
58
+ - From ~turn 10 onward (of 16), STOP forensic tool calls entirely and
59
+ write the verdict file with what you have. A verdict you can defend
60
+ from the checks already done is worth infinitely more than one more
61
+ probe: a missing verdict file FAILS the whole review round as an
62
+ infra error.
63
+ - The verdict file MUST exist on disk BEFORE your final reply. If you
64
+ are running out of budget, write it NOW with your best judgment.
65
+ """
66
+
67
+
68
+ @dataclasses.dataclass
69
+ class ReviewResult:
70
+ verdict: str # PASS | FAIL | ERROR
71
+ violations: list[str] = dataclasses.field(default_factory=list)
72
+ pillar_findings: list[dict] = dataclasses.field(default_factory=list)
73
+ notes: str = ""
74
+ duration_s: float = 0.0
75
+ error: Optional[str] = None
76
+
77
+ def ok(self) -> bool:
78
+ return self.verdict == "PASS"
79
+
80
+
81
+ def _run_review_hermes(
82
+ prompt_path: str, workdir: str, max_turns: int, timeout: int
83
+ ) -> tuple[int, str]:
84
+ cmd = [
85
+ "hermes", "chat",
86
+ "--query-file", prompt_path,
87
+ "--in", workdir,
88
+ "-Q",
89
+ "--max-turns", str(max_turns),
90
+ ]
91
+ proc = subprocess.run(
92
+ cmd, capture_output=True, text=True, timeout=timeout, cwd=workdir
93
+ )
94
+ return proc.returncode, proc.stdout or ""
95
+
96
+
97
+ def parse_review(raw: Optional[str]) -> Optional[dict[str, Any]]:
98
+ if not raw:
99
+ return None
100
+ raw = raw.strip()
101
+ if raw.startswith("```"):
102
+ raw = raw.split("\n", 1)[1] if "\n" in raw else ""
103
+ if raw.rstrip().endswith("```"):
104
+ raw = raw.rstrip()[:-3]
105
+ data = json_loads_lenient(raw)
106
+ if not isinstance(data, dict) or "verdict" not in data:
107
+ return None
108
+ return data
109
+
110
+
111
+ def _rescue_verdict_from_stdout(raw: Optional[str]) -> Optional[dict[str, Any]]:
112
+ """A reviewer that hit its turn cap mid-forensics never writes the
113
+ verdict file; its final chat message still contains the judgment.
114
+ REVIEW: PASS/FAIL line is authoritative; lift the richest JSON verdict
115
+ object the reviewer emitted, if any."""
116
+ if not raw:
117
+ return None
118
+ marker = None
119
+ for m in ("REVIEW: PASS", "REVIEW: FAIL"):
120
+ if m in raw:
121
+ marker = m.split(":")[1].strip()
122
+ break
123
+ if marker is None:
124
+ return None
125
+ data: dict[str, Any] = {"verdict": marker}
126
+ for candidate in _json_object_spans(raw):
127
+ try:
128
+ obj = json_loads_lenient(candidate)
129
+ except Exception:
130
+ continue
131
+ if isinstance(obj, dict) and "verdict" in obj:
132
+ data = obj
133
+ data["verdict"] = marker
134
+ break
135
+ return data
136
+
137
+
138
+ def _json_object_spans(text: str) -> list[str]:
139
+ """Balanced top-level {...} spans from text, largest first."""
140
+ spans: list[tuple[int, int]] = []
141
+ depth = 0
142
+ start = -1
143
+ in_str = False
144
+ esc = False
145
+ for i, ch in enumerate(text):
146
+ if in_str:
147
+ if esc:
148
+ esc = False
149
+ elif ch == "\\":
150
+ esc = True
151
+ elif ch == '"':
152
+ in_str = False
153
+ continue
154
+ if ch == '"':
155
+ in_str = True
156
+ elif ch == "{":
157
+ if depth == 0:
158
+ start = i
159
+ depth += 1
160
+ elif ch == "}" and depth:
161
+ depth -= 1
162
+ if depth == 0 and start >= 0:
163
+ spans.append((start, i + 1))
164
+ start = -1
165
+ spans.sort(key=lambda s: s[1] - s[0], reverse=True)
166
+ return [text[a:b] for a, b in spans[:20]]
167
+
168
+
169
+ def run_review(
170
+ cfg: Config,
171
+ improved_path: str,
172
+ task_snippet: str,
173
+ workdir: str,
174
+ round_no: int,
175
+ ) -> ReviewResult:
176
+ """One independent review round."""
177
+ started = time.time()
178
+ verdict_path = os.path.join(
179
+ workdir, f"review_{round_no:02d}.json"
180
+ )
181
+ if os.path.exists(verdict_path):
182
+ os.unlink(verdict_path)
183
+ prompt = REVIEW_HEADER.format(
184
+ improved_path=improved_path,
185
+ task_snippet=task_snippet[:2000],
186
+ guidelines_path=cfg.guidelines_path,
187
+ verdict_path=verdict_path,
188
+ )
189
+ os.makedirs(cfg.prompts_dir, exist_ok=True)
190
+ prompt_path = os.path.join(
191
+ cfg.prompts_dir,
192
+ f"review_{os.path.basename(os.path.dirname(improved_path))}"
193
+ f"_r{round_no}.txt",
194
+ )
195
+ with open(prompt_path, "w", encoding="utf-8") as f:
196
+ f.write(prompt)
197
+ try:
198
+ _code, _out = _run_review_hermes(
199
+ prompt_path, workdir, cfg.review_max_turns, cfg.review_timeout_s
200
+ )
201
+ except subprocess.TimeoutExpired:
202
+ return ReviewResult(
203
+ verdict="ERROR",
204
+ error=f"review round {round_no} timeout",
205
+ duration_s=time.time() - started,
206
+ )
207
+ data = parse_review(
208
+ open(verdict_path, encoding="utf-8").read()
209
+ if os.path.exists(verdict_path) else None
210
+ )
211
+ if data is None:
212
+ rescue = _rescue_verdict_from_stdout(_out)
213
+ if rescue is not None:
214
+ data = rescue
215
+ if data is None:
216
+ return ReviewResult(
217
+ verdict="ERROR",
218
+ error=f"review round {round_no} produced no parseable verdict",
219
+ duration_s=time.time() - started,
220
+ )
221
+ return ReviewResult(
222
+ verdict=str(data.get("verdict", "FAIL")).upper(),
223
+ violations=[
224
+ str(v) for v in (data.get("violations", []) or [])
225
+ ],
226
+ pillar_findings=[
227
+ p for p in (data.get("pillar_findings", []) or []) if isinstance(p, dict)
228
+ ],
229
+ notes=str(data.get("notes", "")),
230
+ duration_s=time.time() - started,
231
+ )
traceforge/improver/verify.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """External-claim verification stage: a web-searching verifier agent.
2
+
3
+ The grounding gate covers environment-internal facts (paths, outputs from
4
+ the source trace). This stage covers the OTHER class: claims about the
5
+ external world (library versions, API behavior, dates, technical facts)
6
+ that an agent can state without the trace proving them — the classic
7
+ hallucination vector.
8
+
9
+ The verifier is a Hermes one-shot session with the default toolset (web
10
+ search + extract enabled). It reads the improved trace, extracts
11
+ external-world claims, web-searches each, and writes a verdict JSON.
12
+ It VERDICTS, never rewrites — on FAIL the unsupported claims flow back
13
+ into the improver repair loop.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import dataclasses
18
+ import json
19
+ import os
20
+ import subprocess
21
+ import time
22
+ from typing import Any, Optional
23
+
24
+ from traceforge.config import Config
25
+ from traceforge.trace import json_loads_lenient
26
+
27
+ VERIFIER_HEADER = """You are an adversarial fact-checker for a synthetic agent
28
+ trace. The file below is an "ideal-world" agent trace under review for
29
+ corpus admission. Your ONLY job: find incorrect or unverifiable statements
30
+ about the EXTERNAL WORLD and fail the trace if any exist.
31
+
32
+ What counts as an external claim (CHECK IT):
33
+ - Technical facts: library/framework versions, API behavior, default
34
+ settings, command flags, file formats, error semantics
35
+ - Factual assertions: dates, quantities, named results, "X supports Y",
36
+ "Z is deprecated since W"
37
+ - Citations: paper names, spec sections, RFC numbers
38
+
39
+ What does NOT count (SKIP — already verified against the source trace):
40
+ - Anything about the project's own files/paths/commands/outputs
41
+ - The trace's internal narrative (plans, reasoning steps)
42
+ - Opinions, hedged statements, future intentions
43
+
44
+ Procedure (use web_search / web_extract as needed — that is the point):
45
+ 1. Read the improved trace at: {improved_path}
46
+ 2. List every external claim. If there are none, verdict PASS with
47
+ claims_checked: 0 — do not search.
48
+ 3. For each claim: search the web. Primary sources beat blogs; note the
49
+ source URL per claim. A claim passes when a real source confirms it.
50
+ A claim fails when sources contradict it — or passes_with_caveat when
51
+ partially right. Unverifiable-but-asserted-with-confidence = fail.
52
+ 4. Write your verdict as JSON to exactly: {verdict_path}
53
+ Schema:
54
+ {{
55
+ "verdict": "PASS" | "FAIL",
56
+ "claims_checked": <int>,
57
+ "unsupported": [
58
+ {{"claim": "...", "why": "...", "searched": "..."}}
59
+ ],
60
+ "notes": "<one paragraph>"
61
+ }}
62
+ Include ALL checked claims (pass and fail) as a "checked" array with
63
+ per-claim {{"claim", "verdict": "pass"|"fail"|"pass_with_caveat",
64
+ "source"}} entries.
65
+ 5. Reply with just: VERDICT: PASS or VERDICT: FAIL plus claim count.
66
+
67
+ Be adversarial: your value is in finding the wrong statement everyone
68
+ else let slide. But do not fail a trace for hedged or internal statements.
69
+
70
+ DISCIPLINE — you have a LIMITED tool-call budget:
71
+ - Read the trace once; list the claims in your reasoning, then check
72
+ them with batched searches (multiple queries in parallel tool calls).
73
+ - From ~turn 15 onward (of 20), STOP searching and write verdict.json
74
+ with what you have. The verdict file MUST exist on disk BEFORE your
75
+ final reply — a missing verdict file fails the whole verification
76
+ round as an infra error. If unsure, write your best verdict NOW.
77
+ """
78
+
79
+
80
+ @dataclasses.dataclass
81
+ class VerifierResult:
82
+ verdict: str # PASS | FAIL | ERROR
83
+ claims_checked: int = 0
84
+ unsupported: list[dict] = dataclasses.field(default_factory=list)
85
+ checked: list[dict] = dataclasses.field(default_factory=list)
86
+ notes: str = ""
87
+ hermes_session_id: Optional[str] = None
88
+ duration_s: float = 0.0
89
+ error: Optional[str] = None
90
+
91
+ def ok(self) -> bool:
92
+ return self.verdict == "PASS"
93
+
94
+
95
+ def _run_verifier_hermes(
96
+ cfg: Config, prompt_path: str, workdir: str
97
+ ) -> tuple[int, str, str]:
98
+ cmd = [
99
+ "hermes", "chat",
100
+ "--query-file", prompt_path,
101
+ "--in", workdir,
102
+ "-Q",
103
+ "--max-turns", str(cfg.verifier_max_turns),
104
+ ]
105
+ proc = subprocess.run(
106
+ cmd,
107
+ capture_output=True,
108
+ text=True,
109
+ timeout=cfg.verifier_timeout_s,
110
+ cwd=workdir,
111
+ )
112
+ return proc.returncode, proc.stdout or "", proc.stderr or ""
113
+
114
+
115
+ def parse_verdict(raw: Optional[str]) -> Optional[dict[str, Any]]:
116
+ if not raw:
117
+ return None
118
+ raw = raw.strip()
119
+ if raw.startswith("```"):
120
+ raw = raw.split("\n", 1)[1] if "\n" in raw else ""
121
+ if raw.rstrip().endswith("```"):
122
+ raw = raw.rstrip()[:-3]
123
+ data = json_loads_lenient(raw)
124
+ if not isinstance(data, dict) or "verdict" not in data:
125
+ return None
126
+ return data
127
+
128
+
129
+ def _rescue_verdict_from_stdout(raw: Optional[str]) -> Optional[dict[str, Any]]:
130
+ """A verifier that hit its turn cap mid-research never writes
131
+ verdict.json; its final chat message still contains the judgment.
132
+ VERDICT: PASS/FAIL line is authoritative; lift the richest JSON
133
+ verdict object the verifier emitted, if any."""
134
+ from traceforge.improver.review import (
135
+ _json_object_spans,
136
+ )
137
+
138
+ if not raw:
139
+ return None
140
+ marker = None
141
+ for m in ("VERDICT: PASS", "VERDICT: FAIL"):
142
+ if m in raw:
143
+ marker = m.split(":")[1].strip()
144
+ break
145
+ if marker is None:
146
+ return None
147
+ data: dict[str, Any] = {"verdict": marker}
148
+ for candidate in _json_object_spans(raw):
149
+ try:
150
+ obj = json_loads_lenient(candidate)
151
+ except Exception:
152
+ continue
153
+ if isinstance(obj, dict) and "verdict" in obj:
154
+ data = obj
155
+ data["verdict"] = marker
156
+ break
157
+ return data
158
+
159
+
160
+ def run_verifier(
161
+ cfg: Config, improved_path: str, workdir: str
162
+ ) -> VerifierResult:
163
+ """Fact-check the improved trace's external claims via web search."""
164
+ started = time.time()
165
+ verdict_path = os.path.join(workdir, "verdict.json")
166
+ if os.path.exists(verdict_path):
167
+ os.unlink(verdict_path)
168
+ prompt = VERIFIER_HEADER.format(
169
+ improved_path=improved_path, verdict_path=verdict_path
170
+ )
171
+ os.makedirs(cfg.prompts_dir, exist_ok=True)
172
+ prompt_path = os.path.join(
173
+ cfg.prompts_dir,
174
+ f"verify_{os.path.basename(os.path.dirname(improved_path))}.txt",
175
+ )
176
+ with open(prompt_path, "w", encoding="utf-8") as f:
177
+ f.write(prompt)
178
+ try:
179
+ code, stdout, stderr = _run_verifier_hermes(cfg, prompt_path, workdir)
180
+ except subprocess.TimeoutExpired:
181
+ return VerifierResult(
182
+ verdict="ERROR", error=f"verifier timeout {cfg.verifier_timeout_s}s",
183
+ duration_s=time.time() - started,
184
+ )
185
+ data = parse_verdict(
186
+ open(verdict_path, encoding="utf-8").read()
187
+ if os.path.exists(verdict_path) else None
188
+ )
189
+ if data is None:
190
+ rescue = _rescue_verdict_from_stdout(stdout)
191
+ if rescue is not None:
192
+ data = rescue
193
+ if data is None:
194
+ tail = stderr.strip()[-400:] if stderr else ""
195
+ return VerifierResult(
196
+ verdict="ERROR",
197
+ error=(
198
+ f"verifier produced no parseable verdict.json "
199
+ f"(exit {code}{'; ' + tail if tail else ''})"
200
+ ),
201
+ duration_s=time.time() - started,
202
+ )
203
+ return VerifierResult(
204
+ verdict=str(data.get("verdict", "FAIL")).upper(),
205
+ claims_checked=int(data.get("claims_checked", 0) or 0),
206
+ unsupported=list(data.get("unsupported", []) or []),
207
+ checked=list(data.get("checked", []) or []),
208
+ notes=str(data.get("notes", "")),
209
+ duration_s=time.time() - started,
210
+ )
traceforge/live.py ADDED
@@ -0,0 +1,508 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Live terminal telemetry for long-running batches.
2
+
3
+ Every stage traceforge spawns (improver, digester, map, writer, verifier,
4
+ reviewer) is a Hermes one-shot running with cwd = the per-trace workdir,
5
+ and Hermes updates live counters on the session row in state.db as the
6
+ model streams. LiveStats polls those rows on a background thread and
7
+ renders:
8
+
9
+ TTY detail rows under the batch bar covering EVERY in-flight
10
+ trace (stage, age, tokens in/out, instantaneous tok/s,
11
+ child-session count), plus a batch postfix (total out ·
12
+ tok/s). Layout adapts to --workers and terminal size: full
13
+ detail rows while they fit; then packed chips
14
+ (`<sid-tail> <stage> <age>`, several per row); a stage
15
+ histogram only when even chips overflow the screen. An
16
+ in-flight trace is never silently hidden.
17
+ pipes a plain heartbeat line every live_heartbeat_s with the same
18
+ aggregate — every in-flight trace listed up to 10, then
19
+ per-stage counts — so backgrounded runs show real progress.
20
+ end token totals on IMPROVED/FAILED lines and a batch summary line.
21
+
22
+ Read-only against state.db (mode=ro); poll failures are swallowed and
23
+ keep the last snapshot. Contract: poll() runs on one thread (the built-in
24
+ loop); render helpers are safe from any thread.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import shutil
30
+ import sqlite3
31
+ import threading
32
+ import time
33
+ from typing import Optional
34
+
35
+ from traceforge.progress import _fmt_dur
36
+
37
+
38
+ def fmt_tok(n) -> str:
39
+ """Compact token count: 1234 -> '1.2k', 1234567 -> '1.23M'."""
40
+ n = int(n or 0)
41
+ if n >= 1_000_000:
42
+ return f"{n / 1_000_000:.2f}M"
43
+ if n >= 10_000:
44
+ return f"{n / 1000:.0f}k"
45
+ if n >= 1000:
46
+ return f"{n / 1000:.1f}k"
47
+ return str(n)
48
+
49
+
50
+ def _stage_counts(acts) -> dict:
51
+ """{stage: n} over [(sid, info), ...] pairs."""
52
+ counts: dict = {}
53
+ for _sid, info in acts:
54
+ st = info.get("stage", "?")
55
+ counts[st] = counts.get(st, 0) + 1
56
+ return counts
57
+
58
+
59
+ def sid_short(sid: str) -> str:
60
+ """Distinguishing short form of a session id for detail rows.
61
+
62
+ Repo sids are date-prefixed (`20260926_160647_ceb268`), so the
63
+ first 12 chars are the SAME for every trace started the same
64
+ minute — head truncation makes rows indistinguishable. Show the
65
+ tail instead: last `_` segment (hermes) or last 8 chars (uuid).
66
+ """
67
+ seg = sid.rsplit("_", 1)[-1] if len(sid) > 10 else sid
68
+ if len(seg) > 8:
69
+ seg = seg[-8:]
70
+ return seg if seg == sid else f"…{seg}"
71
+
72
+
73
+ class LiveRegistry:
74
+ """Thread-safe map of in-flight traces -> current stage + timestamps."""
75
+
76
+ def __init__(self):
77
+ self._lock = threading.Lock()
78
+ self._traces: dict = {}
79
+
80
+ def begin(self, sid: str, stage: str = "starting") -> None:
81
+ with self._lock:
82
+ now = time.monotonic()
83
+ self._traces[sid] = {
84
+ "stage": stage, "t0": now, "stage_t": now,
85
+ }
86
+
87
+ def set_stage(self, sid: str, stage: str) -> None:
88
+ with self._lock:
89
+ d = self._traces.get(sid)
90
+ if d is not None:
91
+ d["stage"] = stage
92
+ d["stage_t"] = time.monotonic()
93
+
94
+ def end(self, sid: str) -> None:
95
+ with self._lock:
96
+ self._traces.pop(sid, None)
97
+
98
+ def snapshot(self):
99
+ """[(sid, info), ...] oldest first — stable display order."""
100
+ with self._lock:
101
+ return [
102
+ (sid, dict(d))
103
+ for sid, d in sorted(
104
+ self._traces.items(), key=lambda kv: kv[1]["t0"]
105
+ )
106
+ ]
107
+
108
+
109
+ class LiveStats:
110
+ """Polls state.db child-session counters; renders live progress."""
111
+
112
+ def __init__(self, cfg, registry: LiveRegistry, prog=None,
113
+ *, max_sublines: int = 0):
114
+ self.registry = registry
115
+ self.prog = prog
116
+ # 0 = adaptive: show every in-flight trace, switching layout
117
+ # (full rows -> packed chips -> stage histogram) by screen fit.
118
+ # >0 (tests, explicit caps): hard cap on full detail rows.
119
+ self.max_sublines = max(0, int(max_sublines))
120
+ self._forced_cap = self.max_sublines > 0
121
+ self.state_db = cfg.state_db
122
+ self.poll_s = max(0.5, float(getattr(cfg, "live_poll_s", 2.0)))
123
+ self.heartbeat_s = max(
124
+ 5.0, float(getattr(cfg, "live_heartbeat_s", 30.0))
125
+ )
126
+ # ours = one-shot sessions started in this batch window AND inside
127
+ # this batch's workdir root (cwd leaf = trace sid)
128
+ self._like = os.path.join(cfg.improver_workdir, "")
129
+ self.enabled = os.path.exists(self.state_db)
130
+ self._t0_mono = time.monotonic()
131
+ self._since = time.time() - 1.0
132
+ self._per: dict = {}
133
+ self._prev_out: dict = {}
134
+ self._sess_models: dict = {} # child session id -> model name
135
+ self._sessions = 0
136
+ self._tot_in = 0
137
+ self._tot_out = 0
138
+ self._rate = 0.0
139
+ self._last_poll = None # monotonic ts of previous poll
140
+ self._lock = threading.Lock()
141
+ self._stop = threading.Event()
142
+ self._thread: Optional[threading.Thread] = None
143
+
144
+ # ----------------------------------------------------------- lifecycle
145
+ @classmethod
146
+ def setup(cls, cfg, prog=None, registry=None, **kw):
147
+ """Factory honoring the live_poll_s=0 kill-switch."""
148
+ if float(getattr(cfg, "live_poll_s", 0) or 0) <= 0:
149
+ return None
150
+ reg = registry or LiveRegistry()
151
+ return cls(cfg, reg, prog, **kw)
152
+
153
+ def start(self) -> None:
154
+ if not self.enabled or self._thread is not None:
155
+ return
156
+ self._thread = threading.Thread(
157
+ target=self._loop, name="traceforge-live", daemon=True
158
+ )
159
+ self._thread.start()
160
+
161
+ def stop(self) -> None:
162
+ self._stop.set()
163
+ t = self._thread
164
+ if (
165
+ t is not None and t.is_alive()
166
+ and threading.current_thread() is not t
167
+ ):
168
+ t.join(timeout=self.poll_s + 1.0)
169
+ self._thread = None
170
+
171
+ # ---------------------------------------------------------------- poll
172
+ def poll(self) -> None:
173
+ """One state.db read; recomputes aggregates. Single-thread contract."""
174
+ try:
175
+ conn = sqlite3.connect(
176
+ f"file:{self.state_db}?mode=ro", uri=True, timeout=2.0
177
+ )
178
+ try:
179
+ rows = conn.execute(
180
+ "SELECT id, cwd, input_tokens, output_tokens,"
181
+ " message_count, api_call_count, ended_at IS NULL, model"
182
+ " FROM sessions WHERE source='oneshot'"
183
+ " AND started_at >= ?",
184
+ (self._since,),
185
+ ).fetchall()
186
+ finally:
187
+ conn.close()
188
+ except sqlite3.Error:
189
+ return # transient lock/IO hiccup: keep last snapshot
190
+ rows = [r for r in rows if (r[1] or "").startswith(self._like)]
191
+ now = time.monotonic()
192
+ with self._lock:
193
+ prev_out = dict(self._prev_out)
194
+ last_poll = self._last_poll
195
+ dt = None
196
+ if last_poll is not None:
197
+ dt = max(now - last_poll, 1e-6)
198
+ per: dict = {}
199
+ delta_out = 0
200
+ models: dict = {}
201
+ for (sess, cwd, tin, tout, msgs, api, running, model) in rows:
202
+ key = (cwd or "").rstrip("/").rsplit("/", 1)[-1]
203
+ d = per.setdefault(key, {
204
+ "in": 0, "out": 0, "msgs": 0, "api": 0,
205
+ "runs": 0, "running": 0, "rate": 0.0, "models": set(),
206
+ })
207
+ tin, tout = tin or 0, tout or 0
208
+ d["in"] += tin
209
+ d["out"] += tout
210
+ d["msgs"] += msgs or 0
211
+ d["api"] += api or 0
212
+ d["runs"] += 1
213
+ d["running"] += 1 if running else 0
214
+ if model:
215
+ d["models"].add(str(model))
216
+ models[sess] = str(model)
217
+ po = prev_out.get(sess)
218
+ if po is not None and tout > po:
219
+ grew = tout - po
220
+ delta_out += grew
221
+ d["rate"] += grew / dt if dt else 0.0
222
+ # splice path: merge direct-HTTP usage (no state.db rows exist
223
+ # for _post_chat calls; SPLICE_USAGE is the only telemetry source)
224
+ splice_delta = 0
225
+ sper: dict = {}
226
+ try:
227
+ from traceforge.splice import SPLICE_USAGE
228
+ sper, splice_delta = SPLICE_USAGE.drain(dt)
229
+ for sid, b in sper.items():
230
+ d = per.setdefault(sid, {
231
+ "in": 0, "out": 0, "msgs": 0, "api": 0,
232
+ "runs": 0, "running": 0, "rate": 0.0, "models": set(),
233
+ })
234
+ d["in"] += b["in"]
235
+ d["out"] += b["out"]
236
+ d["api"] += b["calls"]
237
+ d["runs"] += b["calls"]
238
+ d["rate"] += b["rate"]
239
+ for m in b["models"]:
240
+ d["models"].add(m)
241
+ models[f"splice:{sid}:{m}"] = m
242
+ except Exception:
243
+ pass # telemetry must never break the batch
244
+ for d in per.values():
245
+ d["rate"] = round(d["rate"], 2)
246
+ d["models"] = sorted(d["models"])
247
+ with self._lock:
248
+ self._per = per
249
+ self._prev_out = {r[0]: r[3] or 0 for r in rows}
250
+ self._sessions = len(rows) + len(
251
+ [s for s in sper if s != "-"]
252
+ )
253
+ self._sess_models.update(models)
254
+ self._tot_in = sum(d["in"] for d in per.values())
255
+ self._tot_out = sum(d["out"] for d in per.values())
256
+ if dt:
257
+ self._rate = round(
258
+ 0.5 * self._rate
259
+ + 0.5 * ((delta_out + splice_delta) / dt), 2
260
+ )
261
+ self._last_poll = now
262
+
263
+ def _loop(self) -> None:
264
+ next_hb = time.monotonic() + self.heartbeat_s
265
+ while not self._stop.is_set():
266
+ self.poll()
267
+ if self.prog is not None:
268
+ if self.prog.enabled:
269
+ pf = self._postfix()
270
+ if pf:
271
+ self.prog.set_postfix(pf)
272
+ # always refresh: an empty list clears stale sublines
273
+ self.prog.set_sublines(self.sublines())
274
+ elif time.monotonic() >= next_hb:
275
+ self.prog.write(self.heartbeat())
276
+ next_hb = time.monotonic() + self.heartbeat_s
277
+ self._stop.wait(self.poll_s)
278
+
279
+ # ------------------------------------------------------------ rendering
280
+ def _snapshot(self):
281
+ with self._lock:
282
+ return dict(self._per), self._tot_in, self._tot_out, self._rate
283
+
284
+ def per_trace(self, sid: str):
285
+ """Aggregate token usage for one trace (all its child sessions)."""
286
+ with self._lock:
287
+ d = self._per.get(sid)
288
+ return dict(d) if d else None
289
+
290
+ def sublines(self) -> list:
291
+ """Detail lines for the Progress bar — covering EVERY in-flight
292
+ trace, never a "+N more" cutoff.
293
+
294
+ Adaptive layout (no Rich TUI needed — the bar is the dashboard):
295
+ 1. full detail rows, one per trace, while they all fit on
296
+ screen — the default at any worker count with headroom;
297
+ 2. packed chips — several traces per row
298
+ (`├ <sid8> improving 1:23 <sid8> verify 1/2 0:45`) once
299
+ full rows would overflow the terminal height;
300
+ 3. stage histogram (`├ stages: improving 9 · verify 2/2 4`) only
301
+ when rows×width can't fit every chip — traces are never
302
+ silently hidden at any worker count or terminal size.
303
+ """
304
+ acts = self.registry.snapshot()
305
+ if not acts:
306
+ return []
307
+ per, _tin, _tout, _rate = self._snapshot()
308
+ now = time.monotonic()
309
+ rows_budget = self._detail_rows_budget()
310
+ if rows_budget >= len(acts):
311
+ return self._full_rows(acts, per, now)
312
+ return self._packed_rows(acts, rows_budget, now)
313
+
314
+ # ------------------------------------------------- adaptive subline help
315
+ def _detail_rows_budget(self) -> int:
316
+ """How many detail rows the screen has room for.
317
+
318
+ Forced caps (>0, tests/explicit limit) win; adaptive mode
319
+ reserves 4 lines around the frame (bar row, prompt, slack) and
320
+ keeps at least the historical 8 visible rows.
321
+ """
322
+ if self._forced_cap:
323
+ return self.max_sublines
324
+ lines = shutil.get_terminal_size((120, 24)).lines
325
+ return max(8, lines - 4)
326
+
327
+ def _full_rows(self, acts, per, now) -> list:
328
+ cols = shutil.get_terminal_size((120, 24)).columns
329
+ lines = []
330
+ last = len(acts) - 1
331
+ for i, (sid, info) in enumerate(acts):
332
+ connector = "└" if i == last else "├"
333
+ head = (
334
+ f"{connector} {sid_short(sid):<10}"
335
+ f" {info.get('stage', '?'):<12}"
336
+ f" {_fmt_dur(now - info.get('t0', now)):>6}"
337
+ )
338
+ d = per.get(sid)
339
+ if not d:
340
+ lines.append(head + " · telemetry pending")
341
+ continue
342
+ # richest first; trim fields until the line fits the terminal
343
+ m = d.get("models") or []
344
+ fields = [
345
+ f"{d['rate']:5.1f} tok/s",
346
+ f"{fmt_tok(d['out'])} out",
347
+ f"{fmt_tok(d['in'])} in",
348
+ "/".join(m) if m else "?",
349
+ f"{d['runs']} run" + ("s" if d["runs"] != 1 else ""),
350
+ ]
351
+ line = head
352
+ for f in fields:
353
+ cand = line + f" · {f}"
354
+ if len(cand) + 1 <= cols:
355
+ line = cand
356
+ lines.append(line)
357
+ return lines
358
+
359
+ @staticmethod
360
+ def _chip(sid: str, info: dict, now: float) -> str:
361
+ """One packed trace chip: `<sid8>… <stage≤12> <age>`."""
362
+ stage = info.get("stage", "?")
363
+ if len(stage) > 12:
364
+ stage = stage[:12]
365
+ return f"{sid_short(sid)} {stage} {_fmt_dur(now - info.get('t0', now))}"
366
+
367
+ def _packed_rows(self, acts, rows_budget: int, now) -> list:
368
+ """Chips packed several-per-row: ALL traces visible, compact.
369
+
370
+ Greedy packing by ACTUAL chip width (worst ~28 chars, typically
371
+ less) honors the terminal columns; the row count stays within
372
+ rows_budget. If width×rows together cannot show every trace,
373
+ degrade to the stage histogram — traces are never dropped.
374
+ """
375
+ cols = shutil.get_terminal_size((120, 24)).columns
376
+ chips = [self._chip(sid, info, now) for sid, info in acts]
377
+ rows: list = []
378
+ cur: list = []
379
+ cur_w = 2 # "├ " connector prefix
380
+ for chip in chips:
381
+ w = len(chip) + (2 if cur else 0) # gap before non-first chip
382
+ if cur and cur_w + w > cols:
383
+ rows.append(cur)
384
+ cur, cur_w = [], 2
385
+ if len(rows) >= rows_budget:
386
+ break
387
+ cur.append(chip)
388
+ cur_w += w
389
+ if cur and len(rows) < rows_budget:
390
+ rows.append(cur)
391
+ placed = sum(len(r) for r in rows)
392
+ if placed < len(chips):
393
+ # cannot fit every trace even packed: histogram, not hiding
394
+ return self._stage_histogram(acts, rows_budget)
395
+ return [
396
+ f"{'├' if i < len(rows) - 1 else '└'} " + " ".join(r)
397
+ for i, r in enumerate(rows)
398
+ ]
399
+
400
+ @staticmethod
401
+ def _stage_histogram(acts, rows_budget: int) -> list:
402
+ """Last resort (narrow/short terminals): per-stage counts.
403
+
404
+ Width-safe: stage groups wrap across rows within the terminal
405
+ columns; the oldest in-flight trace closes the frame when the
406
+ row budget allows it.
407
+ """
408
+ cols = shutil.get_terminal_size((120, 24)).columns
409
+ groups = [
410
+ f"{st} {n}"
411
+ for st, n in sorted(
412
+ _stage_counts(acts).items(), key=lambda kv: (-kv[1], kv[0])
413
+ )
414
+ ]
415
+ rows: list = []
416
+ cur: list = []
417
+ cur_w = len("├ stages: ")
418
+ for g in groups:
419
+ w = len(g) + (3 if cur else 0) # " · " separator
420
+ if cur and cur_w + w > cols:
421
+ rows.append(" · ".join(cur))
422
+ cur, cur_w = [], len("├ stages: ")
423
+ if len(rows) >= max(1, rows_budget - 1):
424
+ break
425
+ cur.append(g)
426
+ cur_w += w
427
+ if cur:
428
+ rows.append(" · ".join(cur))
429
+ if len(rows) > max(1, rows_budget - 1):
430
+ rows = rows[:max(1, rows_budget - 1)]
431
+ sid, info = acts[0] # snapshot() is oldest-first
432
+ now = time.monotonic()
433
+ oldest = (
434
+ f"oldest: {sid_short(sid)} {info.get('stage', '?')}"
435
+ f" {_fmt_dur(now - info.get('t0', now))}"
436
+ )
437
+ if rows_budget == 1:
438
+ # single-row budget: merge the oldest into the histogram row
439
+ head = "stages: " + (rows[0] if rows else "")
440
+ return [f"└ {head} · {oldest}"[:cols]]
441
+ lines = [f"stages: {r}" if i == 0 else f" {r}"
442
+ for i, r in enumerate(rows)]
443
+ lines.append(oldest)
444
+ return [f"{'└' if i == len(lines) - 1 else '├'} {ln}"
445
+ for i, ln in enumerate(lines[:rows_budget])]
446
+
447
+ def _postfix(self) -> str:
448
+ _per, tin, tout, rate = self._snapshot()
449
+ if tout or tin:
450
+ return f"{fmt_tok(tout)} out · {rate:.1f} tok/s"
451
+ return ""
452
+
453
+ def heartbeat(self) -> str:
454
+ _per, tin, tout, rate = self._snapshot()
455
+ wall = _fmt_dur(time.monotonic() - self._t0_mono)
456
+ now = time.monotonic()
457
+ acts = self.registry.snapshot()
458
+ # every in-flight trace listed for as long as the line stays
459
+ # readable (~300 chars); beyond that collapse to stage counts
460
+ shown = acts[:10]
461
+ inflight = ", ".join(
462
+ f"{sid_short(sid)} {info.get('stage', '?')}"
463
+ f" ({_fmt_dur(now - info.get('t0', now))})"
464
+ for sid, info in shown
465
+ )
466
+ if len(acts) > len(shown):
467
+ rest = acts[len(shown):]
468
+ counts = " · ".join(
469
+ f"{st} {n}"
470
+ for st, n in sorted(
471
+ _stage_counts(rest).items(), key=lambda kv: -kv[1]
472
+ )
473
+ )
474
+ inflight = f"{inflight} … +{len(acts) - len(shown)} ({counts})" if inflight else counts
475
+ inflight = inflight or "none"
476
+ # Judge cache hit rate: a repair round re-judges every window, so this
477
+ # is the share of judge calls that cost nothing. Silent until the
478
+ # first miss, so a run that never judges does not print "0/0".
479
+ try:
480
+ from traceforge.splice import judge_cache_stats
481
+ js = judge_cache_stats()
482
+ jtot = js["hit"] + js["miss"]
483
+ jtxt = (f" · judge cache {100 * js['hit'] / jtot:.0f}%"
484
+ f" ({js['hit']}/{jtot})") if jtot else ""
485
+ except Exception:
486
+ jtxt = ""
487
+ return (
488
+ f"-- [{wall}] {fmt_tok(tin)} in · {fmt_tok(tout)} out"
489
+ f" · {rate:.1f} tok/s · {self._models_str()} · in flight: {inflight}"
490
+ f"{jtxt}"
491
+ )
492
+
493
+ def _models_str(self) -> str:
494
+ """Distinct child models seen this batch, e.g. 'glmb300'."""
495
+ with self._lock:
496
+ ms = set(self._sess_models.values())
497
+ return "/".join(sorted(ms)) if ms else "?"
498
+
499
+ def summary(self) -> str:
500
+ with self._lock:
501
+ tin, tout, ns = self._tot_in, self._tot_out, self._sessions
502
+ wall = max(time.monotonic() - self._t0_mono, 1e-6)
503
+ avg = tout / wall if tout else 0.0
504
+ return (
505
+ f"-- live: {ns} child sessions · {fmt_tok(tin)} tok in"
506
+ f" · {fmt_tok(tout)} tok out · avg {avg:.1f} tok/s gen"
507
+ f" · model {self._models_str()} · wall {_fmt_dur(wall)}"
508
+ )
traceforge/progress.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal dependency-free progress bars (tqdm-style, stdlib only).
2
+
3
+ Design notes:
4
+ - Bar renders to stderr and only when stderr is a TTY; when output is
5
+ piped/redirected (CI, background shells, logs) the bar disables itself
6
+ and callers fall back to plain per-item lines on stdout.
7
+ - Thread-safe: a single lock guards rendering, so worker threads can
8
+ update counts / postfixes concurrently.
9
+ - write(msg) clears the current frame, prints msg on stderr, and
10
+ redraws — the tqdm.write pattern — so per-item result lines never
11
+ visually corrupt the bar.
12
+ - Multi-line frames: set_sublines(lines) installs detail lines drawn
13
+ under the bar (one per in-flight worker). Each render draws the whole
14
+ frame atomically and repaints in place with ANSI cursor-up codes.
15
+ Height changes (workers starting/finishing) repaint from the old
16
+ frame's top row, so no stale rows survive.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import shutil
21
+ import sys
22
+ import threading
23
+ import time
24
+ from typing import Optional, TextIO
25
+
26
+
27
+ def _fmt_dur(sec: float) -> str:
28
+ s = int(sec)
29
+ if s >= 3600:
30
+ return f"{s // 3600}:{s % 3600 // 60:02d}:{s % 60:02d}"
31
+ return f"{s // 60:}:{s % 60:02d}"
32
+
33
+
34
+ class Progress:
35
+ """Progress bar with optional detail sublines.
36
+
37
+ Frame layout (TTY only):
38
+
39
+ desc 42%|########----| 4/10 [1:12<24:51, 3.2s/it] postfix
40
+ ├ abc123def456 improving 1:23 45.2k in · 3.1k out · 12.4 tok/s
41
+ └ def789abc123 verify 1/2 0:45 12.0k in · 1.2k out · 4.2 tok/s
42
+
43
+ update()/set_postfix()/set_sublines() are thread-safe; render is
44
+ rate-limited by mininterval so fast loops don't flood the terminal.
45
+ """
46
+
47
+ PAD_TARGET = 150 # pad each line to at least this width (wipe leftovers)
48
+
49
+ def __init__(
50
+ self,
51
+ total: int,
52
+ desc: str = "",
53
+ *,
54
+ stream: Optional[TextIO] = None,
55
+ width: int = 24,
56
+ mininterval: float = 0.25,
57
+ enabled: Optional[bool] = None,
58
+ ):
59
+ self.total = max(int(total), 0)
60
+ self.n = 0
61
+ self.desc = desc
62
+ self.width = width
63
+ self.mininterval = mininterval
64
+ self.stream = stream or sys.stderr
65
+ if enabled is None:
66
+ enabled = bool(getattr(self.stream, "isatty", lambda: False)())
67
+ self.enabled = enabled and self.total > 0
68
+ self._lock = threading.Lock()
69
+ self._postfix = ""
70
+ self._sublines: list[str] = []
71
+ self._frame_rows = 0 # rows the on-screen frame currently spans
72
+ self._start = time.monotonic()
73
+ self._last_render = 0.0
74
+ self._closed = False
75
+ if self.enabled:
76
+ self._render(force=True)
77
+
78
+ # ------------------------------------------------------------ lifecycle
79
+ def set_postfix(self, s: str) -> None:
80
+ with self._lock:
81
+ self._postfix = s
82
+ self._maybe_render()
83
+
84
+ def set_sublines(self, lines) -> None:
85
+ """Install/refresh the detail lines drawn under the bar."""
86
+ with self._lock:
87
+ if not self.enabled:
88
+ return
89
+ new = list(lines)
90
+ if new == self._sublines:
91
+ return # byte-identical: skip render
92
+ self._sublines = new
93
+ self._maybe_render(force=True)
94
+
95
+ def update(self, n: int = 1) -> None:
96
+ with self._lock:
97
+ self.n = min(self.n + n, self.total)
98
+ self._maybe_render()
99
+
100
+ def close(self) -> None:
101
+ with self._lock:
102
+ if self._closed:
103
+ return
104
+ self._closed = True
105
+ if self.enabled:
106
+ self._render(force=True)
107
+ self.stream.write("\n")
108
+ self.stream.flush()
109
+
110
+ # context-manager sugar
111
+ def __enter__(self) -> "Progress":
112
+ return self
113
+
114
+ def __exit__(self, *exc) -> None:
115
+ self.close()
116
+
117
+ # ------------------------------------------------------------ rendering
118
+ def _maybe_render(self, force: bool = False) -> None:
119
+ if not self.enabled or self._closed:
120
+ return
121
+ now = time.monotonic()
122
+ if force or now - self._last_render >= self.mininterval:
123
+ self._render(force=True)
124
+
125
+ def _render(self, force: bool = False) -> None:
126
+ if not self.enabled:
127
+ return
128
+ now = time.monotonic()
129
+ if not force and now - self._last_render < self.mininterval:
130
+ return
131
+ self._last_render = now
132
+ elapsed = max(now - self._start, 1e-6)
133
+ frac = self.n / self.total if self.total else 0.0
134
+ filled = int(self.width * frac)
135
+ bar = "#" * filled + "-" * (self.width - filled)
136
+ # rate: per-item time; 0 completed -> unknown
137
+ if self.n > 0:
138
+ per = elapsed / self.n
139
+ eta = _fmt_dur(per * (self.total - self.n))
140
+ rate = f"{per:.1f}s/it"
141
+ else:
142
+ eta, rate = "-:--", "?it/s"
143
+ line = (
144
+ f"{self.desc} {int(frac * 100):3d}%|{bar}|"
145
+ f" {self.n}/{self.total} [{_fmt_dur(elapsed)}<{eta}, {rate}]"
146
+ )
147
+ if self._postfix:
148
+ line += f" {self._postfix}"
149
+ frame = [line] + list(self._sublines)
150
+ old_h = self._frame_rows
151
+ if old_h > 0 and len(frame) < old_h:
152
+ # frame shrank: overwrite stale rows below with blanks
153
+ frame += [""] * (old_h - len(frame))
154
+ # truncate each line to the terminal width — a longer line would
155
+ # wrap to a second physical row and break the up-cursor math
156
+ cols = shutil.get_terminal_size((120, 24)).columns
157
+ padded = []
158
+ for ln in frame:
159
+ ln = ln[:cols]
160
+ padded.append(ln + " " * (cols - len(ln)))
161
+ rows = len(padded)
162
+ body = "\r" + "\n".join(padded)
163
+
164
+ # Cursor sits ON the frame's last row (lines joined with \n, no
165
+ # trailing newline). Move up to the frame's top row before the
166
+ # rewrite; F also returns the cursor to column 0. old_h == 1
167
+ # needs no prefix: the cursor is already on the only frame row
168
+ # and the leading \r returns it to column 0.
169
+ if old_h >= 2:
170
+ body = f"\x1b[{old_h - 1}F" + body
171
+
172
+ self.stream.write(body)
173
+ self.stream.flush()
174
+ self._frame_rows = rows
175
+
176
+ def write(self, msg: str) -> None:
177
+ """Print a permanent line above the frame (thread-safe)."""
178
+ with self._lock:
179
+ if not self.enabled:
180
+ print(msg) # non-TTY: plain stdout log line
181
+ return
182
+ if self._frame_rows > 0:
183
+ # park on the frame's TOP row: cursor sits ON the last
184
+ # row of the frame, so up = rows-1 lands on the top row
185
+ up = max(self._frame_rows - 1, 1)
186
+ self.stream.write(f"\x1b[{up}F")
187
+ self.stream.write("\x1b[0J") # clear cursor..end of screen
188
+ self._frame_rows = 0 # frame gone; next render starts here
189
+ else:
190
+ self.stream.write("\r" + " " * 130 + "\r")
191
+ self.stream.write(msg + "\n")
192
+ self.stream.flush()
193
+ if not self._closed:
194
+ self._render(force=True)
traceforge/readers/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from traceforge.readers.claude import ClaudeTraceReader
2
+ from traceforge.readers.codex import CodexTraceReader
3
+ from traceforge.readers.hermes import HermesTraceReader
4
+ from traceforge.readers.nemotron import NemotronTraceReader
5
+
6
+ __all__ = [
7
+ "HermesTraceReader",
8
+ "ClaudeTraceReader",
9
+ "CodexTraceReader",
10
+ "NemotronTraceReader",
11
+ ]
traceforge/readers/_common.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared reader helpers: self-reference guard + silent-skip accounting.
2
+
3
+ Both concerns are cross-cutting (every reader has a store it must not
4
+ read its own output back from, and every reader has code paths that
5
+ skip a file or a row without telling anyone), so they live here rather
6
+ than being re-implemented per reader.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections import Counter
12
+ from typing import Optional
13
+
14
+
15
+ def _norm(path: Optional[str]) -> str:
16
+ """Forward-slash, case-folded, drive-less form of a path for comparison.
17
+
18
+ Seed archives come from other machines: a macOS rollout carries
19
+ ``/Users/x/dev/traceforge/data/work/<sid>`` while the live guard is
20
+ ``C:\\Users\\y\\...\\data\\work``. Comparing raw strings misses that,
21
+ which is exactly the self-reference leak the guard exists to stop.
22
+ """
23
+ if not path:
24
+ return ""
25
+ p = str(path).replace("\\", "/")
26
+ if len(p) > 1 and p[1] == ":":
27
+ p = p[2:] # strip the Windows drive letter
28
+ return p.rstrip("/").lower()
29
+
30
+
31
+ def in_workdir(cwd: Optional[str], workdir: Optional[str]) -> bool:
32
+ """True when ``cwd`` sits inside ``workdir`` (any platform's spelling).
33
+
34
+ Two matches count:
35
+ 1. the normalized cwd starts with the normalized workdir;
36
+ 2. the cwd ends with the workdir's last two components
37
+ (``data/work/<sid>``) — the portable form, which catches a
38
+ foreign-platform archive whose absolute prefix differs.
39
+ """
40
+ c, w = _norm(cwd), _norm(workdir)
41
+ if not c or not w:
42
+ return False
43
+ if c == w or c.startswith(w + "/"):
44
+ return True
45
+ tail = "/".join(w.split("/")[-2:])
46
+ return bool(tail) and (c == tail or c.startswith(tail + "/") or f"/{tail}/" in c)
47
+
48
+
49
+ def new_skips() -> Counter:
50
+ """Counter for rows/files a reader dropped without a candidate row."""
51
+ return Counter()
52
+
53
+
54
+ def bump(skips: Optional[Counter], reason: str, n: int = 1) -> None:
55
+ """Increment a skip counter when one is attached (never fatal)."""
56
+ if skips is not None:
57
+ skips[reason] += n
58
+
59
+
60
+ def oversize_row(row: dict, max_messages: int) -> dict:
61
+ """Flag a candidate that exceeds the (soft) message cap.
62
+
63
+ ``max_messages`` is a SOFT cap: a session above it is still a
64
+ candidate because the pipeline bounds what it actually processes by
65
+ rendered size (``split_long`` / ``split_max_chars``), not by message
66
+ count. The flag lets callers report the long-session share instead
67
+ of silently discarding it.
68
+
69
+ Returns a COPY: the codex reader keeps the same dict in its on-disk
70
+ summary cache, and a persisted ``oversize`` flag would survive a
71
+ later ``max_messages`` change.
72
+ """
73
+ out = dict(row)
74
+ if max_messages and out.get("message_count", 0) > max_messages:
75
+ out["oversize"] = True
76
+ return out
77
+
78
+
79
+ def workdir_of(cfg) -> str:
80
+ """Improver workdir for a Config (empty when unset)."""
81
+ return getattr(cfg, "improver_workdir", "") or ""
82
+
83
+
84
+ # --------------------------------------------------------------- dispatch briefs
85
+ # An orchestrator that dispatches a subagent pastes its own brief into the
86
+ # child's first user row: 6-7KB of Orca plumbing (session/task ids, CLI docs,
87
+ # worker_done rules) followed by a ``=== TASK ===`` block holding the real
88
+ # instruction. Promoted as-is, the trainee learns to emit harness plumbing as
89
+ # the user turn, so ``validate`` rejects the trace outright. Measured
90
+ # 2026-10-24 on the raw pool: 130 claude-code traces open with this brief, 0
91
+ # promoted, 20 gate failures. The TASK block IS the user's own words, so keep
92
+ # exactly that and drop the plumbing.
93
+ _DISPATCH_BRIEF_RE = re.compile(
94
+ r"You are working inside Orca, a multi-agent IDE"
95
+ r"|You are a dispatched worker"
96
+ r"|Your coordinator'?s Orca session ID is"
97
+ r"|=== CLI COMMANDS ===",
98
+ re.IGNORECASE,
99
+ )
100
+ _TASK_BLOCK = "=== TASK ==="
101
+ # The brief arrives wrapped in the parent's paste envelope; the closing tag
102
+ # trails the TASK block and is not part of the task.
103
+ _PASTE_CLOSE_RE = re.compile(r"\s*</pasted_content[^>]*>\s*$", re.IGNORECASE)
104
+ # A split segment's task row is the original task with this marker prepended
105
+ # (splitter.save_segments). Unwrapping a brief must not eat it: the marker is
106
+ # what tells the trainee the window is a continuation, not a fresh request.
107
+ _SEGMENT_PREFIX_RE = re.compile(r"^(\[trace segment \d+ of \d+[^\]]*\]\s*)")
108
+
109
+ # Orchestration notifications: the harness injecting "You have N orchestration
110
+ # messages. Run `orca orchestration check`." into the conversation. Pure
111
+ # plumbing, never user content — and because ``validate`` matches the bare
112
+ # phrase ``orca orchestration``, ONE such row kills an otherwise clean trace
113
+ # (measured 2026-10-24: 4 of 20 dispatch-brief gate failures had a clean task
114
+ # row and a 65-char notice at row 168).
115
+ _ORCH_NOTICE_RE = re.compile(
116
+ r"^\s*You have \d+ orchestration messages?\."
117
+ r"\s*Run `orca orchestration check",
118
+ re.IGNORECASE,
119
+ )
120
+
121
+
122
+ def is_orchestration_notice(text: Optional[str]) -> bool:
123
+ """True for the harness's 'You have N orchestration messages' row."""
124
+ return bool(_ORCH_NOTICE_RE.match(text or ""))
125
+
126
+
127
+ def unwrap_dispatch_brief(text: str) -> str:
128
+ """The real task inside an orchestrator dispatch brief.
129
+
130
+ Returns ``text`` unchanged when it is not a brief, the ``=== TASK ===``
131
+ block when it is, and ``""`` when the brief carries no task block (pure
132
+ harness plumbing — the row must be dropped, not kept).
133
+ """
134
+ if not _DISPATCH_BRIEF_RE.search(text):
135
+ return text
136
+ if _TASK_BLOCK in text:
137
+ task = text.split(_TASK_BLOCK, 1)[1]
138
+ task = _PASTE_CLOSE_RE.sub("", task).strip()
139
+ seg = _SEGMENT_PREFIX_RE.match(text)
140
+ return (seg.group(1) + task) if seg else task
141
+ return ""
142
+
143
+
144
+ def reframe_task_rows(messages: list, skips: Optional[Counter] = None) -> list:
145
+ """Drop harness plumbing user rows; unwrap dispatch-brief user rows.
146
+
147
+ Two loss points, one pass (both are the same defect: harness plumbing
148
+ sitting where the user's own words belong):
149
+
150
+ 1. ``You have N orchestration messages`` notification rows — dropped
151
+ everywhere they appear, counted ``orchestration_notice_row``.
152
+ 2. A dispatch brief in a user row — replaced by its ``=== TASK ===``
153
+ block (``dispatch_brief_reframed``), or dropped when the brief
154
+ carries no task (``dispatch_brief_no_task``). Every user row is
155
+ checked, not just the first: a codex child can receive a second
156
+ dispatch mid-thread, and the validate gate rejects the trace for
157
+ ANY row that still carries the boilerplate.
158
+
159
+ Returns a new list; the caller decides what an empty result means (a
160
+ taskless trace, which the reader's own taskless path then counts).
161
+ """
162
+ out = []
163
+ n_notice = 0
164
+ for m in messages:
165
+ if m.role == "user" and is_orchestration_notice(m.content):
166
+ n_notice += 1
167
+ continue
168
+ out.append(m)
169
+ bump(skips, "orchestration_notice_row", n_notice)
170
+
171
+ kept = []
172
+ for m in out:
173
+ if m.role == "user" and _DISPATCH_BRIEF_RE.search(m.content or ""):
174
+ task = unwrap_dispatch_brief(m.content)
175
+ if task:
176
+ m.content = task
177
+ bump(skips, "dispatch_brief_reframed")
178
+ else:
179
+ bump(skips, "dispatch_brief_no_task")
180
+ if not task:
181
+ continue
182
+ kept.append(m)
183
+ return kept
184
+
traceforge/readers/claude.py ADDED
@@ -0,0 +1,531 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Claude Code trace reader — session JSONLs under ~/.claude/projects.
2
+
3
+ Verified schema (Claude Code 2.x, probed 2026-09-26). Layout:
4
+ ~/.claude/projects/-<slugified-cwd>/<session-uuid>.jsonl
5
+ one JSON object per line, relevant row types:
6
+ user message:{role:"user", content: str | [tool_result blocks]}
7
+ assistant message:{role:"assistant", content:[text|thinking|tool_use],
8
+ model, usage}
9
+ custom-title / ai-title title sources (customTitle / aiTitle)
10
+ queue-operation, attachment, system, last-prompt, bridge-session, ... → skip
11
+
12
+ Probed skip rules (UI/engine artifacts, never agent content):
13
+ - isSidechain rows INSIDE top-level session files: subagent traffic
14
+ relayed into the parent transcript (also: only top-level
15
+ <project>/*.jsonl files are scanned for parent sessions; the
16
+ <uuid>/ subdirs hold the subagents' OWN transcripts and are read
17
+ by the sidechain index — see include_sidechains)
18
+ - isMeta string rows starting with '<local-command-caveat>' or 'Another
19
+ Claude session sent a message' (subagent hand-back wrapper); other
20
+ isMeta rows carry real goal-continuation tasks and are KEPT
21
+ - user string rows starting with '<command-'/'<local-command-'/'<bash-'/
22
+ '<system-reminder>' — EXCEPT '/goal' command rows, whose <command-args>
23
+ carry the real task text, which is extracted as a user message
24
+ - the session-continuation row ('This session is being continued ...') is
25
+ KEPT as a user message (handoff context, cf. Hermes compaction rows);
26
+ Trace.first_task_message() skips it when picking the task
27
+
28
+ Tool results live as tool_result blocks inside USER rows (results arrive in
29
+ the row after the assistant turn, possibly several blocks batched for
30
+ parallel calls). Tool names are resolved via a tool_use_id → name map built
31
+ from the assistant tool_use blocks in the same file.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import os
37
+ import re
38
+ from datetime import datetime
39
+ from typing import Any, Optional
40
+
41
+ from traceforge.readers._common import (
42
+ bump, new_skips, oversize_row, reframe_task_rows,
43
+ )
44
+ from traceforge.trace import Message, ToolCall, Trace
45
+
46
+ _ARTIFACT_PREFIXES = (
47
+ "<command-",
48
+ "<local-command-",
49
+ "<bash-",
50
+ "<system-reminder>",
51
+ "<task-",
52
+ )
53
+ _META_SKIP_PREFIXES = (
54
+ "<local-command-caveat>",
55
+ "Another Claude session sent a message",
56
+ )
57
+ CONTINUATION_PREFIX = "This session is being continued"
58
+ _GOAL_ARGS_RE = re.compile(r"<command-args>(.*?)</command-args>", re.DOTALL)
59
+
60
+
61
+ def _iso_to_epoch(ts: Optional[str]) -> Optional[float]:
62
+ if not ts:
63
+ return None
64
+ try:
65
+ return datetime.fromisoformat(str(ts).replace("Z", "+00:00")).timestamp()
66
+ except ValueError:
67
+ return None
68
+
69
+
70
+ def _block_text(blocks: Any) -> str:
71
+ """Flatten a content-blocks list to text (tool_result content nests)."""
72
+ if isinstance(blocks, str):
73
+ return blocks
74
+ parts: list[str] = []
75
+ if not isinstance(blocks, list):
76
+ return ""
77
+ for b in blocks:
78
+ if isinstance(b, str):
79
+ parts.append(b)
80
+ elif isinstance(b, dict):
81
+ t = b.get("type")
82
+ if t == "text" and b.get("text"):
83
+ parts.append(str(b["text"]))
84
+ elif t == "image":
85
+ parts.append("[image]")
86
+ elif t == "tool_result" and b.get("content") is not None:
87
+ parts.append(_block_text(b["content"]))
88
+ return "\n\n".join(p for p in parts if p)
89
+
90
+
91
+ class ClaudeTraceReader:
92
+ """Reads real traces from the Claude Code session store (read-only).
93
+
94
+ include_sidechains=True (default) also indexes the subagents' OWN
95
+ transcripts (<session-uuid>/subagents/agent-*.jsonl) as separate
96
+ candidate traces. Verified schema (probed 2026-10-12): the sidechain
97
+ files use the SAME user/assistant/tool_result row shapes as parent
98
+ session files, with isSidechain=true on every row. Sidechain
99
+ threads open with the dispatched lane-brief ("You are lane A of a
100
+ 5-lane agent team…") — that row IS the task; the thread becomes a
101
+ self-contained trainable (task → work → result) trace, the same
102
+ doctrine as codex taskless-child recovery. MASS (arXiv 2610.12176):
103
+ orchestrator+subagent conversations are the densest RSI training
104
+ signal — skipping them discards the highest-value data in the store.
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ projects_dir: Optional[list[str] | str] = None,
110
+ *,
111
+ include_sidechains: bool = True,
112
+ ):
113
+ # Multi-store support: a list of projects dirs is indexed
114
+ # together (live store + seed archives). Same session uuid in
115
+ # two stores -> the LARGER file wins (fresher snapshot of the
116
+ # same session), matching the single-store collision rule.
117
+ if projects_dir is None:
118
+ self.projects_dirs = [
119
+ os.path.expanduser(os.path.join("~", ".claude", "projects"))
120
+ ]
121
+ elif isinstance(projects_dir, (list, tuple)):
122
+ self.projects_dirs = [
123
+ os.path.expanduser(d) for d in projects_dir
124
+ ]
125
+ else:
126
+ self.projects_dirs = [os.path.expanduser(projects_dir)]
127
+ self.include_sidechains = include_sidechains
128
+ # files/rows dropped without a candidate row: reported by stats()
129
+ self.skips = new_skips()
130
+ # memoized _index() — see the docstring there (double-counted skips)
131
+ self._idx_cache: Optional[dict[str, str]] = None
132
+
133
+ @property
134
+ def projects_dir(self) -> str:
135
+ """First store (backwards compat for callers/tests)."""
136
+ return self.projects_dirs[0]
137
+
138
+ # ---------------------------------------------------------------- index
139
+
140
+ def _index(self) -> dict[str, str]:
141
+ """session uuid -> newest (largest) jsonl path across stores.
142
+
143
+ Memoized (2026-10-24): the index walk is what bumps the file-level
144
+ skip counters (``sidechain_sidecar``, ``non_transcript_file``), so
145
+ re-walking it — ``stats()`` used to call ``_index()`` a second time
146
+ after ``list_sessions`` — reported every file skip TWICE (measured:
147
+ 2,684 sidecars where the store holds 1,342). A skip census that
148
+ doubles itself is worse than no census.
149
+ """
150
+ if self._idx_cache is not None:
151
+ return self._idx_cache
152
+ idx: dict[str, tuple[int, str]] = {}
153
+ found = False
154
+ for projects_dir in self.projects_dirs:
155
+ if not os.path.isdir(projects_dir):
156
+ continue # missing store (seed archive) is not an error
157
+ found = True
158
+ self._index_store(projects_dir, idx)
159
+ if not found:
160
+ raise FileNotFoundError(
161
+ f"Claude projects dir not found: {self.projects_dirs}"
162
+ )
163
+ self._idx_cache = {sid: path for sid, (_, path) in idx.items()}
164
+ return self._idx_cache
165
+
166
+ def _index_store(self, projects_dir: str, idx: dict) -> None:
167
+ local_sids: list[str] = [] # sids discovered in THIS store
168
+ for proj in sorted(os.listdir(projects_dir)):
169
+ proj_dir = os.path.join(projects_dir, proj)
170
+ if not os.path.isdir(proj_dir):
171
+ continue # files at top level are never traces
172
+ try:
173
+ entries = os.listdir(proj_dir)
174
+ except OSError:
175
+ continue
176
+ for fn in entries:
177
+ if not fn.endswith(".jsonl"):
178
+ # A non-transcript FILE (a .json sidecar, a stray .txt)
179
+ # is counted, not just .json — the counter used to miss
180
+ # every other extension. Directories are the <uuid>/
181
+ # sidechain containers, not skips.
182
+ if os.path.isfile(os.path.join(proj_dir, fn)):
183
+ bump(self.skips, "non_transcript_file")
184
+ continue # <uuid>/ subdirs: parent's sidechains live inside
185
+ sid = fn[:-6]
186
+ path = os.path.join(proj_dir, fn)
187
+ try:
188
+ size = os.path.getsize(path)
189
+ except OSError:
190
+ bump(self.skips, "unreadable_file")
191
+ continue
192
+ prev = idx.get(sid)
193
+ if prev is None or size > prev[0]:
194
+ idx[sid] = (size, path)
195
+ local_sids.append(sid)
196
+ if self.include_sidechains:
197
+ # Only THIS store's sids (2026-10-24). Iterating the global idx
198
+ # re-scanned every earlier store's subagents dir on each store
199
+ # pass, so a two-store setup counted the live store's sidecars
200
+ # twice (measured: 1,342 reported where the stores hold 1,169).
201
+ for sid in local_sids:
202
+ path = idx[sid][1]
203
+ sub_dir = os.path.join(
204
+ os.path.dirname(path), sid, "subagents"
205
+ )
206
+ if not os.path.isdir(sub_dir):
207
+ continue
208
+ try:
209
+ subs = sorted(os.listdir(sub_dir))
210
+ except OSError:
211
+ continue
212
+ for fn in subs:
213
+ if not (fn.startswith("agent-") and fn.endswith(".jsonl")):
214
+ # agent-*.meta.json sidecars (and the odd
215
+ # `workflows/` dir): metadata, not a transcript.
216
+ # Counted so the skip is visible.
217
+ bump(self.skips, "sidechain_sidecar")
218
+ continue
219
+ # namespaced id: <parent>#sa-<agentfile-stem> —
220
+ # '#' cannot appear in session uuids, so the raw-cache
221
+ # namespace stays collision-free (same rule as the
222
+ # splitter's <sid>#pN segments)
223
+ sub_sid = f"{sid}#sa-{fn[6:-6]}"
224
+ sub_path = os.path.join(sub_dir, fn)
225
+ try:
226
+ sub_size = os.path.getsize(sub_path)
227
+ except OSError:
228
+ continue
229
+ prev = idx.get(sub_sid)
230
+ if prev is None or sub_size > prev[0]:
231
+ idx[sub_sid] = (sub_size, sub_path)
232
+ return {sid: path for sid, (_, path) in idx.items()}
233
+
234
+ # ---------------------------------------------------------------- parse
235
+
236
+ def _parse_file(self, path: str, session_id: Optional[str] = None) -> Trace:
237
+ apath = os.path.abspath(path).replace("/", os.sep)
238
+ # a subagent's OWN transcript: every row carries isSidechain=true
239
+ # (kept here; only RELAYED sidechain rows inside parent files
240
+ # are skipped) and the sid defaults to the namespaced form
241
+ is_sidechain = os.sep + "subagents" + os.sep in apath
242
+ if session_id:
243
+ sid = session_id
244
+ elif is_sidechain:
245
+ stem = os.path.basename(path)[:-6]
246
+ parent = os.path.basename(os.path.dirname(os.path.dirname(path)))
247
+ sid = f"{parent}#sa-{stem[len('agent-'):]}"
248
+ else:
249
+ sid = os.path.basename(path)[:-6]
250
+ messages: list[Message] = []
251
+ tool_names: dict[str, str] = {} # tool_use_id -> name
252
+ model: Optional[str] = None
253
+ title_custom: Optional[str] = None
254
+ title_ai: Optional[str] = None
255
+ cwd: Optional[str] = None
256
+ started: Optional[float] = None
257
+ last_ts: Optional[float] = None
258
+
259
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
260
+ for line in f:
261
+ try:
262
+ d = json.loads(line)
263
+ except json.JSONDecodeError:
264
+ bump(self.skips, "unparseable_line")
265
+ continue # truncated/interleaved tail lines
266
+ if not isinstance(d, dict):
267
+ continue
268
+ ts = _iso_to_epoch(d.get("timestamp"))
269
+ if started is None and ts is not None:
270
+ started = ts
271
+ if ts is not None:
272
+ last_ts = ts
273
+
274
+ t = d.get("type")
275
+ if t == "custom-title":
276
+ title_custom = d.get("customTitle") or title_custom
277
+ continue
278
+ if t == "ai-title":
279
+ title_ai = d.get("aiTitle") or title_ai
280
+ continue
281
+ if t not in ("user", "assistant"):
282
+ continue
283
+ if d.get("isSidechain") and not is_sidechain:
284
+ continue # relayed subagent traffic inside a parent file
285
+ if cwd is None and d.get("cwd"):
286
+ cwd = d["cwd"]
287
+
288
+ m = d.get("message")
289
+ if not isinstance(m, dict):
290
+ continue
291
+ content = m.get("content")
292
+
293
+ if t == "assistant":
294
+ if model is None and m.get("model"):
295
+ model = str(m["model"])
296
+ texts: list[str] = []
297
+ thinks: list[str] = []
298
+ calls: list[ToolCall] = []
299
+ if isinstance(content, list):
300
+ for blk in content:
301
+ if not isinstance(blk, dict):
302
+ continue
303
+ bt = blk.get("type")
304
+ if bt == "text" and blk.get("text"):
305
+ texts.append(str(blk["text"]))
306
+ elif bt == "thinking" and blk.get("thinking"):
307
+ thinks.append(str(blk["thinking"]))
308
+ elif bt == "tool_use":
309
+ tc_id = str(blk.get("id") or "")
310
+ name = str(blk.get("name") or "")
311
+ if tc_id:
312
+ tool_names[tc_id] = name
313
+ calls.append(
314
+ ToolCall(
315
+ id=tc_id,
316
+ name=name,
317
+ arguments=json.dumps(
318
+ blk.get("input") or {},
319
+ ensure_ascii=False,
320
+ ),
321
+ )
322
+ )
323
+ if not (texts or thinks or calls):
324
+ continue
325
+ messages.append(
326
+ Message(
327
+ role="assistant",
328
+ content="\n\n".join(texts) or None,
329
+ reasoning="\n\n".join(thinks) or None,
330
+ tool_calls=calls,
331
+ timestamp=ts,
332
+ )
333
+ )
334
+
335
+ elif t == "user":
336
+ if isinstance(content, str):
337
+ if d.get("isMeta") and content.startswith(
338
+ _META_SKIP_PREFIXES
339
+ ):
340
+ continue
341
+ if content.startswith("<command-name>"):
342
+ # /goal rows carry the task in <command-args>
343
+ if "/goal" in content[:80]:
344
+ mm = _GOAL_ARGS_RE.search(content)
345
+ if mm and mm.group(1).strip():
346
+ messages.append(
347
+ Message(
348
+ role="user",
349
+ content=mm.group(1).strip(),
350
+ timestamp=ts,
351
+ )
352
+ )
353
+ continue
354
+ if content.startswith(_ARTIFACT_PREFIXES):
355
+ continue
356
+ messages.append(
357
+ Message(role="user", content=content, timestamp=ts)
358
+ )
359
+ elif isinstance(content, list):
360
+ for blk in content:
361
+ if (
362
+ not isinstance(blk, dict)
363
+ or blk.get("type") != "tool_result"
364
+ ):
365
+ continue
366
+ tc_id = str(blk.get("tool_use_id") or "")
367
+ body = _block_text(blk.get("content"))
368
+ if not body and blk.get("is_error"):
369
+ body = "(tool error)"
370
+ if not body and not tc_id:
371
+ continue
372
+ messages.append(
373
+ Message(
374
+ role="tool",
375
+ content=body or None,
376
+ tool_call_id=tc_id,
377
+ tool_name=tool_names.get(tc_id),
378
+ timestamp=ts,
379
+ )
380
+ )
381
+
382
+ # Harness plumbing in user rows (2026-10-24): an orchestrator's
383
+ # dispatch brief pasted as the task row, and the harness's "You have
384
+ # N orchestration messages" notifications. Both are plumbing where
385
+ # the user's own words belong; the brief's `=== TASK ===` block is
386
+ # the real request, so keep that and drop the rest. Measured: 130
387
+ # claude-code raw traces opened with the brief, 0 promoted, 20 gate
388
+ # failures — and 4 of those had a CLEAN task row killed by a 65-char
389
+ # notice at row 168.
390
+ messages = reframe_task_rows(messages, self.skips)
391
+
392
+ # Subagent-brief recovery (MASS doctrine, mirrors codex taskless-
393
+ # child recovery): a sidechain thread opens with the dispatched
394
+ # lane-brief — already a real first user row in Claude files, so
395
+ # nothing to synthesize. But a sidechain whose rows are ALL
396
+ # assistant/tool (handback content only) stays taskless — drop it
397
+ # here rather than letting it die at the improve gates.
398
+ if is_sidechain and not any(
399
+ m.role == "user" for m in messages
400
+ ):
401
+ return Trace(session_id=sid, model=model, title=None,
402
+ started_at=started or last_ts, cwd=cwd,
403
+ messages=[], source="claude-sidechain")
404
+
405
+ return Trace(
406
+ session_id=sid,
407
+ model=model,
408
+ title=title_custom or title_ai,
409
+ started_at=started or last_ts,
410
+ cwd=cwd,
411
+ messages=messages,
412
+ source="claude-sidechain" if is_sidechain else "claude-code",
413
+ )
414
+
415
+ # ------------------------------------------------------------- sessions
416
+
417
+ def list_sessions(
418
+ self,
419
+ *,
420
+ min_messages: int = 10,
421
+ max_messages: int = 120,
422
+ min_tool_calls: int = 2,
423
+ limit: Optional[int] = None,
424
+ ) -> list[dict[str, Any]]:
425
+ """Candidate traces for improvement, newest first (summaries only).
426
+
427
+ ``max_messages`` is a SOFT cap (2026-10-23): a session above it is
428
+ still listed, flagged ``oversize``. The hard cap it replaced
429
+ discarded 1,217 real claude sessions (median 245 messages, 107
430
+ tool calls, ~25% of them over ``split_max_chars`` and therefore
431
+ split candidates) before they could reach ``split_long``.
432
+ """
433
+ rows: list[dict[str, Any]] = []
434
+ for sid, path in self._index().items():
435
+ try:
436
+ trace = self._parse_file(path, sid)
437
+ except OSError:
438
+ bump(self.skips, "unreadable_file")
439
+ continue
440
+ n_msgs = len(trace.messages)
441
+ if not n_msgs:
442
+ bump(self.skips, "taskless_sidechain"
443
+ if trace.source == "claude-sidechain" else "empty_trace")
444
+ continue
445
+ if n_msgs < min_messages:
446
+ bump(self.skips, "below_min_messages")
447
+ continue
448
+ n_calls = sum(len(m.tool_calls) for m in trace.messages)
449
+ if n_calls < min_tool_calls:
450
+ bump(self.skips, "below_min_tool_calls")
451
+ continue
452
+ roles: dict[str, int] = {}
453
+ for m in trace.messages:
454
+ roles[m.role] = roles.get(m.role, 0) + 1
455
+ rows.append(
456
+ oversize_row(
457
+ {
458
+ "session_id": sid,
459
+ "model": trace.model,
460
+ "title": (trace.title or "")[:120].strip(),
461
+ "started_at": trace.started_at,
462
+ "ended_at": trace.messages[-1].timestamp
463
+ if trace.messages
464
+ else None,
465
+ "message_count": n_msgs,
466
+ "tool_call_count": n_calls,
467
+ "cwd": trace.cwd,
468
+ "source": trace.source,
469
+ "roles": roles,
470
+ },
471
+ max_messages,
472
+ )
473
+ )
474
+ rows.sort(key=lambda r: r["started_at"] or 0.0, reverse=True)
475
+ if limit is not None:
476
+ rows = rows[:limit]
477
+ return rows
478
+
479
+ def load_trace(self, session_id: str) -> Trace:
480
+ """Load one full trace as a normalized Trace."""
481
+ path = self._index().get(session_id)
482
+ if not path:
483
+ raise KeyError(f"claude session not found: {session_id}")
484
+ return self._parse_file(path, session_id)
485
+
486
+ # -------------------------------------------------------------- helpers
487
+
488
+ def stats(self) -> dict[str, Any]:
489
+ """Counts over the store(s), plus the skip census.
490
+
491
+ The parse loop runs against a SCRATCH counter (2026-10-24): stats()
492
+ re-parses every session to total messages/tool calls, and those
493
+ parses bump the row-level skip counters again — so a census taken
494
+ after stats() reported every reframed brief and dropped notice row
495
+ twice. stats() must observe the funnel, not re-count it.
496
+ """
497
+ idx = self._index()
498
+ n_msgs = 0
499
+ n_calls = 0
500
+ real_skips = self.skips
501
+ census = new_skips()
502
+ self.skips = census
503
+ try:
504
+ for _, path in idx.items():
505
+ try:
506
+ t = self._parse_file(path)
507
+ except OSError:
508
+ continue
509
+ n_msgs += len(t.messages)
510
+ n_calls += sum(len(m.tool_calls) for m in t.messages)
511
+ finally:
512
+ self.skips = real_skips
513
+ return {
514
+ "projects": sum(
515
+ len(
516
+ [
517
+ e
518
+ for e in os.listdir(d)
519
+ if os.path.isdir(os.path.join(d, e))
520
+ ]
521
+ )
522
+ for d in self.projects_dirs
523
+ if os.path.isdir(d)
524
+ ),
525
+ "sessions": len(idx),
526
+ "messages": n_msgs,
527
+ "tool_calls": n_calls,
528
+ # the scratch counter, not the restored one: reading self.skips
529
+ # here returned the caller's counter and threw the census away
530
+ "skips": dict(census),
531
+ }
traceforge/readers/codex.py ADDED
@@ -0,0 +1,637 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Codex trace reader — rollout JSONLs under ~/.codex/sessions (and exports).
2
+
3
+ Verified schema (Codex Desktop 0.145.x, probed 2026-10-07 against the
4
+ `exported-seed-datas` archives + the live store). Layout:
5
+ <root>/YYYY/MM/DD/rollout-<iso>-<thread-uuid>.jsonl
6
+ one JSON object per line, top-level `type`:
7
+ session_meta payload:{session_id, id (=thread id, unique per file),
8
+ cwd, timestamp, originator, cli_version, source{...}}
9
+ turn_context payload:{turn_id, cwd, model, effort, ...} (engine noise)
10
+ response_item the actual conversation (payload.type below)
11
+ event_msg / world_state engine telemetry — skipped
12
+
13
+ response_item payload types:
14
+ message role:user|assistant|developer, content:
15
+ [{type:"input_text"|"output_text", text}]
16
+ reasoning summary:[{type:"summary_text", text}] (when
17
+ summaries are enabled; encrypted_content
18
+ otherwise — skipped)
19
+ custom_tool_call id, call_id, name, input (STRING of JS code
20
+ for the `exec` tool — kept verbatim)
21
+ custom_tool_call_output call_id, output:[{type:"input_text", text}]
22
+ function_call / function_call_output same via call_id
23
+ agent_message / tool_search_call / ... multi-agent chatter — skipped
24
+
25
+ Probed skip rules (harness artifacts, never real user content):
26
+ user rows starting with <recommended_plugins>, <subagent_notification>,
27
+ <turn_aborted>, <environment_context>, <permissions, <user_instructions
28
+ user rows starting with '# AGENTS.md' or 'The following is the Codex'
29
+ (harness preamble), and ALL developer-role rows (engine instructions)
30
+ Tool results attach via call_id -> the preceding call row. Session id =
31
+ the session_meta payload.id (thread id in the filename), NOT session_id
32
+ (forked rollouts share the parent's session_id; files would collide).
33
+
34
+ Self-reference guard: rollouts whose session_meta cwd sits inside the
35
+ improver workdir are dropped from listing (same doctrine as hermes).
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import glob
40
+ import json
41
+ import os
42
+ import re
43
+ import sys
44
+ import threading
45
+ import time
46
+ from concurrent.futures import ThreadPoolExecutor, as_completed
47
+ from datetime import datetime
48
+ from typing import Any, Optional
49
+
50
+ from traceforge.readers._common import (
51
+ bump, in_workdir, new_skips, oversize_row, reframe_task_rows,
52
+ unwrap_dispatch_brief,
53
+ )
54
+ from traceforge.trace import Message, ToolCall, Trace
55
+
56
+ _USER_ARTIFACT_PREFIXES = (
57
+ "<recommended_plugins>",
58
+ "<subagent_notification>",
59
+ "<turn_aborted>",
60
+ "<environment_context>",
61
+ "<permissions",
62
+ "<user_instructions",
63
+ "<plugins_available",
64
+ )
65
+ _USER_ARTIFACT_TEXT_PREFIXES = (
66
+ "# AGENTS.md",
67
+ "The following is the Codex",
68
+ )
69
+ _ROLLOUT_RE = re.compile(r"rollout-.*\.jsonl(?:\.zst)?$")
70
+ _ROLLOUT_SUFFIXES = (".jsonl.zst", ".jsonl")
71
+
72
+
73
+ def _strip_rollout_suffix(name: str) -> str:
74
+ """rollout-<iso>-<uuid>.jsonl[.zst] -> rollout-<iso>-<uuid>."""
75
+ for suf in _ROLLOUT_SUFFIXES:
76
+ if name.endswith(suf):
77
+ return name[: -len(suf)]
78
+ return name
79
+
80
+
81
+ def _open_rollout(path: str):
82
+ """Open a rollout as text, transparently decompressing ``.jsonl.zst``.
83
+
84
+ Codex compresses older rollouts in place — 107 of 1387 files on this host
85
+ (measured 2026-10-22), all of them older than the live ones. The
86
+ decompressed bytes are the same JSONL, so only the opener changes.
87
+
88
+ Python 3.14 ships ``compression.zstd``; on 3.10-3.13 this needs the
89
+ ``zstandard`` package. Both are optional, so a missing backend raises a
90
+ clear error rather than silently skipping the file.
91
+ """
92
+ if not path.endswith(".zst"):
93
+ return open(path, "r", encoding="utf-8", errors="replace")
94
+ try:
95
+ from compression import zstd as _zstd # Python 3.14+
96
+ except ImportError:
97
+ _zstd = None
98
+ if _zstd is not None:
99
+ return _zstd.open(path, "rt", encoding="utf-8", errors="replace")
100
+ try:
101
+ import zstandard # type: ignore
102
+ except ImportError as exc: # pragma: no cover - depends on interpreter
103
+ raise RuntimeError(
104
+ f"cannot read compressed rollout {path!r}: install the "
105
+ "'zstandard' package or run on Python 3.14+"
106
+ ) from exc
107
+ return zstandard.open(path, "rt", encoding="utf-8", errors="replace")
108
+ _ISO_RE = re.compile(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})")
109
+ # Completion-report openings: work summaries, not task restatements.
110
+ # Anchored at string start (case-insensitive); "Implemented …", "Wrote …".
111
+ _REPORT_OPENING_RE = re.compile(
112
+ r"^\s*(?:i\s+)?(?:ich\s+)?(?:have\s+|hab\s+|habe\s+)?"
113
+ r"(?:implemented|created|fixed|added|built|completed|wrote|done)\b",
114
+ re.I,
115
+ )
116
+
117
+
118
+ def _iso_to_epoch(ts: Optional[str]) -> Optional[float]:
119
+ if not ts:
120
+ return None
121
+ try:
122
+ return datetime.fromisoformat(str(ts).replace("Z", "+00:00")).timestamp()
123
+ except ValueError:
124
+ return None
125
+
126
+
127
+ def _unwrap_dispatch_brief(text: str) -> str:
128
+ """Return the real task from an Orca coordinator dispatch brief.
129
+
130
+ A codex subagent rollout opens with the coordinator's dispatch text: 6-7KB
131
+ of orchestration boilerplate (session/task ids, `orca orchestration` CLI
132
+ docs, worker_done rules) followed by a `=== TASK ===` block holding the
133
+ actual instruction. Promoted as-is, the trainee learns to emit harness
134
+ plumbing as the user turn (measured 2026-10-22: 81 raw traces, 6 already
135
+ promoted). The TASK block is the user's own words, so keep exactly that;
136
+ a brief with no TASK block yields "" and the row is dropped.
137
+
138
+ Thin alias over the shared helper (2026-10-24): the claude reader needs
139
+ the same unwrap, and two copies of the pattern drift. Kept as a module
140
+ name because tests and callers import it from here.
141
+ """
142
+ return unwrap_dispatch_brief(text)
143
+
144
+
145
+ def recover_taskless_brief(messages: list, role_hint: str = "") -> bool:
146
+ """Synthesize a task row for a taskless child thread (in place).
147
+
148
+ Subagent handoff recovery (owner directive 2026-10-07: taskless child
149
+ threads are valuable work traces, don't drop them): when the rollout
150
+ carries no real user row, codex children open with the agent restating
151
+ its dispatched brief in task terms. That restatement becomes a
152
+ synthesized first user row so the trace is a trainable
153
+ (task → work → result) pair; the improve pipeline's reframe path and
154
+ judges still verify task↔work fit. Completion-REPORT-shaped openings
155
+ ("Implemented …", "Wrote …") are work summaries, not tasks — they stay
156
+ taskless.
157
+
158
+ Shared with the raw-pool repair pass (2026-10-24): dropping the
159
+ harness's orchestration-notification rows can leave a trace with no
160
+ user row at all, and a cached trace must land in the same state a
161
+ fresh pull would produce.
162
+
163
+ Returns True when a row was inserted.
164
+ """
165
+ if any(m.role == "user" for m in messages):
166
+ return False
167
+ first_asst = next(
168
+ (m for m in messages if m.role == "assistant" and (m.content or "")),
169
+ None,
170
+ )
171
+ if (
172
+ first_asst is None
173
+ or len(first_asst.content or "") < 80
174
+ or _REPORT_OPENING_RE.search(first_asst.content or "")
175
+ ):
176
+ return False
177
+ messages.insert(
178
+ 0,
179
+ Message(
180
+ role="user",
181
+ content=role_hint + (first_asst.content or "").strip(),
182
+ timestamp=first_asst.timestamp,
183
+ ),
184
+ )
185
+ return True
186
+
187
+
188
+ def _content_text(blocks: Any) -> str:
189
+ """Flatten content-block lists (message bodies, tool outputs)."""
190
+ if isinstance(blocks, str):
191
+ return blocks
192
+ if not isinstance(blocks, list):
193
+ return ""
194
+ parts: list[str] = []
195
+ for b in blocks:
196
+ if isinstance(b, dict):
197
+ t = b.get("text")
198
+ if t:
199
+ parts.append(str(t))
200
+ elif isinstance(b, str):
201
+ parts.append(b)
202
+ return "\n\n".join(p for p in parts if p)
203
+
204
+
205
+ class CodexTraceReader:
206
+ """Reads real traces from Codex rollout session stores (read-only)."""
207
+
208
+ def __init__(self, roots: Optional[list[str]] = None):
209
+ if roots:
210
+ self.roots = [os.path.expanduser(r) for r in roots]
211
+ else:
212
+ self.roots = [os.path.expanduser("~/.codex/sessions")]
213
+ # files/rows dropped without a candidate row: reported by stats()
214
+ self.skips = new_skips()
215
+
216
+ # ---------------------------------------------------------------- index
217
+
218
+ def _agent_role(self, path: str) -> Optional[str]:
219
+ """Subagent role (agent_path/agent_nickname) from session_meta."""
220
+ try:
221
+ with _open_rollout(path) as f:
222
+ for line in f:
223
+ try:
224
+ d = json.loads(line)
225
+ except json.JSONDecodeError:
226
+ continue
227
+ if d.get("type") == "session_meta":
228
+ pay = d.get("payload") or {}
229
+ role = pay.get("agent_path") or pay.get("agent_nickname")
230
+ if role:
231
+ return str(role).strip("/")
232
+ return None
233
+ except OSError:
234
+ pass
235
+ return None
236
+
237
+ def _rollout_files(self) -> list[str]:
238
+ files: list[str] = []
239
+ for root in self.roots:
240
+ if not os.path.isdir(root):
241
+ continue
242
+ for p in glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True):
243
+ if _ROLLOUT_RE.search(os.path.basename(p)):
244
+ files.append(p)
245
+ for p in glob.glob(os.path.join(root, "**", "*.jsonl.zst"), recursive=True):
246
+ if _ROLLOUT_RE.search(os.path.basename(p)):
247
+ files.append(p)
248
+ files.sort()
249
+ return files
250
+
251
+ def _index(self) -> dict[str, str]:
252
+ """thread id (session_meta payload.id) -> rollout path."""
253
+ idx: dict[str, str] = {}
254
+ for path in self._rollout_files():
255
+ sid = self._thread_id(path)
256
+ if sid:
257
+ idx[sid] = path
258
+ else:
259
+ bump(self.skips, "no_thread_id")
260
+ return idx
261
+
262
+ def _thread_id(self, path: str) -> Optional[str]:
263
+ """session_meta payload.id — the thread id from the filename.
264
+
265
+ The last uuid in the filename IS the thread id; parsing it avoids
266
+ scanning the file for sessions whose meta row was truncated away.
267
+ Fallback: scan for session_meta when the name is non-standard.
268
+ """
269
+ base = _strip_rollout_suffix(os.path.basename(path))
270
+ mm = re.search(r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$", base, re.I)
271
+ if mm:
272
+ return mm.group(1)
273
+ for line in _open_rollout(path):
274
+ try:
275
+ d = json.loads(line)
276
+ except json.JSONDecodeError:
277
+ continue
278
+ if d.get("type") == "session_meta":
279
+ pid = (d.get("payload") or {}).get("id")
280
+ if pid:
281
+ return str(pid)
282
+ return None
283
+
284
+ # ---------------------------------------------------------------- parse
285
+
286
+ def _parse_file(self, path: str, session_id: Optional[str] = None) -> Trace:
287
+ sid = session_id or self._thread_id(path) or _strip_rollout_suffix(os.path.basename(path))
288
+ messages: list[Message] = []
289
+ call_names: dict[str, str] = {} # call_id -> tool name
290
+ model: Optional[str] = None
291
+ cwd: Optional[str] = None
292
+ started: Optional[float] = None
293
+ last_ts: Optional[float] = None
294
+ title: Optional[str] = None # first real user task, set below
295
+ pending_reasoning: Optional[str] = None # reasoning buffered for the next assistant row
296
+
297
+ with _open_rollout(path) as f:
298
+ for line in f:
299
+ try:
300
+ d = json.loads(line)
301
+ except json.JSONDecodeError:
302
+ bump(self.skips, "unparseable_line")
303
+ continue
304
+ if not isinstance(d, dict):
305
+ continue
306
+ ts = _iso_to_epoch(d.get("timestamp"))
307
+ if started is None and ts is not None:
308
+ started = ts
309
+ if ts is not None:
310
+ last_ts = ts
311
+
312
+ t = d.get("type")
313
+ if t == "session_meta":
314
+ pay = d.get("payload") or {}
315
+ cwd = cwd or pay.get("cwd")
316
+ elif t == "turn_context":
317
+ pay = d.get("payload") or {}
318
+ if model is None and pay.get("model"):
319
+ model = str(pay["model"])
320
+ cwd = cwd or pay.get("cwd")
321
+ elif t != "response_item":
322
+ continue
323
+
324
+ pay = d.get("payload") or {}
325
+ pt = pay.get("type")
326
+
327
+ if pt == "message":
328
+ role = pay.get("role")
329
+ text = _content_text(pay.get("content"))
330
+ if not text:
331
+ continue
332
+ if role == "user":
333
+ if text.startswith(_USER_ARTIFACT_PREFIXES) or text.startswith(
334
+ _USER_ARTIFACT_TEXT_PREFIXES
335
+ ):
336
+ continue
337
+ messages.append(
338
+ Message(role="user", content=text, timestamp=ts)
339
+ )
340
+ elif role == "assistant":
341
+ messages.append(
342
+ Message(role="assistant", content=text, timestamp=ts)
343
+ )
344
+ # developer-role rows: engine instructions, skipped
345
+
346
+ elif pt == "reasoning":
347
+ # codex emits reasoning BEFORE its assistant row
348
+ # (reasoning -> message / custom_tool_call). Buffer and
349
+ # attach to the next assistant message. Encrypted-only
350
+ # reasoning rows produce no text and are skipped.
351
+ s = pay.get("summary") or []
352
+ text = _content_text(s)
353
+ if text:
354
+ pending_reasoning = (
355
+ f"{pending_reasoning}\n\n{text}"
356
+ if pending_reasoning
357
+ else text
358
+ )
359
+
360
+ elif pt in ("custom_tool_call", "function_call"):
361
+ name = str(pay.get("name") or "")
362
+ call_id = str(pay.get("call_id") or pay.get("id") or "")
363
+ if call_id:
364
+ call_names[call_id] = name
365
+ inp = pay.get("input")
366
+ if isinstance(inp, str):
367
+ # `exec` custom tool calls carry raw JS code, not a
368
+ # JSON object — normalize to {"code": ...} so the
369
+ # structural gate (JSON tool args) stays satisfiable
370
+ # while the code remains byte-verbatim.
371
+ args = json.dumps({"code": inp}, ensure_ascii=False)
372
+ else:
373
+ args = json.dumps(inp or {}, ensure_ascii=False)
374
+ messages.append(
375
+ Message(
376
+ role="assistant",
377
+ content=None,
378
+ reasoning=pending_reasoning or None,
379
+ tool_calls=[
380
+ ToolCall(id=call_id, name=name, arguments=args)
381
+ ],
382
+ timestamp=ts,
383
+ )
384
+ )
385
+ pending_reasoning = None
386
+
387
+ elif pt in ("custom_tool_call_output", "function_call_output"):
388
+ call_id = str(pay.get("call_id") or "")
389
+ out = pay.get("output")
390
+ body = _content_text(out) if isinstance(out, list) else (
391
+ str(out) if out is not None else ""
392
+ )
393
+ messages.append(
394
+ Message(
395
+ role="tool",
396
+ content=body or None,
397
+ tool_call_id=call_id,
398
+ tool_name=call_names.get(call_id),
399
+ timestamp=ts,
400
+ )
401
+ )
402
+
403
+ # Harness plumbing in user rows (2026-10-24): the coordinator's
404
+ # dispatch brief (keep its `=== TASK ===` block, drop the 6-7KB of
405
+ # Orca boilerplate) and the harness's "You have N orchestration
406
+ # messages" notifications. Both are plumbing where the user's own
407
+ # words belong, and the validate gate rejects the trace for either.
408
+ # Runs BEFORE the title pick so the title is the real task.
409
+ messages = reframe_task_rows(messages, self.skips)
410
+
411
+ # title: first real user task (codex has no title rows)
412
+ for m in messages:
413
+ if m.role == "user" and m.content:
414
+ title = " ".join(m.content.split())[:120]
415
+ break
416
+
417
+ # Subagent handoff recovery (owner directive 2026-10-07: taskless
418
+ # child threads are valuable work traces, don't drop them): when
419
+ # the rollout carries no real user row, codex children open with
420
+ # the agent restating its dispatched brief in task terms. That
421
+ # restatement becomes a synthesized first user row so the trace
422
+ # is a trainable (task → work → result) pair; the improve
423
+ # pipeline's reframe path and judges still verify task↔work fit.
424
+ # Completion-REPORT-shaped openings ("Implemented …", "Wrote …")
425
+ # are work summaries, not tasks — they stay taskless.
426
+ if not any(m.role == "user" for m in messages):
427
+ role = self._agent_role(path) # extra file read: taskless only
428
+ recover_taskless_brief(
429
+ messages, f"[subagent brief — role: {role}]\n" if role else ""
430
+ )
431
+
432
+ return Trace(
433
+ session_id=sid,
434
+ model=model,
435
+ title=title,
436
+ started_at=started or last_ts,
437
+ cwd=cwd,
438
+ messages=messages,
439
+ source="codex",
440
+ )
441
+
442
+ # ------------------------------------------------------------- sessions
443
+
444
+ def _cache_path(self) -> str:
445
+ """Per-roots summary cache file (hash of the roots list)."""
446
+ import hashlib
447
+
448
+ key = hashlib.sha1(
449
+ "\x00".join(self.roots).encode("utf-8", "replace")
450
+ ).hexdigest()[:12]
451
+ base = next(
452
+ (r for r in self.roots if os.path.isdir(r)), None
453
+ ) or os.getcwd()
454
+ d = os.path.join(os.path.dirname(os.path.abspath(base)), ".cache")
455
+ try:
456
+ os.makedirs(d, exist_ok=True)
457
+ except OSError:
458
+ return os.path.join(
459
+ os.environ.get("TMPDIR", os.getcwd()), f".tf-codex-{key}.json"
460
+ )
461
+ return os.path.join(d, f"codex-summaries-{key}.json")
462
+
463
+ def _load_cache(self) -> dict:
464
+ try:
465
+ with open(self._cache_path(), "r", encoding="utf-8") as f:
466
+ d = json.load(f)
467
+ if isinstance(d, dict) and d.get("v") == 1:
468
+ return d.get("entries", {})
469
+ except (OSError, ValueError):
470
+ pass
471
+ return {}
472
+
473
+ def _save_cache(self, entries: dict) -> None:
474
+ try:
475
+ tmp = self._cache_path() + ".tmp"
476
+ with open(tmp, "w", encoding="utf-8") as f:
477
+ json.dump({"v": 1, "entries": entries}, f,
478
+ separators=(",", ":"))
479
+ os.replace(tmp, self._cache_path())
480
+ except OSError:
481
+ pass # cache is best-effort, never fatal
482
+
483
+ def list_sessions(
484
+ self,
485
+ *,
486
+ min_messages: int = 10,
487
+ max_messages: int = 120,
488
+ min_tool_calls: int = 2,
489
+ exclude_cwd: Optional[str] = None,
490
+ limit: Optional[int] = None,
491
+ status_cb=None,
492
+ use_cache: bool = True,
493
+ ) -> list[dict[str, Any]]:
494
+ """Candidate traces for improvement, newest first (summaries only).
495
+
496
+ Per-file summaries are cached (path -> {sig: size+mtime, row})
497
+ under the store's parent .cache dir; only new/changed files
498
+ parse. Parsing is thread-parallel — ~3.8k rollouts ≈ 10 GB
499
+ parse in ~20s wall on the first run, near-instant after.
500
+ ``status_cb(msg)`` gets a discovery heartbeat while it runs.
501
+
502
+ ``use_cache=False`` forces a full parse (2026-10-24). The row-level
503
+ skip counters are bumped during parsing, so a warm cache made the
504
+ census report near-zero: measured, 251 rollouts carry a dispatch
505
+ brief and the cached run counted 0 reframes. ``stats()`` passes
506
+ False so its census is authoritative.
507
+
508
+ ``max_messages`` is a SOFT cap (2026-10-23): a rollout above it is
509
+ still listed, flagged ``oversize``. The hard cap it replaced
510
+ discarded 1,246 real codex rollouts (median 210 messages, 98 tool
511
+ calls) before they could reach ``split_long``.
512
+ """
513
+ idx = self._index()
514
+ cached = self._load_cache() if use_cache else {}
515
+ entries: dict[str, Any] = {}
516
+ stale: list[tuple[str, str, str]] = []
517
+ for sid, path in idx.items():
518
+ try:
519
+ sig = f"{os.path.getsize(path):x}-{int(os.path.getmtime(path)):x}"
520
+ except OSError:
521
+ # stat failed: the file vanished or is locked. Counted —
522
+ # this used to drop the rollout with no trace of it.
523
+ bump(self.skips, "unreadable_file")
524
+ continue
525
+ c = cached.get(path)
526
+ if c and c.get("sig") == sig:
527
+ entries[path] = c
528
+ else:
529
+ stale.append((sid, path, sig))
530
+ rows: list[dict[str, Any]] = [
531
+ dict(e["row"]) for e in entries.values()
532
+ ]
533
+ if stale:
534
+ t0 = time.monotonic()
535
+ done = 0
536
+ lock = threading.Lock()
537
+
538
+ def _parse_one(item):
539
+ sid, path, sig = item
540
+ try:
541
+ trace = self._parse_file(path, sid)
542
+ except OSError:
543
+ bump(self.skips, "unreadable_file")
544
+ return None
545
+ return path, sig, trace
546
+
547
+ workers = min(8, max(1, len(stale)))
548
+ with ThreadPoolExecutor(max_workers=workers) as ex:
549
+ futs = [ex.submit(_parse_one, it) for it in stale]
550
+ for f in as_completed(futs):
551
+ got = f.result()
552
+ with lock:
553
+ done += 1
554
+ if status_cb and (
555
+ done % 250 == 0 or done == len(stale)
556
+ ):
557
+ status_cb(
558
+ f"discovery: {done}/{len(stale)} rollouts "
559
+ f"({time.monotonic() - t0:.0f}s)"
560
+ )
561
+ if got is None:
562
+ continue
563
+ path, sig, trace = got
564
+ roles: dict[str, int] = {}
565
+ for m in trace.messages:
566
+ roles[m.role] = roles.get(m.role, 0) + 1
567
+ row = {
568
+ "session_id": trace.session_id,
569
+ "model": trace.model,
570
+ "title": (trace.title or "")[:120].strip(),
571
+ "started_at": trace.started_at,
572
+ "ended_at": trace.messages[-1].timestamp
573
+ if trace.messages
574
+ else None,
575
+ "message_count": len(trace.messages),
576
+ "tool_call_count": sum(
577
+ len(m.tool_calls) for m in trace.messages
578
+ ),
579
+ "cwd": trace.cwd,
580
+ "source": "codex",
581
+ "roles": roles,
582
+ }
583
+ rows.append(row)
584
+ entries[path] = {"sig": sig, "row": row}
585
+ self._save_cache(entries)
586
+ out: list[dict[str, Any]] = []
587
+ for r in rows:
588
+ if exclude_cwd and in_workdir(r["cwd"], exclude_cwd):
589
+ bump(self.skips, "cwd_self_reference")
590
+ continue
591
+ if r["message_count"] < min_messages:
592
+ bump(self.skips, "below_min_messages")
593
+ continue
594
+ if r["tool_call_count"] < min_tool_calls:
595
+ bump(self.skips, "below_min_tool_calls")
596
+ continue
597
+ out.append(oversize_row(r, max_messages))
598
+ out.sort(key=lambda r: r["started_at"] or 0.0, reverse=True)
599
+ if limit is not None:
600
+ out = out[:limit]
601
+ return out
602
+
603
+ def load_trace(self, session_id: str) -> Trace:
604
+ """Load one full trace as a normalized Trace."""
605
+ path = self._index().get(session_id)
606
+ if not path:
607
+ raise KeyError(f"codex session not found: {session_id}")
608
+ return self._parse_file(path, session_id)
609
+
610
+ # -------------------------------------------------------------- helpers
611
+
612
+ def stats(self) -> dict[str, Any]:
613
+ """Counts over the store(s), plus the skip census.
614
+
615
+ Runs against a SCRATCH counter (2026-10-24): stats() re-walks the
616
+ index and re-lists sessions, which bumps the file-level counters
617
+ again — a census taken after stats() reported every skip twice.
618
+ """
619
+ real_skips = self.skips
620
+ census = new_skips()
621
+ self.skips = census
622
+ try:
623
+ rows = self.list_sessions(
624
+ min_messages=0, max_messages=10**9, min_tool_calls=0,
625
+ use_cache=False,
626
+ )
627
+ finally:
628
+ self.skips = real_skips
629
+ return {
630
+ "roots": len(self.roots),
631
+ "sessions": len(rows),
632
+ "messages": sum(r["message_count"] for r in rows),
633
+ "tool_calls": sum(r["tool_call_count"] for r in rows),
634
+ # the scratch counter, not the restored one: reading self.skips
635
+ # here returned the caller's counter and threw the census away
636
+ "skips": dict(census),
637
+ }
traceforge/readers/hermes.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hermes Agent trace reader — reads sessions+messages from state.db.
2
+
3
+ Verified schema (Hermes state.db, 2026-09-26):
4
+ sessions(id, model, title, display_name, started_at, ended_at,
5
+ message_count, tool_call_count, input_tokens, output_tokens,
6
+ cwd, source, ...)
7
+ messages(id, session_id, role, content, tool_call_id, tool_calls,
8
+ tool_name, timestamp, reasoning, reasoning_content, active,
9
+ compacted, display_order, ...)
10
+
11
+ display_order is NULL on ~47% of rows -> order by (timestamp, id).
12
+ tool_calls is a JSON array: [{"id","call_id","type":"function",
13
+ "function":{"name","arguments"}}]. Tool results are role='tool' rows
14
+ with tool_call_id + tool_name, paired against the issuing assistant turn.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sqlite3
20
+ from typing import Any, Optional
21
+
22
+ from traceforge.readers._common import bump, in_workdir, new_skips, oversize_row
23
+ from traceforge.trace import Message, ToolCall, Trace, json_loads_lenient
24
+
25
+ # Sources that carry a real user-facing agent session. 'oneshot' is
26
+ # traceforge's OWN improver runtime (6,625 sessions in the seed archive,
27
+ # 1,578 of them long enough to look like candidates) and 'tool' is a
28
+ # 2-message probe — both stay out. 'subagent' / 'kanban' / 'local' are
29
+ # real work sessions: the subagent ones are delegate_task children whose
30
+ # transcripts are the densest multi-agent signal in the store (MASS,
31
+ # arXiv 2610.12176 — the claude reader already indexes its sidechains
32
+ # for the same reason), and excluding them here was an inconsistency,
33
+ # not a policy. Measured 2026-10-23: 43 such sessions pass every other
34
+ # filter and were being dropped by the whitelist alone.
35
+ _CANDIDATE_SOURCES = ("desktop", "cli", "tui", "subagent", "local", "kanban")
36
+
37
+
38
+ class HermesTraceReader:
39
+ """Reads real traces from one or more Hermes Agent state.dbs (read-only).
40
+
41
+ Multi-db support (2026-10-13): a list of db paths is searched
42
+ together — the live store plus seed archives like
43
+ harness-traces/hermes/state.db. Session ids are uuids, unique per
44
+ store with zero expected cross-store overlap (verified against the
45
+ seed archive); a hit in the FIRST db wins on collision.
46
+ """
47
+
48
+ def __init__(self, state_db: str | list[str]):
49
+ if isinstance(state_db, (list, tuple)):
50
+ self.state_dbs = [os.path.abspath(d) for d in state_db]
51
+ else:
52
+ self.state_dbs = [os.path.abspath(state_db)]
53
+ # rows dropped without a candidate row (self-reference guard,
54
+ # inactive turns): reported by stats() instead of vanishing.
55
+ self.skips = new_skips()
56
+ # Last filter settings list_sessions ran with — the SQL-level drop
57
+ # census in stats() attributes drops in the same order the WHERE
58
+ # clause applies, so it needs to know what the clause was.
59
+ self._last_min_messages = 10
60
+ self._last_min_tool_calls = 2
61
+ self._last_require_ended = True
62
+ self._last_max_input_tokens: Optional[int] = None
63
+ self._last_exclude_cwd: Optional[str] = None
64
+
65
+ @property
66
+ def state_db(self) -> str:
67
+ """First db (backwards compat for callers/tests)."""
68
+ return self.state_dbs[0]
69
+
70
+ def _connect(self, db: str) -> sqlite3.Connection:
71
+ if not os.path.exists(db):
72
+ raise FileNotFoundError(f"Hermes state.db not found at {db}")
73
+ return sqlite3.connect(f"file:{db}?mode=ro", uri=True)
74
+
75
+ def _existing_dbs(self) -> list[str]:
76
+ """All configured dbs that exist on disk (seed archives may be
77
+ absent; the live store always is)."""
78
+ return [d for d in self.state_dbs if os.path.exists(d)]
79
+
80
+ def _iter_dbs(self) -> tuple[str, sqlite3.Connection]:
81
+ """(db, conn) for every existing store; caller closes each conn."""
82
+ for db in self._existing_dbs():
83
+ yield db, self._connect(db)
84
+
85
+ # ------------------------------------------------------------ sessions
86
+
87
+ def list_sessions(
88
+ self,
89
+ *,
90
+ min_messages: int = 10,
91
+ max_messages: int = 120,
92
+ min_tool_calls: int = 2,
93
+ max_input_tokens: Optional[int] = None,
94
+ source: Optional[str] = None,
95
+ require_ended: bool = True,
96
+ exclude_cwd: Optional[str] = None,
97
+ limit: Optional[int] = None,
98
+ ) -> list[dict[str, Any]]:
99
+ """Candidate traces for improvement, newest first (summaries only).
100
+
101
+ ``exclude_cwd`` roots out traceforge's own improver children even
102
+ when mis-tagged: every improver stage runs with cwd inside the
103
+ improver workdir, so a cwd there means self-referential data no
104
+ matter what ``source`` says.
105
+
106
+ ``max_messages`` is a SOFT cap (2026-10-23): a session above it is
107
+ still listed, flagged ``oversize``. The hard cap it replaced
108
+ discarded 563 real hermes sessions (median 187 messages, 99 tool
109
+ calls) before they could reach ``split_long`` — the stage that
110
+ actually bounds what the pipeline processes, by rendered size
111
+ rather than by message count. ``min_messages`` / ``min_tool_calls``
112
+ stay hard: they filter degenerate stubs, not long work.
113
+ """
114
+ q = (
115
+ "SELECT s.id, s.model, s.title, s.display_name, s.started_at,"
116
+ " s.ended_at, s.message_count, s.tool_call_count,"
117
+ " s.input_tokens, s.output_tokens, s.cwd, s.source"
118
+ " FROM sessions s"
119
+ " WHERE s.message_count >= ? AND s.tool_call_count >= ?"
120
+ " AND s.source IN (" + ",".join("?" * len(_CANDIDATE_SOURCES)) + ")"
121
+ )
122
+ # remembered for stats()'s SQL-level drop census
123
+ self._last_min_messages = min_messages
124
+ self._last_min_tool_calls = min_tool_calls
125
+ self._last_require_ended = require_ended
126
+ self._last_max_input_tokens = max_input_tokens
127
+ self._last_exclude_cwd = exclude_cwd
128
+ params: list[Any] = [min_messages, min_tool_calls, *_CANDIDATE_SOURCES]
129
+ if exclude_cwd:
130
+ # Self-reference guard: improver children run with cwd inside
131
+ # the improver workdir; a mis-tagged source (e.g. inherited
132
+ # 'desktop') must never let them become candidates.
133
+ q += " AND (s.cwd IS NULL OR s.cwd NOT LIKE ?)"
134
+ params.append(exclude_cwd.rstrip("/\\") + "/%")
135
+ if require_ended:
136
+ q += " AND s.ended_at IS NOT NULL"
137
+ if source is not None:
138
+ q += " AND s.source = ?"
139
+ params.append(source)
140
+ if max_input_tokens is not None:
141
+ q += " AND s.input_tokens <= ?"
142
+ params.append(max_input_tokens)
143
+ q += " ORDER BY s.started_at DESC"
144
+ # (LIMIT applied per-db below, after the merge)
145
+ rows: list[dict[str, Any]] = []
146
+ seen: set[str] = set()
147
+ for _db, conn in self._iter_dbs():
148
+ try:
149
+ got = conn.execute(q, params).fetchall()
150
+ finally:
151
+ conn.close()
152
+ for r in got:
153
+ if r[0] in seen:
154
+ continue
155
+ seen.add(r[0])
156
+ if exclude_cwd and in_workdir(r[10], exclude_cwd):
157
+ bump(self.skips, "cwd_self_reference")
158
+ continue
159
+ rows.append(
160
+ oversize_row(
161
+ {
162
+ "session_id": r[0],
163
+ "model": r[1],
164
+ "title": (r[2] or r[3] or "").strip(),
165
+ "started_at": r[4],
166
+ "ended_at": r[5],
167
+ "message_count": r[6],
168
+ "tool_call_count": r[7],
169
+ "input_tokens": r[8],
170
+ "output_tokens": r[9],
171
+ "cwd": r[10],
172
+ "source": r[11],
173
+ },
174
+ max_messages,
175
+ )
176
+ )
177
+ rows.sort(key=lambda r: r["started_at"] or 0.0, reverse=True)
178
+ if limit is not None:
179
+ rows = rows[:limit]
180
+ return rows
181
+
182
+ # ------------------------------------------------------------- messages
183
+
184
+ MSG_QUERY = (
185
+ "SELECT id, role, content, tool_call_id, tool_calls, tool_name,"
186
+ " timestamp, reasoning, reasoning_content, active, compacted"
187
+ " FROM messages WHERE session_id = ?"
188
+ " ORDER BY timestamp ASC, id ASC"
189
+ )
190
+
191
+ def load_trace(self, session_id: str) -> Trace:
192
+ """Load one full trace as a normalized Trace (searches all dbs)."""
193
+ for _db, conn in self._iter_dbs():
194
+ try:
195
+ sess = conn.execute(
196
+ "SELECT id, model, title, display_name, started_at, cwd,"
197
+ " source FROM sessions WHERE id = ?",
198
+ (session_id,),
199
+ ).fetchone()
200
+ if not sess:
201
+ continue
202
+ rows = conn.execute(self.MSG_QUERY, (session_id,)).fetchall()
203
+ return Trace(
204
+ session_id=sess[0],
205
+ model=sess[1],
206
+ title=(sess[2] or sess[3] or "").strip() or None,
207
+ started_at=sess[4],
208
+ cwd=sess[5],
209
+ messages=list(self._parse_rows(rows)),
210
+ source="hermes" if not sess[6] else str(sess[6]),
211
+ )
212
+ finally:
213
+ conn.close()
214
+ raise KeyError(f"session not found: {session_id}")
215
+
216
+ def _parse_rows(self, rows: list[sqlite3.Row]) -> list[Message]:
217
+ out: list[Message] = []
218
+ for (mid, role, content, tc_id, tc_json, tool_name, ts,
219
+ reasoning, reasoning_content, active, compacted) in rows:
220
+ if not active:
221
+ bump(self.skips, "inactive_turn")
222
+ continue # rewound/deleted turns
223
+ m = Message(role=role, content=content, timestamp=ts)
224
+ if role == "tool":
225
+ m.tool_call_id = tc_id
226
+ m.tool_name = tool_name
227
+ elif role == "assistant" and tc_json:
228
+ calls = json_loads_lenient(tc_json)
229
+ if isinstance(calls, list):
230
+ for c in calls:
231
+ fn = c.get("function") or {}
232
+ m.tool_calls.append(
233
+ ToolCall(
234
+ id=str(c.get("call_id") or c.get("id") or ""),
235
+ name=str(fn.get("name") or ""),
236
+ arguments=fn.get("arguments") or "{}",
237
+ )
238
+ )
239
+ reasoning = reasoning or reasoning_content
240
+ if reasoning:
241
+ m.reasoning = reasoning
242
+ out.append(m)
243
+ return out
244
+
245
+ # -------------------------------------------------------------- helpers
246
+
247
+ def _sql_drop_census(self) -> dict[str, int]:
248
+ """Attribute every dropped session to the filter that took it.
249
+
250
+ ``list_sessions`` applies min_messages / min_tool_calls / the source
251
+ whitelist / require_ended / max_input_tokens / the cwd guard inside
252
+ ONE SQL query, so none of those drops can bump a counter — measured
253
+ 2026-10-24: 6,741 of 9,110 sessions vanished with ``skips == {}``.
254
+ A skip census that reports nothing for the store that drops the most
255
+ is worse than no census, so the attribution runs here, in the same
256
+ order the WHERE clause applies.
257
+
258
+ Read-only and cheap (one row scan per db, ~9k rows).
259
+ """
260
+ census: dict[str, int] = {}
261
+ seen: set[str] = set()
262
+
263
+ def bump_census(reason: str) -> None:
264
+ census[reason] = census.get(reason, 0) + 1
265
+
266
+ for _db, conn in self._iter_dbs():
267
+ try:
268
+ rows = conn.execute(
269
+ "SELECT id, message_count, tool_call_count, source,"
270
+ " ended_at, cwd, input_tokens FROM sessions"
271
+ ).fetchall()
272
+ finally:
273
+ conn.close()
274
+ for (sid, mc, tc, src, ended, cwd, itok) in rows:
275
+ if sid in seen:
276
+ bump_census("cross_db_duplicate")
277
+ continue
278
+ seen.add(sid)
279
+ if src not in _CANDIDATE_SOURCES:
280
+ bump_census(f"source_not_candidate:{src}")
281
+ elif (mc or 0) < self._last_min_messages:
282
+ bump_census("below_min_messages")
283
+ elif (tc or 0) < self._last_min_tool_calls:
284
+ bump_census("below_min_tool_calls")
285
+ elif self._last_require_ended and ended is None:
286
+ bump_census("not_ended")
287
+ elif (self._last_max_input_tokens is not None
288
+ and (itok or 0) > self._last_max_input_tokens):
289
+ bump_census("over_max_input_tokens")
290
+ elif self._last_exclude_cwd and cwd and in_workdir(
291
+ cwd, self._last_exclude_cwd
292
+ ):
293
+ bump_census("cwd_self_reference")
294
+ return census
295
+
296
+ def stats(self) -> dict[str, Any]:
297
+ """Quick counts over the store(s) for sanity checks.
298
+
299
+ ``skips`` merges the row-level counters with the SQL-level drop
300
+ census, so the store's whole funnel is visible in one place.
301
+ """
302
+ out: dict[str, Any] = {"dbs": len(self._existing_dbs())}
303
+ for key in ("sessions", "messages", "tool_rows"):
304
+ out[key] = 0
305
+ for _db, conn in self._iter_dbs():
306
+ try:
307
+ one = lambda sql: conn.execute(sql).fetchone()[0]
308
+ out["sessions"] += one("SELECT COUNT(*) FROM sessions")
309
+ out["messages"] += one("SELECT COUNT(*) FROM messages")
310
+ out["tool_rows"] += one(
311
+ "SELECT COUNT(*) FROM messages WHERE role='tool'"
312
+ )
313
+ finally:
314
+ conn.close()
315
+ skips = dict(self.skips)
316
+ for reason, n in self._sql_drop_census().items():
317
+ skips[reason] = skips.get(reason, 0) + n
318
+ out["skips"] = skips
319
+ return out
traceforge/readers/nemotron.py ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Nemotron SFT dataset reader — NVIDIA's published agentic SFT corpora.
2
+
3
+ These are not live agent stores. Each row is an already-finished
4
+ ``(messages, tools)`` pair from a released SFT dataset, so the reader's
5
+ job is narrower than the harness readers': normalize the row into a
6
+ :class:`~traceforge.trace.Trace` and serve it by session id.
7
+
8
+ Why they belong in the pool at all: the live stores are ~95% coding and
9
+ English, and the corpus's weak buckets (research, data-analysis, ops,
10
+ office-docs) cannot be filled by re-reading the same harness. The
11
+ Nemotron agentic subsets carry tool-call trajectories from other
12
+ harnesses and other domains (SEC filings, customer support, weather),
13
+ which is exactly the coverage the classifier reports as missing.
14
+
15
+ Three normalizations the raw rows need:
16
+
17
+ 1. ``messages`` is a JSON *string* in some subsets (SWE-v3.5) and a list
18
+ in others — both are accepted.
19
+ 2. SWE-v3.5 tool rows carry only ``content``: no ``tool_call_id`` and no
20
+ ``name``. They are paired positionally against the issuing assistant
21
+ turn's calls (verified 1:1 in order on the released shards).
22
+ 3. ``tools`` is a separate column with no counterpart in the Trace
23
+ model, and the export has no field for it either. Dropping it would
24
+ leave tool calls pointing at tools nothing defines, so the schemas are
25
+ folded into the system row as a compact block (name, short
26
+ description, parameter names) — capped, because SWE-v3.5 ships 33 KB
27
+ of JSON schema per row and the OpenCode system prompt already
28
+ describes the tools in prose.
29
+
30
+ Provenance is carried twice: ``source`` (``nemotron-swe`` …) for the
31
+ benchmark's ``by_source`` grouping, and the session-id namespace
32
+ (``nemotron-swe-<uuid>``) because the improve stage overwrites ``source``
33
+ with ``traceforge-splice`` while the id survives verbatim.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import hashlib
38
+ import json
39
+ import os
40
+ from typing import Any, Optional
41
+
42
+ from traceforge.readers._common import bump, new_skips, oversize_row
43
+ from traceforge.trace import VALID_ROLES, Message, ToolCall, Trace
44
+
45
+ # Subset -> provenance tag. The tag is the reader's contract with the
46
+ # benchmark: `by_source` groups on it, so it must stay stable.
47
+ SUBSET_SOURCES = {
48
+ "swe": "nemotron-swe",
49
+ "agentic-tc": "nemotron-agentic-tc",
50
+ "agentic-ka": "nemotron-agentic-ka",
51
+ }
52
+
53
+ # Cap on the folded tool-schema block. SWE-v3.5 ships 33 KB of JSON schema
54
+ # per row; the OpenCode system prompt already names and describes every
55
+ # tool, so the block only has to make the calls resolvable.
56
+ TOOLS_BLOCK_MAX_CHARS = 4000
57
+
58
+
59
+ # --------------------------------------------------------------- row parsing
60
+
61
+ def as_list(value: Any) -> Optional[list]:
62
+ """A JSON list, whether the column stores a list or a JSON string."""
63
+ if isinstance(value, list):
64
+ return value
65
+ if isinstance(value, str):
66
+ try:
67
+ got = json.loads(value)
68
+ except json.JSONDecodeError:
69
+ return None
70
+ return got if isinstance(got, list) else None
71
+ return None
72
+
73
+
74
+ def content_text(value: Any) -> str:
75
+ """Message content as text.
76
+
77
+ OpenAI-style content may be a string or a list of parts
78
+ (``{"type": "text", "text": …}``); the datasets use strings, but a
79
+ part list must not silently become ``str(list)``.
80
+ """
81
+ if value is None:
82
+ return ""
83
+ if isinstance(value, str):
84
+ return value
85
+ if isinstance(value, list):
86
+ out = []
87
+ for part in value:
88
+ if isinstance(part, str):
89
+ out.append(part)
90
+ elif isinstance(part, dict):
91
+ out.append(str(part.get("text") or part.get("content") or ""))
92
+ return "\n".join(p for p in out if p)
93
+ return str(value)
94
+
95
+
96
+ def _arguments_text(value: Any) -> str:
97
+ """Tool-call arguments as the JSON string the Trace model stores."""
98
+ if value is None:
99
+ return "{}"
100
+ if isinstance(value, str):
101
+ return value
102
+ try:
103
+ return json.dumps(value, ensure_ascii=False)
104
+ except (TypeError, ValueError):
105
+ return "{}"
106
+
107
+
108
+ def tools_block(tools: Optional[list], max_chars: int = TOOLS_BLOCK_MAX_CHARS) -> str:
109
+ """Compact ``## Available tools`` block for the system row.
110
+
111
+ Name + first line of the description + parameter names. Full JSON
112
+ schemas are deliberately not reproduced: they are 33 KB on SWE-v3.5
113
+ and the arguments in the trace are what the trainee has to match.
114
+ """
115
+ if not tools:
116
+ return ""
117
+ lines = ["## Available tools", ""]
118
+ for t in tools:
119
+ if not isinstance(t, dict):
120
+ continue
121
+ fn = t.get("function") if isinstance(t.get("function"), dict) else t
122
+ if not isinstance(fn, dict):
123
+ continue
124
+ name = str(fn.get("name") or "").strip()
125
+ if not name:
126
+ continue
127
+ desc = str(fn.get("description") or "").strip().split("\n")[0][:160]
128
+ params = fn.get("parameters") or {}
129
+ props = params.get("properties") if isinstance(params, dict) else None
130
+ arg_names = ", ".join(props.keys()) if isinstance(props, dict) else ""
131
+ required = params.get("required") if isinstance(params, dict) else None
132
+ req = ", ".join(str(r) for r in required) if isinstance(required, list) else ""
133
+ line = f"- `{name}`"
134
+ if desc:
135
+ line += f": {desc}"
136
+ if arg_names:
137
+ line += f" (args: {arg_names}"
138
+ line += f"; required: {req})" if req else ")"
139
+ lines.append(line)
140
+ if sum(len(x) for x in lines) > max_chars:
141
+ lines.append("- … (further tool schemas omitted)")
142
+ break
143
+ return "\n".join(lines)
144
+
145
+
146
+ def _normalize_calls(raw_calls: list, skips, counter: list[int]) -> list[ToolCall]:
147
+ """Assistant tool calls -> ToolCall list, synthesizing missing ids.
148
+
149
+ ``counter`` is a one-element list shared across the whole row: SWE-v3.5
150
+ ships no call ids at all, and a per-message ``call_0`` would collide
151
+ across turns (the export pairs results by id).
152
+ """
153
+ out: list[ToolCall] = []
154
+ for tc in raw_calls:
155
+ if not isinstance(tc, dict):
156
+ continue
157
+ fn = tc.get("function") if isinstance(tc.get("function"), dict) else tc
158
+ if not isinstance(fn, dict):
159
+ continue
160
+ name = str(fn.get("name") or "").strip()
161
+ if not name:
162
+ bump(skips, "tool_call_without_name")
163
+ continue
164
+ call_id = str(tc.get("id") or tc.get("call_id") or "").strip()
165
+ if not call_id:
166
+ call_id = f"call_{counter[0]}"
167
+ counter[0] += 1
168
+ out.append(
169
+ ToolCall(
170
+ id=call_id,
171
+ name=name,
172
+ arguments=_arguments_text(fn.get("arguments")),
173
+ )
174
+ )
175
+ return out
176
+
177
+
178
+ def _title_from(text: str) -> Optional[str]:
179
+ """First substantive line of the task, for the listing.
180
+
181
+ SWE-v3.5 opens with an ``<uploaded_files>`` envelope whose next line is
182
+ a bare ``/workspace/repo`` path, so neither the literal first line nor
183
+ the first non-tag line is a title. Skip markup, bare paths and short
184
+ fragments; fall back to the first non-empty line.
185
+ """
186
+ fallback: Optional[str] = None
187
+ for line in (text or "").splitlines():
188
+ s = line.strip().strip('"').strip()
189
+ if not s:
190
+ continue
191
+ if fallback is None:
192
+ fallback = s[:80]
193
+ if s.startswith("<") or s.startswith("```"):
194
+ continue
195
+ if " " not in s and ("/" in s or "\\" in s):
196
+ continue # a bare path, not prose
197
+ if len(s) < 20 or len(s.split()) < 3:
198
+ continue
199
+ return s[:80]
200
+ return fallback
201
+
202
+
203
+ def normalize_row(
204
+ row: dict,
205
+ subset: str,
206
+ *,
207
+ skips=None,
208
+ embed_tools: bool = True,
209
+ ) -> Optional[Trace]:
210
+ """One dataset row -> Trace, or None when the row cannot be a trace.
211
+
212
+ Returns None (and bumps a skip counter) for a row with no messages, no
213
+ user turn, or no assistant turn — those are not improvable traces and
214
+ must not reach the raw pool.
215
+ """
216
+ source = SUBSET_SOURCES.get(subset, f"nemotron-{subset}")
217
+ raw_msgs = as_list(row.get("messages"))
218
+ if not raw_msgs:
219
+ bump(skips, "no_messages")
220
+ return None
221
+
222
+ messages: list[Message] = []
223
+ pending: list[ToolCall] = [] # calls awaiting their result row
224
+ counter = [0] # synthesized call-id counter, shared across the row
225
+ for raw in raw_msgs:
226
+ if not isinstance(raw, dict):
227
+ bump(skips, "non_dict_message")
228
+ continue
229
+ role = str(raw.get("role") or "").strip()
230
+ if role not in VALID_ROLES:
231
+ bump(skips, f"unknown_role:{role or '?'}")
232
+ continue
233
+ text = content_text(raw.get("content"))
234
+ if role == "tool":
235
+ if not pending:
236
+ # Orphan result: no issuing call in this row. Keeping it
237
+ # would fail the pairing gate on every attempt.
238
+ bump(skips, "orphan_tool_result")
239
+ continue
240
+ call = pending.pop(0)
241
+ messages.append(
242
+ Message(
243
+ role="tool",
244
+ content=text,
245
+ tool_call_id=str(raw.get("tool_call_id") or call.id),
246
+ tool_name=str(raw.get("name") or call.name),
247
+ )
248
+ )
249
+ continue
250
+ if role == "assistant":
251
+ calls = _normalize_calls(raw.get("tool_calls") or [], skips, counter)
252
+ reasoning = raw.get("reasoning_content") or raw.get("reasoning")
253
+ if not text and not calls:
254
+ bump(skips, "empty_assistant_row")
255
+ continue
256
+ pending.extend(calls)
257
+ messages.append(
258
+ Message(
259
+ role="assistant",
260
+ content=text or None,
261
+ reasoning=str(reasoning) if reasoning else None,
262
+ tool_calls=calls,
263
+ )
264
+ )
265
+ continue
266
+ if role == "system" and not text.strip():
267
+ bump(skips, "empty_system_row")
268
+ continue
269
+ messages.append(Message(role=role, content=text))
270
+
271
+ if pending:
272
+ # Calls with no result: the row was cut mid-run. Drop the calls so
273
+ # the trace does not carry an unanswered invocation.
274
+ bump(skips, "unanswered_tool_call", len(pending))
275
+ dead = {c.id for c in pending}
276
+ for m in messages:
277
+ if m.role == "assistant" and m.tool_calls:
278
+ m.tool_calls = [c for c in m.tool_calls if c.id not in dead]
279
+
280
+ roles = {m.role for m in messages}
281
+ if "user" not in roles:
282
+ bump(skips, "no_user_message")
283
+ return None
284
+ if "assistant" not in roles:
285
+ bump(skips, "no_assistant_message")
286
+ return None
287
+
288
+ if embed_tools:
289
+ block = tools_block(as_list(row.get("tools")))
290
+ if block:
291
+ if messages and messages[0].role == "system":
292
+ messages[0].content = (messages[0].content or "").rstrip() + "\n\n" + block
293
+ else:
294
+ messages.insert(0, Message(role="system", content=block))
295
+
296
+ uuid = str(row.get("uuid") or "").strip()
297
+ if not uuid:
298
+ uuid = hashlib.sha1(
299
+ json.dumps(row, sort_keys=True, ensure_ascii=False).encode("utf-8")
300
+ ).hexdigest()[:16]
301
+ first_user = next((m.content for m in messages if m.role == "user"), "") or ""
302
+ return Trace(
303
+ session_id=f"{source}-{uuid}",
304
+ title=_title_from(first_user),
305
+ messages=messages,
306
+ source=source,
307
+ )
308
+
309
+
310
+ # ------------------------------------------------------------------- reader
311
+
312
+ class NemotronTraceReader:
313
+ """Reads normalized Nemotron SFT traces from one or more JSONL roots.
314
+
315
+ Each root holds one ``<subset>.jsonl`` per dataset subset, one Trace
316
+ dict per line (written by ``scripts/fetch_nemotron.py``). A byte-offset
317
+ index per file is cached next to the data, so ``load_trace`` seeks
318
+ instead of re-parsing a multi-hundred-MB file.
319
+ """
320
+
321
+ def __init__(self, roots: Optional[list[str]] = None):
322
+ if isinstance(roots, str):
323
+ roots = [roots]
324
+ self.roots = [os.path.abspath(os.path.expanduser(r)) for r in (roots or [])]
325
+ self.skips = new_skips()
326
+
327
+ # ------------------------------------------------------------- indexing
328
+
329
+ def _files(self) -> list[str]:
330
+ out: list[str] = []
331
+ for root in self.roots:
332
+ if not os.path.isdir(root):
333
+ continue
334
+ for name in sorted(os.listdir(root)):
335
+ if name.endswith(".jsonl"):
336
+ out.append(os.path.join(root, name))
337
+ return out
338
+
339
+ def _index_path(self, path: str) -> str:
340
+ root = os.path.dirname(path)
341
+ return os.path.join(root, ".index", os.path.basename(path) + ".idx.json")
342
+
343
+ def _build_index(self, path: str) -> dict[str, Any]:
344
+ """Byte offsets + summary per row, cached on (size, mtime)."""
345
+ try:
346
+ st = os.stat(path)
347
+ except OSError:
348
+ bump(self.skips, "unreadable_file")
349
+ return {"sig": "", "rows": []}
350
+ sig = f"{st.st_size:x}-{int(st.st_mtime):x}"
351
+ cache_path = self._index_path(path)
352
+ try:
353
+ with open(cache_path, "r", encoding="utf-8") as f:
354
+ cached = json.load(f)
355
+ if cached.get("sig") == sig:
356
+ return cached
357
+ except (OSError, json.JSONDecodeError):
358
+ pass
359
+
360
+ rows: list[dict[str, Any]] = []
361
+ with open(path, "rb") as f:
362
+ offset = 0
363
+ for raw in f:
364
+ length = len(raw)
365
+ if raw.strip():
366
+ try:
367
+ d = json.loads(raw.decode("utf-8"))
368
+ except (json.JSONDecodeError, UnicodeDecodeError):
369
+ bump(self.skips, "unparseable_row")
370
+ offset += length
371
+ continue
372
+ msgs = d.get("messages") or []
373
+ rows.append(
374
+ {
375
+ "sid": d.get("session_id"),
376
+ "offset": offset,
377
+ "length": length,
378
+ "n_messages": len(msgs),
379
+ "n_tool_calls": sum(
380
+ len(m.get("tool_calls") or []) for m in msgs
381
+ ),
382
+ "chars": sum(len(m.get("content") or "") for m in msgs),
383
+ "title": d.get("title"),
384
+ "source": d.get("source"),
385
+ "started_at": d.get("started_at"),
386
+ }
387
+ )
388
+ offset += length
389
+ index = {"sig": sig, "rows": rows}
390
+ try:
391
+ os.makedirs(os.path.dirname(cache_path), exist_ok=True)
392
+ tmp = cache_path + ".tmp"
393
+ with open(tmp, "w", encoding="utf-8") as f:
394
+ json.dump(index, f)
395
+ os.replace(tmp, cache_path)
396
+ except OSError:
397
+ pass # a read-only seed dir still works, just slower
398
+ return index
399
+
400
+ def _index(self) -> dict[str, dict[str, Any]]:
401
+ """session id -> index entry (with its file path)."""
402
+ out: dict[str, dict[str, Any]] = {}
403
+ for path in self._files():
404
+ for row in self._build_index(path).get("rows", []):
405
+ sid = row.get("sid")
406
+ if not sid:
407
+ bump(self.skips, "row_without_session_id")
408
+ continue
409
+ entry = dict(row)
410
+ entry["path"] = path
411
+ out[sid] = entry
412
+ return out
413
+
414
+ # ------------------------------------------------------------ sessions
415
+
416
+ def list_sessions(
417
+ self,
418
+ *,
419
+ min_messages: int = 10,
420
+ max_messages: int = 120,
421
+ min_tool_calls: int = 2,
422
+ limit: Optional[int] = None,
423
+ status_cb=None,
424
+ **_ignored,
425
+ ) -> list[dict[str, Any]]:
426
+ """Candidate traces, newest first (summaries only).
427
+
428
+ ``max_messages`` is the same SOFT cap the other readers use: an
429
+ over-cap row is listed with ``oversize`` and bounded later by
430
+ ``split_long``. ``min_messages`` / ``min_tool_calls`` stay hard.
431
+ """
432
+ if status_cb:
433
+ status_cb(f"indexing {len(self._files())} nemotron file(s)")
434
+ rows: list[dict[str, Any]] = []
435
+ for sid, e in self._index().items():
436
+ if e["n_messages"] < min_messages:
437
+ bump(self.skips, "below_min_messages")
438
+ continue
439
+ if e["n_tool_calls"] < min_tool_calls:
440
+ bump(self.skips, "below_min_tool_calls")
441
+ continue
442
+ rows.append(
443
+ oversize_row(
444
+ {
445
+ "session_id": sid,
446
+ "model": None,
447
+ "title": e.get("title") or "",
448
+ "started_at": e.get("started_at"),
449
+ "ended_at": None,
450
+ "message_count": e["n_messages"],
451
+ "tool_call_count": e["n_tool_calls"],
452
+ "input_tokens": None,
453
+ "output_tokens": None,
454
+ "cwd": None,
455
+ "source": e.get("source") or "nemotron",
456
+ },
457
+ max_messages,
458
+ )
459
+ )
460
+ rows.sort(key=lambda r: (r["started_at"] or 0.0, r["session_id"]), reverse=True)
461
+ if limit is not None:
462
+ rows = rows[:limit]
463
+ return rows
464
+
465
+ # ------------------------------------------------------------- loading
466
+
467
+ def load_trace(self, session_id: str) -> Trace:
468
+ entry = self._index().get(session_id)
469
+ if entry is None:
470
+ raise KeyError(f"session not found: {session_id}")
471
+ with open(entry["path"], "rb") as f:
472
+ f.seek(entry["offset"])
473
+ raw = f.read(entry["length"])
474
+ return Trace.from_dict(json.loads(raw.decode("utf-8")))
475
+
476
+ # --------------------------------------------------------------- stats
477
+
478
+ def stats(self) -> dict[str, Any]:
479
+ files = self._files()
480
+ idx = self._index()
481
+ return {
482
+ "files": len(files),
483
+ "traces": len(idx),
484
+ "messages": sum(e["n_messages"] for e in idx.values()),
485
+ "tool_calls": sum(e["n_tool_calls"] for e in idx.values()),
486
+ "by_source": {
487
+ src: sum(1 for e in idx.values() if e.get("source") == src)
488
+ for src in sorted({e.get("source") or "?" for e in idx.values()})
489
+ },
490
+ "skips": dict(self.skips),
491
+ }
traceforge/space/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Portable traceforge: content-addressed seeds, a queue, and a Gradio UI.
2
+
3
+ The package is import-safe without Gradio — `app.py` imports it lazily, so
4
+ the CLI and the tests never pay for the UI dependency.
5
+
6
+ from traceforge.space import TraceStore, SpacePipeline, seed_hash
7
+ """
8
+ from traceforge.space.hashing import (canonical_messages, fingerprint,
9
+ output_hash, seed_hash, short_hash,
10
+ tool_names)
11
+ from traceforge.space.ingest import parse_records, read_upload, trace_from_record
12
+ from traceforge.space.pipeline import (SpacePipeline, pipeline_ready,
13
+ space_config)
14
+ from traceforge.space.store import (DONE, FAILED, QUEUED, REJECTED, RUNNING,
15
+ TraceStore)
16
+
17
+ __all__ = [
18
+ "TraceStore", "SpacePipeline", "space_config", "pipeline_ready",
19
+ "seed_hash", "output_hash", "short_hash", "fingerprint",
20
+ "canonical_messages", "tool_names", "trace_from_record",
21
+ "parse_records", "read_upload",
22
+ "QUEUED", "RUNNING", "DONE", "FAILED", "REJECTED",
23
+ ]
traceforge/space/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (1.17 kB). View file
 
traceforge/space/__pycache__/hashing.cpython-314.pyc ADDED
Binary file (12.9 kB). View file
 
traceforge/space/__pycache__/ingest.cpython-314.pyc ADDED
Binary file (12.1 kB). View file
 
traceforge/space/__pycache__/pipeline.cpython-314.pyc ADDED
Binary file (15.2 kB). View file
 
traceforge/space/__pycache__/stats.cpython-314.pyc ADDED
Binary file (9.1 kB). View file
 
traceforge/space/__pycache__/store.cpython-314.pyc ADDED
Binary file (23.8 kB). View file
 
traceforge/space/app.py ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio app: submit a trace, sponsor a dataset, read the dashboard.
2
+
3
+ The UI is deliberately thin — every decision lives in `SpacePipeline` and
4
+ `TraceStore`, so the same code runs locally (`python app.py`) and on a
5
+ Hugging Face Space (`app.py` at the repo root).
6
+
7
+ Tabs
8
+ Submit one trace (JSON / JSONL / pasted) -> hash -> queue -> output
9
+ Sponsor a JSONL of traces -> hashed, deduped, queued, attributed
10
+ Dashboard public stats: corpus, time sensitivity, tasks, sponsors, queue
11
+ Browse the stored outputs, by hash
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import html
16
+ import json
17
+ import os
18
+ from typing import Any, Optional
19
+
20
+ from traceforge.space.hashing import short_hash
21
+ from traceforge.space.ingest import parse_records, read_upload
22
+ from traceforge.space.jobs import get_worker
23
+ from traceforge.space.pipeline import SpacePipeline, pipeline_ready, space_config
24
+ from traceforge.space.stats import bar_rows, dashboard, pct
25
+ from traceforge.space.store import DONE, FAILED, QUEUED, RUNNING, TraceStore
26
+
27
+ REFRESH_S = 8.0
28
+
29
+ CSS = """
30
+ .tf-wrap { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
31
+ .tf-kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr));
32
+ gap:8px; margin:6px 0 14px; }
33
+ .tf-kpi { background:#161b22; border:1px solid #2a3140; border-radius:8px;
34
+ padding:9px 11px; }
35
+ .tf-kpi .v { font-size:21px; font-weight:600; color:#e6edf3; line-height:1.2; }
36
+ .tf-kpi .k { font-size:11px; color:#8b949e; text-transform:uppercase;
37
+ letter-spacing:.04em; }
38
+ .tf-kpi .s { font-size:11px; color:#6e7681; }
39
+ .tf-ok { color:#3fb950; } .tf-warn { color:#d29922; } .tf-bad { color:#f85149; }
40
+ .tf-bar-row { display:grid; grid-template-columns:190px 1fr 64px; gap:8px;
41
+ align-items:center; font-size:12px; color:#9aa4b2; margin:3px 0; }
42
+ .tf-bar-outer { background:#1b2029; border-radius:6px; height:14px;
43
+ overflow:hidden; border:1px solid #2a3140; }
44
+ .tf-bar-inner { height:100%; background:linear-gradient(90deg,#2f81f7,#3fb950); }
45
+ .tf-head { font-size:13px; color:#8b949e; margin:2px 0 8px; }
46
+ .tf-pill { padding:2px 8px; border-radius:999px; font-size:11px;
47
+ border:1px solid #2a3140; }
48
+ """
49
+
50
+
51
+ # --------------------------------------------------------------------- html
52
+
53
+ def _esc(s: Any) -> str:
54
+ return html.escape(str(s if s is not None else ""))
55
+
56
+
57
+ def _kpi(value: Any, key: str, sub: str = "", cls: str = "") -> str:
58
+ sub_html = f'<div class="s">{_esc(sub)}</div>' if sub else ""
59
+ return (f'<div class="tf-kpi"><div class="v {cls}">{_esc(value)}</div>'
60
+ f'<div class="k">{_esc(key)}</div>{sub_html}</div>')
61
+
62
+
63
+ def _bars(counts: dict, total: Optional[int] = None) -> str:
64
+ rows = bar_rows(counts, total=total)
65
+ if not rows:
66
+ return '<div class="tf-head">no data yet</div>'
67
+ out = ['<div class="tf-wrap">']
68
+ for label, n, p in rows:
69
+ out.append(
70
+ '<div class="tf-bar-row">'
71
+ f'<span>{_esc(label)}</span>'
72
+ f'<span class="tf-bar-outer"><span class="tf-bar-inner" '
73
+ f'style="width:{max(0.0, min(100.0, p)):.1f}%"></span></span>'
74
+ f'<span>{n} · {p:.0f}%</span></div>'
75
+ )
76
+ out.append("</div>")
77
+ return "".join(out)
78
+
79
+
80
+ def _status_pill(status: str) -> str:
81
+ cls = {DONE: "tf-ok", FAILED: "tf-bad", RUNNING: "tf-warn",
82
+ QUEUED: "tf-warn"}.get(status, "")
83
+ return f'<span class="tf-pill {cls}">{_esc(status)}</span>'
84
+
85
+
86
+ def _kpi_html(space: dict, ref: dict, worker: dict, ready: tuple[bool, str]) -> str:
87
+ ok, reason = ready
88
+ cards = [
89
+ _kpi(f"{space['seeds']:,}", "seeds", "distinct content hashes"),
90
+ _kpi(f"{space['done']:,}", "outputs",
91
+ f"{pct(space['done'], space['seeds']):.0f}% of seeds"),
92
+ _kpi(f"{space['queued']:,}", "queued",
93
+ f"{space['running']} running"),
94
+ _kpi(f"{space['failed']:,}", "failed", "rejected or errored"),
95
+ _kpi(f"{len(space['sponsors'])}", "sponsors",
96
+ f"{sum(s['seeds'] for s in space['sponsors'])} seeds"),
97
+ _kpi(f"{space['duration_s']['median']:.0f}s", "median runtime",
98
+ f"p90 {space['duration_s']['p90']:.0f}s"),
99
+ ]
100
+ if ref.get("available"):
101
+ cards.append(_kpi(f"{ref.get('traces', 0):,}", "reference corpus",
102
+ "published ideal traces"))
103
+ head = (
104
+ '<div class="tf-wrap"><div class="tf-head">'
105
+ f'<span class="tf-pill {"tf-ok" if ok else "tf-bad"}">'
106
+ f'{"pipeline ready" if ok else "pipeline not configured"}</span> '
107
+ f'{_esc(reason)} · worker {"alive" if worker.get("alive") else "stopped"}'
108
+ f' · queue {worker.get("queued", 0)}'
109
+ "</div></div>"
110
+ )
111
+ return head + '<div class="tf-wrap"><div class="tf-kpis">' + "".join(cards) + "</div></div>"
112
+
113
+
114
+ def _ts_html(space: dict, ref: dict) -> str:
115
+ """Time sensitivity: the metric that says whether an answer decays."""
116
+ parts = ['<div class="tf-wrap"><div class="tf-head">'
117
+ "time sensitivity — a temporal fact answered from weights decays; "
118
+ "the same fact fetched with a tool does not</div>"]
119
+ space_ts = space.get("time_sensitivity") or {}
120
+ if space_ts:
121
+ parts.append("<b>this Space</b>")
122
+ parts.append(_bars(space_ts))
123
+ census = (ref or {}).get("time_sensitivity") or {}
124
+ if census.get("available"):
125
+ parts.append(f"<b>reference corpus</b> ({census.get('total', 0):,} traces)")
126
+ parts.append(_bars(census.get("classes") or {}))
127
+ parts.append(
128
+ f'<div class="tf-head">needs rewrite (tool available but never '
129
+ f'called): {census.get("needs_rewrite", 0):,}</div>')
130
+ if not space_ts and not census.get("available"):
131
+ parts.append('<div class="tf-head">no data yet</div>')
132
+ parts.append("</div>")
133
+ return "".join(parts)
134
+
135
+
136
+ # ------------------------------------------------------------------- tables
137
+
138
+ def _rows(records: list[dict], cols: list[str]) -> list[list]:
139
+ out = []
140
+ for r in records:
141
+ row = []
142
+ for c in cols:
143
+ v = r.get(c)
144
+ if c == "hash":
145
+ v = short_hash(v or "")
146
+ elif c == "time_sensitivity":
147
+ v = (v or {}).get("class", "")
148
+ elif isinstance(v, float):
149
+ v = round(v, 1)
150
+ row.append(v if v is not None else "")
151
+ out.append(row)
152
+ return out
153
+
154
+
155
+ # ---------------------------------------------------------------------- app
156
+
157
+ def build_app(store: Optional[TraceStore] = None,
158
+ pipeline: Optional[SpacePipeline] = None):
159
+ import gradio as gr
160
+
161
+ cfg = pipeline.cfg if pipeline else space_config()
162
+ store = store or (pipeline.store if pipeline else TraceStore(
163
+ os.path.join(cfg.data_dir)))
164
+ pipeline = pipeline or SpacePipeline(store, cfg)
165
+ worker = get_worker(pipeline)
166
+ ready = pipeline_ready(cfg)
167
+ dataset_dir = os.path.join(os.path.dirname(cfg.data_dir), "dataset")
168
+ cache_path = os.path.join(store.root, "reference_census.json")
169
+
170
+ # ------------------------------------------------------------- submit
171
+
172
+ def on_submit(file_path, pasted, sponsor, sync):
173
+ if not ready:
174
+ return ("**pipeline not configured** — this Space has no LLM endpoint, "
175
+ "so nothing was queued. Set `TRACEFORGE_BASE_URL` and "
176
+ "`TRACEFORGE_LLM_KEY` in the Space settings and restart.", None, None)
177
+ if file_path:
178
+ records, errors = read_upload(file_path)
179
+ else:
180
+ records, errors = parse_records(pasted or "")
181
+ if errors:
182
+ return (f"**parse errors**\n\n" + "\n".join(f"- {e}" for e in errors[:10]),
183
+ None, None)
184
+ if not records:
185
+ return "**nothing to submit** — upload a file or paste JSON", None, None
186
+ if len(records) > 1:
187
+ return (f"**{len(records)} records** — the Submit tab takes one trace. "
188
+ "Use **Sponsor a dataset** for a batch.", None, None)
189
+ try:
190
+ res = pipeline.submit(records[0], sponsor=sponsor)
191
+ except Exception as e:
192
+ return f"**submit failed**: {e}", None, None
193
+ h = res["hash"]
194
+ if res.get("duplicate") and res.get("status") == DONE:
195
+ path = store.output_path(h)
196
+ return (f"**already processed** — this seed hash already has an output.\n\n"
197
+ f"`{h}`\n\nNo second output was created.", h,
198
+ path if os.path.exists(path) else None)
199
+ if sync:
200
+ res = pipeline.process(h)
201
+ rec = pipeline.result(h)
202
+ md = _result_md(rec)
203
+ path = store.output_path(h) if rec.get("status") == DONE else None
204
+ return md, h, path if path and os.path.exists(path) else None
205
+
206
+ def on_check(h):
207
+ if not h:
208
+ return "no hash yet — submit a trace first", None
209
+ rec = pipeline.result(h)
210
+ path = store.output_path(h) if rec.get("status") == DONE else None
211
+ return _result_md(rec), path if path and os.path.exists(path) else None
212
+
213
+ def _result_md(rec: dict) -> str:
214
+ h = rec.get("hash") or ""
215
+ st = rec.get("status") or "unknown"
216
+ lines = [f"### {_status_pill(st)} `{h}`", ""]
217
+ if rec.get("duplicate"):
218
+ lines.append("_duplicate submission — the existing record was returned_")
219
+ lines.append("")
220
+ if rec.get("task"):
221
+ lines.append(f"- task: **{rec['task']}** · language: **{rec.get('language', '?')}**")
222
+ if rec.get("n_messages"):
223
+ lines.append(f"- seed: {rec['n_messages']} messages · "
224
+ f"{rec.get('n_tool_calls', 0)} tool calls")
225
+ if rec.get("n_messages_out"):
226
+ lines.append(f"- output: {rec['n_messages_out']} messages")
227
+ if rec.get("route"):
228
+ lines.append(f"- route: `{rec['route']}` · attempts: {rec.get('attempts', 0)}")
229
+ if rec.get("duration_s"):
230
+ lines.append(f"- runtime: {rec['duration_s']:.0f}s")
231
+ ts = rec.get("time_sensitivity") or {}
232
+ if ts.get("class"):
233
+ lines.append(f"- time sensitivity: **{ts['class']}** — {ts.get('label', '')}")
234
+ if ts.get("temporal_facts"):
235
+ lines.append(f" - temporal facts: {', '.join(ts['temporal_facts'])}")
236
+ if ts.get("retrieval_available"):
237
+ lines.append(f" - retrieval available: {', '.join(ts['retrieval_available'])}")
238
+ if ts.get("needs_rewrite"):
239
+ lines.append(" - the raw trace answered from weights although a "
240
+ "retrieval tool was available")
241
+ ts2 = rec.get("time_sensitivity_improved") or {}
242
+ if ts2.get("class") and ts2.get("class") != ts.get("class"):
243
+ lines.append(f"- after rewrite: **{ts2['class']}**")
244
+ if rec.get("error"):
245
+ lines.append(f"- error: `{rec['error'][:600]}`")
246
+ if st == QUEUED:
247
+ lines.append("")
248
+ lines.append("_queued — the worker picks it up in submission order. "
249
+ "Press **Check status** to refresh._")
250
+ return "\n".join(lines)
251
+
252
+ # ------------------------------------------------------------ sponsor
253
+
254
+ def on_sponsor(file_path, pasted, sponsor):
255
+ if not ready:
256
+ return ("**pipeline not configured** — this Space has no LLM endpoint, "
257
+ "so nothing was queued. Set `TRACEFORGE_BASE_URL` and "
258
+ "`TRACEFORGE_LLM_KEY` in the Space settings and restart.", [], "")
259
+ if file_path:
260
+ records, errors = read_upload(file_path)
261
+ else:
262
+ records, errors = parse_records(pasted or "")
263
+ if errors:
264
+ return (f"**parse errors**\n\n" + "\n".join(f"- {e}" for e in errors[:10]),
265
+ [], "")
266
+ if not records:
267
+ return "**nothing to sponsor** — upload a JSONL of traces", [], ""
268
+ results = pipeline.submit_many(records, sponsor=sponsor)
269
+ new = sum(1 for r in results if not r.get("duplicate") and r.get("hash"))
270
+ dup = sum(1 for r in results if r.get("duplicate"))
271
+ err = sum(1 for r in results if not r.get("hash"))
272
+ rows = []
273
+ for r in results:
274
+ rows.append([
275
+ short_hash(r.get("hash") or ""),
276
+ r.get("status") or "error",
277
+ "duplicate" if r.get("duplicate") else ("new" if r.get("hash") else "error"),
278
+ r.get("n_messages", ""),
279
+ r.get("task", "")[:60] if r.get("task") else "",
280
+ (r.get("time_sensitivity") or {}).get("class", ""),
281
+ (r.get("error") or "")[:120],
282
+ ])
283
+ md = (f"**{len(records)} records** — {new} new, {dup} already known, "
284
+ f"{err} rejected. Sponsor: **{_esc(sponsor or 'anonymous')}**.\n\n"
285
+ "New seeds are queued; each one produces at most one output.")
286
+ return md, rows, sponsor or "anonymous"
287
+
288
+ # ---------------------------------------------------------- dashboard
289
+
290
+ def on_dashboard():
291
+ d = dashboard(store, dataset_dir=dataset_dir, cache_path=cache_path)
292
+ space, ref = d["space"], d["reference"]
293
+ w = worker.status()
294
+ kpis = _kpi_html(space, ref, w, ready)
295
+ ts = _ts_html(space, ref)
296
+ tasks = bar_rows(space.get("tasks") or {})
297
+ langs = bar_rows(space.get("languages") or {})
298
+ sponsors = [[s["sponsor"], s["seeds"], s["done"], s["failed"]]
299
+ for s in space.get("sponsors") or []]
300
+ recent = _rows(d["recent"], ["hash", "status", "sponsor", "task",
301
+ "time_sensitivity", "duration_s"])
302
+ queue = _rows(d["queue"], ["hash", "sponsor", "task", "n_messages"])
303
+ ref_rows = []
304
+ if ref.get("available"):
305
+ card = ref.get("card") or {}
306
+ for k, v in (card.get("tasks") or {}).items():
307
+ ref_rows.append([k, v, pct(v, card.get("traces", 0))])
308
+ return kpis, ts, tasks, langs, sponsors, recent, queue, ref_rows
309
+
310
+ # ------------------------------------------------------------- browse
311
+
312
+ def browse_choices():
313
+ done = [r for r in store.all() if r.get("status") == DONE]
314
+ done.sort(key=lambda r: r.get("updated_at") or 0, reverse=True)
315
+ return gr.update(choices=[r["hash"] for r in done][:500])
316
+
317
+ def on_browse(h):
318
+ if not h:
319
+ return None, None
320
+ out = store.load_output(h)
321
+ if out is None:
322
+ return None, None
323
+ return out.to_dict(), store.output_path(h)
324
+
325
+ # ---------------------------------------------------------------- UI
326
+
327
+ with gr.Blocks(title="traceforge — ideal-world agent traces") as app:
328
+ gr.Markdown(
329
+ "# traceforge\n"
330
+ "Turn a real agent trace into its **ideal-world reference**: the same "
331
+ "work, with the dead ends removed, gated for structure and grounding.\n\n"
332
+ "Every seed is identified by a **content hash** — submitting the same "
333
+ "trace twice returns the existing output instead of creating a second one."
334
+ )
335
+
336
+ with gr.Tab("Submit a trace"):
337
+ with gr.Row():
338
+ with gr.Column(scale=3):
339
+ file_in = gr.File(label="trace file (.json / .jsonl)",
340
+ file_types=[".json", ".jsonl"],
341
+ type="filepath")
342
+ paste_in = gr.Textbox(
343
+ label="…or paste a trace (JSON, JSONL, or a message list)",
344
+ lines=10, placeholder='{"messages": [{"role": "user", ...}]}')
345
+ with gr.Column(scale=2):
346
+ sponsor_in = gr.Textbox(label="your name / handle (optional)",
347
+ max_lines=1)
348
+ sync_in = gr.Checkbox(
349
+ label="process synchronously (debug — blocks until done)",
350
+ value=os.environ.get("TRACEFORGE_SPACE_SYNC") == "1")
351
+ btn_submit = gr.Button("Submit", variant="primary")
352
+ btn_check = gr.Button("Check status")
353
+ last_hash = gr.State("")
354
+ status_md = gr.Markdown("_no submission yet_")
355
+ out_file = gr.File(label="improved trace (download)", interactive=False)
356
+
357
+ btn_submit.click(on_submit,
358
+ inputs=[file_in, paste_in, sponsor_in, sync_in],
359
+ outputs=[status_md, last_hash, out_file])
360
+ btn_check.click(on_check, inputs=[last_hash],
361
+ outputs=[status_md, out_file])
362
+
363
+ with gr.Tab("Sponsor a dataset"):
364
+ gr.Markdown(
365
+ "Upload a JSONL of traces. Each row is hashed; **new** seeds are "
366
+ "queued for the pipeline and attributed to you, **known** seeds are "
367
+ "skipped. The processed rows join the public output dataset."
368
+ )
369
+ with gr.Row():
370
+ with gr.Column(scale=3):
371
+ sp_file = gr.File(label="dataset (.jsonl)", file_types=[".jsonl"],
372
+ type="filepath")
373
+ sp_paste = gr.Textbox(label="…or paste JSONL", lines=8)
374
+ with gr.Column(scale=2):
375
+ sp_name = gr.Textbox(label="sponsor name", max_lines=1)
376
+ sp_btn = gr.Button("Sponsor", variant="primary")
377
+ sp_md = gr.Markdown("_no dataset sponsored yet_")
378
+ sp_table = gr.Dataframe(
379
+ headers=["hash", "status", "new?", "messages", "task",
380
+ "time sensitivity", "error"],
381
+ interactive=False, wrap=True, max_height=420,
382
+ )
383
+ sp_credit = gr.State("")
384
+ sp_btn.click(on_sponsor, inputs=[sp_file, sp_paste, sp_name],
385
+ outputs=[sp_md, sp_table, sp_credit])
386
+
387
+ with gr.Tab("Dashboard"):
388
+ gr.Markdown("Public statistics. Refreshes automatically.")
389
+ dash_kpis = gr.HTML()
390
+ dash_ts = gr.HTML()
391
+ with gr.Row():
392
+ with gr.Column():
393
+ gr.Markdown("### task mix (this Space)")
394
+ dash_tasks = gr.Dataframe(
395
+ headers=["task", "seeds", "pct"], interactive=False,
396
+ wrap=True, max_height=300)
397
+ with gr.Column():
398
+ gr.Markdown("### languages")
399
+ dash_langs = gr.Dataframe(
400
+ headers=["language", "seeds", "pct"], interactive=False,
401
+ wrap=True, max_height=300)
402
+ with gr.Row():
403
+ with gr.Column():
404
+ gr.Markdown("### sponsors")
405
+ dash_sponsors = gr.Dataframe(
406
+ headers=["sponsor", "seeds", "done", "failed"],
407
+ interactive=False, wrap=True, max_height=300)
408
+ with gr.Column():
409
+ gr.Markdown("### reference corpus — task mix")
410
+ dash_ref = gr.Dataframe(
411
+ headers=["task", "traces", "pct"], interactive=False,
412
+ wrap=True, max_height=300)
413
+ gr.Markdown("### recent outputs")
414
+ dash_recent = gr.Dataframe(
415
+ headers=["hash", "status", "sponsor", "task",
416
+ "time sensitivity", "runtime s"],
417
+ interactive=False, wrap=True, max_height=320)
418
+ gr.Markdown("### queue")
419
+ dash_queue = gr.Dataframe(
420
+ headers=["hash", "sponsor", "task", "messages"],
421
+ interactive=False, wrap=True, max_height=240)
422
+
423
+ dash_outputs = [dash_kpis, dash_ts, dash_tasks, dash_langs,
424
+ dash_sponsors, dash_recent, dash_queue, dash_ref]
425
+ timer = gr.Timer(REFRESH_S)
426
+ timer.tick(on_dashboard, outputs=dash_outputs)
427
+ app.load(on_dashboard, outputs=dash_outputs)
428
+
429
+ with gr.Tab("Browse outputs"):
430
+ with gr.Row():
431
+ pick = gr.Dropdown(label="processed seed (by hash)", choices=[],
432
+ scale=4)
433
+ btn_reload = gr.Button("Reload list", scale=1)
434
+ preview = gr.JSON(label="improved trace", max_height=520)
435
+ dl = gr.File(label="download", interactive=False)
436
+ btn_reload.click(browse_choices, outputs=[pick])
437
+ pick.change(on_browse, inputs=[pick], outputs=[preview, dl])
438
+ app.load(browse_choices, outputs=[pick])
439
+
440
+ return app
441
+
442
+
443
+ def main(argv: Optional[list[str]] = None) -> int:
444
+ import argparse
445
+
446
+ import gradio as gr
447
+
448
+ ap = argparse.ArgumentParser(description="traceforge Gradio app")
449
+ ap.add_argument("--host", default=os.environ.get("GRADIO_SERVER_NAME",
450
+ "127.0.0.1"))
451
+ ap.add_argument("--port", type=int,
452
+ default=int(os.environ.get("GRADIO_SERVER_PORT", "7860")))
453
+ ap.add_argument("--share", action="store_true")
454
+ args = ap.parse_args(argv)
455
+
456
+ app = build_app()
457
+ app.queue(default_concurrency_limit=4)
458
+ app.launch(server_name=args.host, server_port=args.port,
459
+ share=args.share, inbrowser=False, show_error=True,
460
+ css=CSS, theme=gr.themes.Base())
461
+ return 0
462
+
463
+
464
+ if __name__ == "__main__":
465
+ raise SystemExit(main())
traceforge/space/hashing.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Content-addressed identity for a seed trace.
2
+
3
+ The Space must never turn one seed trace into two outputs. The key is a
4
+ hash over the *conversation content*, not over the harness metadata:
5
+
6
+ * session id, model name, cwd, timestamps and tool-call ids are excluded —
7
+ the same conversation exported twice by two harnesses is one seed;
8
+ * message text is normalised (line endings, trailing spaces, blank runs)
9
+ so a re-indent or a CRLF conversion does not mint a new seed;
10
+ * tool-call arguments are canonicalised as JSON (sorted keys, no spaces)
11
+ so `{"a": 1, "b": 2}` and `{"b":2,"a":1}` are the same call;
12
+ * the AVAILABLE tool names are included. A trace where the agent had
13
+ `web_search` and one where it did not are different seeds: the first is
14
+ time-anchored, the second is time-sensitive, and they must not collapse
15
+ into one output.
16
+
17
+ from traceforge.space.hashing import seed_hash, fingerprint
18
+ seed_hash(trace) # 64-char sha256
19
+ fingerprint(trace) # hash + the shape the dashboard reports
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import re
26
+ from typing import Any, Optional
27
+
28
+ # Roles the canonical form keeps. Anything else is harness noise.
29
+ _KEEP_ROLES = ("system", "user", "assistant", "tool")
30
+
31
+ _BLANK_RUN_RE = re.compile(r"\n{3,}")
32
+
33
+
34
+ # ------------------------------------------------------------------ helpers
35
+
36
+ def _messages(obj: Any) -> list[dict]:
37
+ """Accept a Trace, an exported record, or a bare message list."""
38
+ if isinstance(obj, list):
39
+ return [m for m in obj if isinstance(m, dict)]
40
+ if hasattr(obj, "messages"):
41
+ return [m.to_dict() if hasattr(m, "to_dict") else m for m in obj.messages]
42
+ if isinstance(obj, dict):
43
+ msgs = obj.get("messages") or []
44
+ return [m for m in msgs if isinstance(m, dict)]
45
+ return []
46
+
47
+
48
+ def _norm_text(text: Any) -> str:
49
+ """Whitespace-normalise message text without changing its words."""
50
+ if text is None:
51
+ return ""
52
+ if not isinstance(text, str):
53
+ # OpenAI content parts: [{"type": "text", "text": "..."}]
54
+ if isinstance(text, list):
55
+ parts = []
56
+ for p in text:
57
+ if isinstance(p, dict):
58
+ parts.append(str(p.get("text") or p.get("content") or ""))
59
+ else:
60
+ parts.append(str(p))
61
+ text = "\n".join(parts)
62
+ else:
63
+ text = str(text)
64
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
65
+ text = "\n".join(line.rstrip() for line in text.split("\n"))
66
+ text = _BLANK_RUN_RE.sub("\n\n", text)
67
+ return text.strip()
68
+
69
+
70
+ def _canon_arguments(raw: Any) -> str:
71
+ """Canonical JSON for a tool call's arguments.
72
+
73
+ Accepts the OpenAI string form, a mapping, or a malformed blob. A blob
74
+ that does not parse is normalised as text — it is still part of the
75
+ seed's identity, just not comparable field-by-field.
76
+ """
77
+ if isinstance(raw, (dict, list)):
78
+ return json.dumps(raw, sort_keys=True, separators=(",", ":"),
79
+ ensure_ascii=False)
80
+ s = (raw or "").strip()
81
+ if not s:
82
+ return "{}"
83
+ try:
84
+ return json.dumps(json.loads(s), sort_keys=True,
85
+ separators=(",", ":"), ensure_ascii=False)
86
+ except (ValueError, TypeError):
87
+ return _norm_text(s)
88
+
89
+
90
+ def _tool_call(tc: Any) -> Optional[dict]:
91
+ """Normalise one tool call from either schema.
92
+
93
+ Flat (traceforge): {id, name, arguments}
94
+ OpenAI: {id, type: "function", function: {name, arguments}}
95
+ """
96
+ if not isinstance(tc, dict):
97
+ return None
98
+ fn_raw = tc.get("function")
99
+ fn: dict = fn_raw if isinstance(fn_raw, dict) else {}
100
+ name = (fn.get("name") or tc.get("name") or "").strip()
101
+ if not name:
102
+ return None
103
+ args = fn.get("arguments") if fn else tc.get("arguments")
104
+ return {"name": name, "arguments": _canon_arguments(args)}
105
+
106
+
107
+ def tool_names(obj: Any) -> list[str]:
108
+ """Names of the tools the harness made available, sorted.
109
+
110
+ Only a record with a `tools` array can answer this; a bare Trace cannot
111
+ (the model was never told which tools existed). An empty list is the
112
+ honest answer, not a claim that no tools were available.
113
+ """
114
+ if not isinstance(obj, dict):
115
+ return []
116
+ names: set[str] = set()
117
+ for t in obj.get("tools") or []:
118
+ if not isinstance(t, dict):
119
+ continue
120
+ fn = t.get("function") if isinstance(t.get("function"), dict) else {}
121
+ name = (fn.get("name") or t.get("name") or "").strip()
122
+ if name:
123
+ names.add(name)
124
+ return sorted(names)
125
+
126
+
127
+ # --------------------------------------------------------------- canonical
128
+
129
+ def canonical_messages(obj: Any) -> list[dict]:
130
+ """The conversation as a stable, metadata-free structure."""
131
+ out: list[dict] = []
132
+ for m in _messages(obj):
133
+ role = str(m.get("role") or "").strip().lower()
134
+ if role not in _KEEP_ROLES:
135
+ continue
136
+ rec: dict[str, Any] = {"role": role}
137
+ content = _norm_text(m.get("content"))
138
+ if content:
139
+ rec["content"] = content
140
+ calls = [c for c in (_tool_call(tc) for tc in (m.get("tool_calls") or []))
141
+ if c]
142
+ if calls:
143
+ rec["tool_calls"] = calls
144
+ if role == "tool":
145
+ name = (m.get("tool_name") or m.get("name") or "").strip()
146
+ if name:
147
+ rec["tool_name"] = name
148
+ # A row with no content and no calls carries nothing.
149
+ if len(rec) == 1:
150
+ continue
151
+ out.append(rec)
152
+ return out
153
+
154
+
155
+ def canonical_payload(obj: Any) -> dict:
156
+ return {
157
+ "messages": canonical_messages(obj),
158
+ "tools": tool_names(obj),
159
+ }
160
+
161
+
162
+ def seed_hash(obj: Any) -> str:
163
+ """Stable sha256 of a seed trace's content. Same content -> same hash."""
164
+ payload = json.dumps(canonical_payload(obj), sort_keys=True,
165
+ separators=(",", ":"), ensure_ascii=False)
166
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
167
+
168
+
169
+ def output_hash(obj: Any) -> str:
170
+ """Hash of an improved trace, for integrity checks on the stored output."""
171
+ return seed_hash(obj)
172
+
173
+
174
+ def short_hash(h: str, n: int = 12) -> str:
175
+ return (h or "")[:n]
176
+
177
+
178
+ # ------------------------------------------------------------- fingerprint
179
+
180
+ def fingerprint(obj: Any) -> dict:
181
+ """Hash plus the shape the dashboard and the index row report."""
182
+ msgs = _messages(obj)
183
+ n_calls = 0
184
+ n_users = 0
185
+ chars = 0
186
+ for m in msgs:
187
+ chars += len(_norm_text(m.get("content")))
188
+ if m.get("role") == "user":
189
+ n_users += 1
190
+ for tc in (m.get("tool_calls") or []):
191
+ n_calls += 1
192
+ fn = tc.get("function") if isinstance(tc.get("function"), dict) else {}
193
+ chars += len(str(fn.get("arguments") or tc.get("arguments") or ""))
194
+ task = ""
195
+ for m in msgs:
196
+ if m.get("role") == "user":
197
+ task = _norm_text(m.get("content"))
198
+ break
199
+ lang = "en"
200
+ try:
201
+ from traceforge.classify import detect_language
202
+ lang = detect_language(task)
203
+ except Exception:
204
+ pass
205
+ return {
206
+ "hash": seed_hash(obj),
207
+ "n_messages": len(msgs),
208
+ "n_tool_calls": n_calls,
209
+ "n_user_turns": n_users,
210
+ "chars": chars,
211
+ "task": task[:400],
212
+ "language": lang,
213
+ "tools": tool_names(obj),
214
+ }
traceforge/space/ingest.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn whatever a user uploads into a `Trace`.
2
+
3
+ Three shapes arrive in practice:
4
+
5
+ 1. traceforge native — `{"session_id", "messages": [...], "source", ...}`
6
+ 2. OpenAI chat JSONL — `{"messages": [...], "tools": [...]}`
7
+ 3. a bare message list — `[{...}, {...}]`
8
+
9
+ The normaliser accepts all three and both tool-call schemas (flat
10
+ `{id,name,arguments}` and OpenAI `{type:"function",function:{...}}`), so a
11
+ user does not have to convert their export before submitting it.
12
+
13
+ `parse_records` is the batch entry point: it reads a JSON file, a JSONL file
14
+ or a pasted blob and yields `(record, trace)` pairs. A malformed row is
15
+ reported, never silently dropped.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ from typing import Any, Iterable, Iterator, Optional
22
+
23
+ from traceforge.trace import Message, ToolCall, Trace
24
+
25
+
26
+ # ------------------------------------------------------------------ helpers
27
+
28
+ def _content_text(content: Any) -> Optional[str]:
29
+ if content is None:
30
+ return None
31
+ if isinstance(content, str):
32
+ return content
33
+ if isinstance(content, list):
34
+ parts: list[str] = []
35
+ for p in content:
36
+ if isinstance(p, dict):
37
+ if p.get("type") in (None, "text", "input_text", "output_text"):
38
+ parts.append(str(p.get("text") or ""))
39
+ elif p.get("type") == "image_url":
40
+ parts.append("[image]")
41
+ else:
42
+ parts.append(str(p))
43
+ return "\n".join(x for x in parts if x)
44
+ return str(content)
45
+
46
+
47
+ def _tool_call(tc: Any, idx: int) -> Optional[ToolCall]:
48
+ if not isinstance(tc, dict):
49
+ return None
50
+ fn_raw = tc.get("function")
51
+ fn: dict = fn_raw if isinstance(fn_raw, dict) else {}
52
+ name = (fn.get("name") or tc.get("name") or "").strip()
53
+ if not name:
54
+ return None
55
+ args = fn.get("arguments") if fn else tc.get("arguments")
56
+ if isinstance(args, (dict, list)):
57
+ args = json.dumps(args, ensure_ascii=False)
58
+ return ToolCall(
59
+ id=str(tc.get("id") or f"call_{idx}"),
60
+ name=name,
61
+ arguments=args if isinstance(args, str) and args.strip() else "{}",
62
+ )
63
+
64
+
65
+ def message_from_dict(d: dict, idx: int = 0) -> Optional[Message]:
66
+ if not isinstance(d, dict):
67
+ return None
68
+ role = str(d.get("role") or "").strip().lower()
69
+ if role not in ("system", "user", "assistant", "tool"):
70
+ return None
71
+ calls = [c for c in (_tool_call(tc, i)
72
+ for i, tc in enumerate(d.get("tool_calls") or [])) if c]
73
+ return Message(
74
+ role=role,
75
+ content=_content_text(d.get("content")),
76
+ reasoning=d.get("reasoning") if isinstance(d.get("reasoning"), str) else None,
77
+ tool_calls=calls,
78
+ tool_call_id=(str(d["tool_call_id"]) if d.get("tool_call_id") is not None
79
+ else None),
80
+ tool_name=(str(d.get("tool_name") or d.get("name") or "") or None),
81
+ timestamp=(float(d["timestamp"]) if isinstance(d.get("timestamp"), (int, float))
82
+ else None),
83
+ )
84
+
85
+
86
+ def trace_from_record(rec: Any, *, fallback_id: str = "upload",
87
+ source: str = "space-upload") -> Trace:
88
+ """Build a Trace from a native record, an OpenAI row, or a message list."""
89
+ if isinstance(rec, list):
90
+ meta: dict = {}
91
+ msgs_raw = rec
92
+ elif isinstance(rec, dict):
93
+ meta = rec
94
+ msgs_raw = rec.get("messages") or []
95
+ else:
96
+ raise ValueError(f"unsupported record type: {type(rec).__name__}")
97
+
98
+ msgs = [m for m in (message_from_dict(d, i)
99
+ for i, d in enumerate(msgs_raw)) if m]
100
+ if not msgs:
101
+ raise ValueError("record has no usable messages")
102
+
103
+ sid = str(meta.get("session_id") or meta.get("id") or fallback_id)
104
+ return Trace(
105
+ session_id=sid,
106
+ model=meta.get("model"),
107
+ title=meta.get("title"),
108
+ started_at=(meta.get("started_at")
109
+ if isinstance(meta.get("started_at"), (int, float)) else None),
110
+ cwd=meta.get("cwd"),
111
+ messages=msgs,
112
+ source=str(meta.get("source") or source),
113
+ improved=bool(meta.get("improved", False)),
114
+ parent_session_id=meta.get("parent_session_id"),
115
+ task=meta.get("task"),
116
+ )
117
+
118
+
119
+ # ------------------------------------------------------------------- input
120
+
121
+ def _iter_jsonl(text: str) -> Iterator[Any]:
122
+ for line in text.splitlines():
123
+ line = line.strip()
124
+ if not line:
125
+ continue
126
+ yield json.loads(line)
127
+
128
+
129
+ def parse_records(text: str) -> tuple[list[Any], list[str]]:
130
+ """Parse a pasted/uploaded blob into records.
131
+
132
+ Accepts a JSON array, a single JSON object, or JSONL. Returns
133
+ `(records, errors)`; a row that fails to parse is reported by line.
134
+ """
135
+ text = (text or "").strip()
136
+ if not text:
137
+ return [], ["empty input"]
138
+ # A single JSON document (object or array) is the common case.
139
+ try:
140
+ doc = json.loads(text)
141
+ if isinstance(doc, list):
142
+ # A bare message list is ONE trace, not N records.
143
+ if doc and all(isinstance(x, dict) and "role" in x for x in doc):
144
+ return [doc], []
145
+ return doc, []
146
+ return [doc], []
147
+ except json.JSONDecodeError:
148
+ pass
149
+ records: list[Any] = []
150
+ errors: list[str] = []
151
+ for i, line in enumerate(text.splitlines(), 1):
152
+ line = line.strip()
153
+ if not line:
154
+ continue
155
+ try:
156
+ records.append(json.loads(line))
157
+ except json.JSONDecodeError as e:
158
+ errors.append(f"line {i}: {e.msg}")
159
+ return records, errors
160
+
161
+
162
+ def read_upload(path: str) -> tuple[list[Any], list[str]]:
163
+ """Read an uploaded file (JSON or JSONL) into records."""
164
+ if not path or not os.path.exists(path):
165
+ return [], [f"file not found: {path}"]
166
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
167
+ return parse_records(f.read())
168
+
169
+
170
+ def iter_traces(records: Iterable[Any], *, source: str = "space-upload"
171
+ ) -> Iterator[tuple[Any, Trace]]:
172
+ """Yield `(record, trace)`; a bad record raises with its index."""
173
+ for i, rec in enumerate(records):
174
+ yield rec, trace_from_record(rec, fallback_id=f"upload-{i}",
175
+ source=source)
traceforge/space/jobs.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Background worker: drain the store's queue without blocking the UI.
2
+
3
+ A Space request must return in seconds, but one trace takes minutes. The
4
+ worker is a single daemon thread (configurable) that claims the oldest
5
+ queued hash, runs the pipeline, and records the outcome. It is safe to
6
+ start twice — the second call is a no-op — and it survives a Gradio
7
+ reload because the queue lives in the store, not in memory.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import threading
13
+ import time
14
+ from typing import Optional, Protocol
15
+
16
+ from traceforge.space.pipeline import SpacePipeline
17
+ from traceforge.space.store import TraceStore
18
+
19
+
20
+ class _Pipeline(Protocol):
21
+ """What the worker needs: a store and a process(hash) -> dict."""
22
+ store: TraceStore
23
+
24
+ def process(self, h: str) -> dict: ...
25
+
26
+
27
+ class Worker:
28
+ def __init__(self, pipeline: _Pipeline, *, poll_s: float = 2.0,
29
+ name: str = "traceforge-space-worker"):
30
+ self.pipeline = pipeline
31
+ self.store: TraceStore = pipeline.store
32
+ self.poll_s = poll_s
33
+ self.name = name
34
+ self._stop = threading.Event()
35
+ self._thread: Optional[threading.Thread] = None
36
+ self._current: Optional[str] = None
37
+ self._last: Optional[dict] = None
38
+ self._started_at: Optional[float] = None
39
+ self._lock = threading.Lock()
40
+
41
+ # ------------------------------------------------------------- control
42
+
43
+ def start(self) -> "Worker":
44
+ with self._lock:
45
+ if self._thread and self._thread.is_alive():
46
+ return self
47
+ self._stop.clear()
48
+ self._thread = threading.Thread(target=self._run, name=self.name,
49
+ daemon=True)
50
+ self._started_at = time.time()
51
+ self._thread.start()
52
+ return self
53
+
54
+ def stop(self, timeout: float = 5.0) -> None:
55
+ self._stop.set()
56
+ t = self._thread
57
+ if t and t.is_alive():
58
+ t.join(timeout=timeout)
59
+
60
+ @property
61
+ def alive(self) -> bool:
62
+ return bool(self._thread and self._thread.is_alive())
63
+
64
+ def status(self) -> dict:
65
+ return {
66
+ "alive": self.alive,
67
+ "current": self._current,
68
+ "last": self._last,
69
+ "uptime_s": (round(time.time() - self._started_at, 1)
70
+ if self._started_at else 0.0),
71
+ "queued": self.store.queue_depth(),
72
+ }
73
+
74
+ # ---------------------------------------------------------------- loop
75
+
76
+ def _run(self) -> None:
77
+ while not self._stop.is_set():
78
+ job = None
79
+ try:
80
+ job = self.store.claim_next()
81
+ except Exception:
82
+ job = None
83
+ if not job:
84
+ self._stop.wait(self.poll_s)
85
+ continue
86
+ h = job["hash"]
87
+ self._current = h
88
+ try:
89
+ self._last = self.pipeline.process(h)
90
+ except Exception as e: # process() is meant not to raise; belt+braces
91
+ self._last = self.store.update(
92
+ h, status="failed", error=f"worker crashed: {e}")
93
+ finally:
94
+ self._current = None
95
+
96
+
97
+ # --------------------------------------------------------------- singleton
98
+
99
+ _WORKER: Optional[Worker] = None
100
+ _WORKER_LOCK = threading.Lock()
101
+
102
+
103
+ def get_worker(pipeline: SpacePipeline, *, autostart: bool = True) -> Worker:
104
+ """One worker per process, started on first use."""
105
+ global _WORKER
106
+ with _WORKER_LOCK:
107
+ if _WORKER is None:
108
+ _WORKER = Worker(pipeline)
109
+ if autostart and not _WORKER.alive:
110
+ _WORKER.start()
111
+ return _WORKER
112
+
113
+
114
+ def worker_count() -> int:
115
+ try:
116
+ return max(1, int(os.environ.get("TRACEFORGE_SPACE_WORKERS", "1")))
117
+ except ValueError:
118
+ return 1
traceforge/space/pipeline.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The pipeline behind the web UI.
2
+
3
+ `SpacePipeline` is the only thing the Gradio app talks to. It wraps the
4
+ windowed splice engine (`improve_trace_splice`) so a trace that arrives as
5
+ JSON — not as a session id in a local store — can be processed:
6
+
7
+ submit(record) hash -> dedupe -> enqueue (idempotent)
8
+ process(hash) run triage + rewrite + gates, store the output
9
+
10
+ Configuration is environment-only, because a Space has no `~/.hermes`:
11
+
12
+ TRACEFORGE_BASE_URL OpenAI-compatible endpoint (…/v1)
13
+ TRACEFORGE_LLM_KEY bearer token
14
+ TRACEFORGE_MODEL writer/judge model id
15
+ TRACEFORGE_TRIAGE_MODEL cheap labeler (default: TRACEFORGE_MODEL)
16
+ TRACEFORGE_JUDGE_MODEL judge override (default: TRACEFORGE_MODEL)
17
+ TRACEFORGE_DATA_DIR store root (default: <repo>/data/space)
18
+
19
+ `split_long` is deliberately OFF: splitting one submitted seed into N
20
+ segments would produce N outputs for one hash, which is exactly what the
21
+ one-seed-one-output rule forbids. The splice engine already windows a long
22
+ trace internally.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import shutil
28
+ import time
29
+ from typing import Any, Optional
30
+
31
+ from traceforge.config import Config, repo_root_from_pkg
32
+ from traceforge.space.hashing import fingerprint, output_hash, seed_hash
33
+ from traceforge.space.ingest import trace_from_record
34
+ from traceforge.space.store import (DONE, FAILED, QUEUED, REJECTED, RUNNING,
35
+ TraceStore)
36
+ from traceforge.trace import Trace
37
+
38
+
39
+ # ------------------------------------------------------------------- config
40
+
41
+ def _env_int(name: str, default: int) -> int:
42
+ try:
43
+ return int(os.environ.get(name, "") or default)
44
+ except ValueError:
45
+ return default
46
+
47
+
48
+ def space_config(data_dir: Optional[str] = None, **overrides: Any) -> Config:
49
+ """A Config that needs no Hermes profile — env vars only."""
50
+ root = repo_root_from_pkg()
51
+ cfg = Config()
52
+ cfg.data_dir = (data_dir or os.environ.get("TRACEFORGE_DATA_DIR")
53
+ or os.path.join(root, "data", "space"))
54
+ cfg.guidelines_path = os.path.join(root, "guidelines",
55
+ "IDEAL_TRACE_GUIDELINES.md")
56
+ cfg.provider = os.environ.get("TRACEFORGE_PROVIDER") or None
57
+ cfg.model = os.environ.get("TRACEFORGE_MODEL") or "qwen-fable-selfhosted"
58
+ cfg.triage_model = (os.environ.get("TRACEFORGE_TRIAGE_MODEL")
59
+ or cfg.model)
60
+ cfg.judge_model = os.environ.get("TRACEFORGE_JUDGE_MODEL") or ""
61
+ cfg.triage_base_url = (os.environ.get("TRACEFORGE_BASE_URL") or "").rstrip("/")
62
+ cfg.triage = True
63
+ cfg.splice = True
64
+ # The Space processes one submitted trace as one unit.
65
+ cfg.split_long = False
66
+ cfg.raw_cache = False
67
+ cfg.verify = False
68
+ cfg.review = False
69
+ cfg.splice_workers = _env_int("TRACEFORGE_SPLICE_WORKERS", 4)
70
+ cfg.triage_workers = _env_int("TRACEFORGE_TRIAGE_WORKERS", 4)
71
+ cfg.rewrite_timeout_s = _env_int("TRACEFORGE_REWRITE_TIMEOUT_S", 300)
72
+ cfg.judge_timeout_s = _env_int("TRACEFORGE_JUDGE_TIMEOUT_S", 300)
73
+ for k, v in overrides.items():
74
+ setattr(cfg, k, v)
75
+ return cfg.resolve(root)
76
+
77
+
78
+ def pipeline_ready(cfg: Config) -> tuple[bool, str]:
79
+ """(ready, reason). A Space without an endpoint must say so, not hang."""
80
+ try:
81
+ from traceforge.triage import _resolve_endpoint
82
+ base, _key, model = _resolve_endpoint(cfg)
83
+ except Exception as e: # RuntimeError from the resolver, or a bad config
84
+ return False, f"no LLM endpoint: {e}"
85
+ if not base:
86
+ return False, "no LLM endpoint configured (TRACEFORGE_BASE_URL)"
87
+ if not model:
88
+ return False, "no model configured (TRACEFORGE_MODEL)"
89
+ return True, f"{model} @ {base}"
90
+
91
+
92
+ # ----------------------------------------------------------------- pipeline
93
+
94
+ class SpacePipeline:
95
+ """Submit / process / report, with the store as the only state."""
96
+
97
+ def __init__(self, store: TraceStore, cfg: Optional[Config] = None,
98
+ *, max_attempts: int = 2):
99
+ self.store = store
100
+ self.cfg = cfg or space_config()
101
+ self.max_attempts = max_attempts
102
+
103
+ # ------------------------------------------------------------- submit
104
+
105
+ def submit(self, record: Any, *, sponsor: str = "",
106
+ source: str = "space-upload") -> dict:
107
+ return self.store.submit(record, sponsor=sponsor, source=source)
108
+
109
+ def submit_many(self, records: list[Any], *, sponsor: str = "",
110
+ source: str = "space-sponsor") -> list[dict]:
111
+ out = []
112
+ for rec in records:
113
+ try:
114
+ out.append(self.submit(rec, sponsor=sponsor, source=source))
115
+ except Exception as e:
116
+ out.append({"hash": "", "status": "error",
117
+ "error": str(e), "duplicate": False})
118
+ return out
119
+
120
+ # ------------------------------------------------------------ process
121
+
122
+ def process(self, h: str) -> dict:
123
+ """Run the pipeline for one queued hash. Never raises."""
124
+ rec = self.store.get(h)
125
+ if not rec:
126
+ return {"hash": h, "status": FAILED, "error": "unknown hash"}
127
+ if rec.get("status") == DONE:
128
+ return {**rec, "skipped": True}
129
+
130
+ sid = f"space-{h[:16]}"
131
+ started = time.time()
132
+ self.store.update(h, status=RUNNING, started_at=started,
133
+ attempts=int(rec.get("attempts") or 0) + 1)
134
+ try:
135
+ record = self.store.load_seed(h)
136
+ trace = trace_from_record(record, fallback_id=sid,
137
+ source="space-upload")
138
+ trace.session_id = sid
139
+ trace.source = "space-upload"
140
+ trace.improved = False
141
+ trace.parent_session_id = None
142
+ except Exception as e:
143
+ return self._fail(h, [f"unparseable seed: {e}"], started)
144
+
145
+ # A fresh attempt must not regate a stale artifact from a previous
146
+ # run (the splice engine's stage-0 fast path would promote it as-is).
147
+ workdir = os.path.join(self.cfg.improver_workdir, sid)
148
+ shutil.rmtree(workdir, ignore_errors=True)
149
+
150
+ try:
151
+ from traceforge.splice import improve_trace_splice
152
+ res = improve_trace_splice(
153
+ self.cfg, trace, max_attempts=self.max_attempts,
154
+ )
155
+ except Exception as e:
156
+ return self._fail(h, [f"pipeline crashed: {e}"], started)
157
+
158
+ if not res.get("ok") or res.get("improved") is None:
159
+ return self._fail(h, list(res.get("errors") or ["no output"]),
160
+ started, route=res.get("route"),
161
+ attempts=res.get("attempts"))
162
+
163
+ improved: Trace = res["improved"]
164
+ improved.session_id = sid
165
+ improved.parent_session_id = sid
166
+ improved.improved = True
167
+ improved.source = "traceforge-space"
168
+
169
+ # The splice engine gates before returning, but a structural check
170
+ # here is cheap and makes the Space's own contract explicit.
171
+ try:
172
+ from traceforge.validate import validate_structural
173
+ structural = validate_structural(improved)
174
+ except Exception as e:
175
+ structural = [f"validator crashed: {e}"]
176
+ if structural:
177
+ return self._fail(h, structural, started, route=res.get("route"),
178
+ attempts=res.get("attempts"))
179
+
180
+ # Task bucket: heuristic only — the Space must not spend an LLM call
181
+ # on a label the dashboard can derive for free.
182
+ try:
183
+ from traceforge.classify import classify_heuristic
184
+ improved.task = classify_heuristic(improved).get("task")
185
+ except Exception:
186
+ pass
187
+
188
+ # Time sensitivity: measured on the RAW record (it may carry the
189
+ # `tools` array, which is what decides anchored vs sensitive) and on
190
+ # the ideal rewrite (did it actually call the retrieval tool?).
191
+ ts_raw = self._time_sensitivity(record)
192
+ ts_out = self._time_sensitivity(improved)
193
+
194
+ self.store.put_output(h, improved)
195
+ duration = time.time() - started
196
+ row = self.store.update(
197
+ h, status=DONE, finished_at=time.time(), duration_s=round(duration, 1),
198
+ output_hash=output_hash(improved),
199
+ route=res.get("route") or "",
200
+ attempts=res.get("attempts") or 0,
201
+ n_messages_out=len(improved.messages),
202
+ task=improved.task or rec.get("task") or "",
203
+ time_sensitivity=ts_raw,
204
+ time_sensitivity_improved=ts_out,
205
+ error="",
206
+ )
207
+ return row
208
+
209
+ def _fail(self, h: str, errors: list[str], started: float, *,
210
+ route: Optional[str] = None, attempts: Any = None) -> dict:
211
+ msg = "; ".join(str(e) for e in errors)[:1200]
212
+ return self.store.update(
213
+ h, status=FAILED, finished_at=time.time(),
214
+ duration_s=round(time.time() - started, 1),
215
+ route=route or "", attempts=attempts or 0, error=msg,
216
+ )
217
+
218
+ @staticmethod
219
+ def _time_sensitivity(obj: Any) -> dict:
220
+ try:
221
+ from traceforge.timesensitivity import detect
222
+ d = detect(obj)
223
+ return {k: d[k] for k in ("class", "label", "temporal_facts",
224
+ "retrieval_available", "retrieval_called",
225
+ "needs_rewrite", "score")}
226
+ except Exception as e:
227
+ return {"class": "", "label": f"unavailable: {e}"}
228
+
229
+ # -------------------------------------------------------------- report
230
+
231
+ def result(self, h: str) -> dict:
232
+ """Everything the UI needs about one hash."""
233
+ rec = self.store.get(h) or {"hash": h, "status": "unknown"}
234
+ out = self.store.load_output(h)
235
+ rec = dict(rec)
236
+ rec["has_output"] = out is not None
237
+ if out is not None:
238
+ rec["output_messages"] = len(out.messages)
239
+ return rec