docs(evals): ship transcript-building scripts + full eval detail in-repo

- scripts/reconstruct_lineage.py: rebuild full uncompacted lineage
  transcripts from a state.db COPY (descendant-tree walk, content-hash
  dedupe, synthetic-artifact strip, system_prompts hash resolution)
- scripts/replay_lineage.py + scripts/build_html_report.py: replay a 500K
  prefix through any checkout's compressor and render before/after
  side-by-side with compaction artifacts color-coded
- README: transcript-building workflow, scoping-tripwire section
- results/SCORECARD-2026-08-15.md: full per-transcript scorecards, all 4
  exam question banks, survival analysis, methodology + caveats
This commit is contained in:
Teknium
2026-08-15 17:40:43 -07:00
parent a2cc867870
commit e2990428e7
5 changed files with 650 additions and 0 deletions

View File

@@ -29,6 +29,39 @@ Transcripts are NOT committed (they contain real session data). Point
`--transcript` at a local file. See `fixtures.py` for the expected shape and
a synthetic-transcript generator used by CI smoke tests.
## Building transcripts from real sessions (`scripts/`)
Compaction rotations mean a single active session rarely exceeds ~300K
tokens, but the *lineage* (parent→children chain) carries the full
uncompacted history. The scripts reconstruct those into eval transcripts:
```bash
# 1. ALWAYS copy the DB first — never point at the live state.db
cp ~/.hermes/state.db /tmp/state_copy.db
# 2. Find big lineages (sessions with parent_session_id form chains), then:
python evals/compaction/scripts/reconstruct_lineage.py \
/tmp/state_copy.db <root_session_id> /tmp/lineage.json
# 3. (optional) Replay a 500K prefix through one checkout's compressor and
# dump before/after for the HTML viewer:
python evals/compaction/scripts/replay_lineage.py <checkout> /tmp/lineage.json out.json 500000
python evals/compaction/scripts/build_html_report.py <runs_dir> report.html
```
`reconstruct_lineage.py` walks the whole descendant tree chronologically,
dedupes rotation-copied rows by content hash, strips synthetic compaction
artifacts (summaries, todo snapshots), and resolves the system prompt through
the `system_prompts` dedup table (sessions only carry a hash). The HTML
report renders before/after transcripts side by side with compaction
artifacts color-coded.
## Region-scoping tripwire
`test_region_scoping.py` plants sentinels in head/middle/tail and asserts the
summarizer's serialized-turns input carries ONLY the middle (compacted)
region in both legacy and lean modes. Run it directly or via pytest.
## Policies
Defined in `policies.py`. Each policy maps to `ContextCompressor` constructor

View File

@@ -51,3 +51,267 @@ lean+recovery 70.0 @ 62K 80.0 @ 41K 43.3 @ 45K 80.0 @ 50K 68.
Ship lean as opt-in (compression.tail_mode: lean, legacy default), harness as
the permanent gate. Iterate prmerge-class recall behind the flag (query
mining, per-epoch anchor windows) before default flip.
## Appendix: full per-transcript detail
### Transcript: sweep
| policy | recall | tokens before | tokens after | compress s |
|---|---|---|---|---|
| lean | 40.0% | 499,625 | 61,567 | 114.9 |
| lean+recovery | 70.0% | 499,625 | 61,792 | 114.8 |
<details><summary>15 exam questions (questions-30b95351c7.json)</summary>
1. **What is the reason given for never using 'git checkout pr-branch -- <file>' on stale branches?**
gold: `the stale file version silently deletes newer main code`
2. **According to the transcript, how much RSS memory does the gateway balloon to every ~2h in the regression reported in issue #81625?**
gold: `~60GB`
3. **Which specific Electron setting is suspected of causing the Windows occlusion freeze in issue #83420?**
gold: `backgroundThrottling`
4. **What exact error message is returned when 'gh pr merge --auto' is attempted on the NousResearch/hermes-agent repository?**
gold: `Auto merge is not allowed for this repository (enablePullRequestAutoMerge)`
5. **What is the specified 'Rule 0' that must be included in a subagent brief?**
gold: `load the skill first`
6. **In the July 2026 title-cluster sweep, what was the title of the missed first submitter PR #35416?**
gold: `add config gate for title generation`
7. **Which file path is noted as containing the #34034/#28149 manifest guard 'test_bundled_plugin_manifests_ship_in_both_wheel_and_sdist'?**
gold: `tests/test_packaging_metadata.py`
8. **What was the result of the 'npm ci' command run in /home/teknium/salv-desktop according to the background process notification?**
gold: `completed normally (exit code 0)`
9. **What was the 'Root Cause A' identified for why 'uv sync --extra all --locked' failed daily in issue #79434?**
gold: `relative exclude-newer makes the committed lock stale every day`
10. **How many tasks are reported as done in the 'fangliquanflq' desktop retry truncation PR #86605?**
gold: `13`
11. **In the 'salv-cron' worktree, what was the exit code when the agent tried to execute a 'BLOCKED (hardline)' command?**
gold: `-1`
12. **What is the full title block text for the technical schematic infographic generated for the Gateway Drain?**
gold: `GATEWAY DRAIN × CRON — SHUTDOWN CONTRACT`
13. **Which PR number's watcher reported '=== ALL GREEN (streak=1, checks=46) ===' at [03:56:19]?**
gold: `82980`
14. **What is the specific Gist ID created for the PR infographic host in the cron cluster?**
gold: `ee33edd5804689243f974536ef7aecb9`
15. **What was the final merge SHA for Cluster D's Trigger-now PR #70638?**
gold: `f9d64b9a9d8b306f64851c1a13869d96ad5d7869`
</details>
<details><summary>15 exam questions (questions-5be475cde0.json)</summary>
1. **What exact command did the agent use to search for open issues related to a specific topic during Phase 1 of the cluster-sweep salvage?**
gold: `gh issue list --search "<topic>" --state open --limit 100 --json number,title`
2. **According to Teknium's design intent, what is the status of 'platform toolsets' in the codebase?**
gold: `platform toolsets are vestigial, never exposed`
3. **During the July sweep, which specific issue's config bridge was found to already exist at the exact line it was claimed to be missing?**
gold: `#32263`
4. **In the Aug 2026 cron-summarizer cluster sweep, which two PR numbers were discovered post-merge as the true first submitters?**
gold: `#60593, #61969`
5. **What is the recommended Git command to find when a specific symbol fix landed on the main branch?**
gold: `git log -S "<symbol>"`
6. **Why did the #39719 salvage silently delete 236 lines of code from cli-config.yaml.example?**
gold: `the stale file version silently deletes newer main code`
7. **What is the rule for salvaging commits with placeholder identities like 'pwn@example.com'?**
gold: `do NOT cherry-pick. Surgical reapply as maintainer-authored commit, Co-authored-by the GitHub PR author`
8. **How should an agent handle a 'gh pr merge' 502 error?**
gold: `retry the same command once after the "Merge already in progress" settles (~45s); check PR state between attempts`
9. **Which two properties shape almost every design decision in Hermes according to the Development Guide?**
gold: `Per-conversation prompt caching is sacred and The core is a narrow waist; capability lives at the edges.`
10. **What error message does the live-checkout git guard display when blocking a history-rewriting command?**
gold: `Blocked: `git <op>` would rewrite Hermes's live source checkout (/home/teknium/.hermes/hermes-agent) and can mix module `
11. **What happened to the Desktop cluster's 'npm ci' command that resulted in an error writing to /tmp/ccH06T4r.s?**
gold: `No space left on device`
12. **What was the GraphQL API rate limit remaining for the user when the 'API rate limit already exceeded' error first occurred?**
gold: `0`
13. **Which PR was identified as the salvage of HexLab98's #85283 to fix hung inline API calls?**
gold: `#86645`
14. **Why did PR #79268 fix invisible overlays in the TUI?**
gold: `renderNodeToOutput skips boxes Yoga squeezes to height 0`
15. **What was the specific ModuleNotFoundError message caused by the wheel subpackage discovery trap in #34701?**
gold: `ModuleNotFoundError: No module named 'hermes_cli.dashboard_auth'`
</details>
### Transcript: gui
| policy | recall | tokens before | tokens after | compress s |
|---|---|---|---|---|
| lean | 60.0% | 499,818 | 41,232 | 118.1 |
| lean+recovery | 80.0% | 499,818 | 41,306 | 115.2 |
<details><summary>15 exam questions (questions-36d3d87e0b.json)</summary>
1. **What is the PR number for the authored fix addressing mid-turn message ordering bugs in Hermes Desktop?**
gold: `#86617`
2. **According to the contribution rubric in AGENTS.md, which type of config belongs in '.env' and which belongs in 'config.yaml'?**
gold: `.env is for secrets only (API keys, tokens, passwords). All behavioral settings... go in config.yaml.`
3. **What specific file and line number were identified as the cause of an AssertionError (assert 56 == 55) in the Python tests?**
gold: `tests/hermes_cli/test_session_recovery_lost_and_found.py:327`
4. **What was the root cause of issue #73793 regarding mid-turn message rendering?**
gold: `redirect/steer paths spliced the mid-turn user bubble BEFORE the active assistant stream row`
5. **Which PR was verified to already be on 'main', resulting in nothing needing to be salvaged for it?**
gold: `#84287`
6. **In the Desktop virtualized-scrolling cluster, what was the fix for issue #79157 (scrollbar unclickable)?**
gold: `pane sash grab band made asymmetric 1px/7px`
7. **Which contributor's email was mapped to 'baihemax' during the attribution audit of PR #86588?**
gold: `602028@ky-tech.com.cn`
8. **What error message does the Hermes terminal tool return when a git command is blocked to prevent rewriting the live source checkout?**
gold: `Blocked: `git <op>` would rewrite Hermes's live source checkout`
9. **What is the core design principle regarding 'Narrow Waist' in Hermes development?**
gold: `The core is a narrow waist; capability lives at the edges.`
10. **What was the result of the rebase-merge attempt for PR #86589?**
gold: `GraphQL: Pull Request has merge conflicts (mergePullRequest)`
11. **In the infographic style picker, what vibe is associated with the 'designers-republic' style?**
gold: `The Designers Republic: flat orange+violet vector schematic on pewter grey`
12. **Why was PR #76286 excluded from the compaction/compression transcript-visibility cluster?**
gold: `conflicts with main in 4 files and introduces a second competing display-dedupe scheme`
13. **What is the 'Provenance note' date for the pr-infographic-workflow.md reference file?**
gold: `May 23 2026`
14. **What specific TypeScript error caused PR #86772 to fail CI linting after a rebase?**
gold: `Property 'onToggleUnread' is missing in type`
15. **According to the Desktop Engineering Guide, who is the authority for process lifecycle and the native filesystem?**
gold: `Electron`
</details>
<details><summary>15 exam questions (questions-9c55c707b6.json)</summary>
1. **What two PR numbers are associated with the 'sidebar-nav-rows-and-overlay-panels.md' and 'hud-mode-internals.md' references in the initial tool content?**
gold: `#85162 and #82285`
2. **According to AGENTS.md, what is the 'one exception' to the rule that nothing should rebuild the system prompt mid-conversation?**
gold: `context compression`
3. **In the Contribution Rubric, what are the three allowed reasons for an automated triage sweeper to close a PR?**
gold: `implemented_on_main, cannot_reproduce, incoherent`
4. **Which contributor is credited with adding the 'Brazilian Portuguese localization' in PR #86292?**
gold: `@gui8515`
5. **What specific error message is reported in issue #83562 regarding the Windows Desktop update?**
gold: `Hermes backend exited (0)`
6. **What is the 'core problem' identified in the parallel-subagent-salvage-orchestration.md reference?**
gold: `subagents share the parent's worktree + main checkout`
7. **Why was the 'nix (macos-latest)' build failing in the salvage batches according to the orchestration reference?**
gold: `Nix build failed due to stale npm lockfile hash`
8. **Which subagent ID was assigned the goal of salvaging the 'inflight-journal duplicate-answer cluster'?**
gold: `sa-2-7318d0ba`
9. **In PR #86595, why was PR #80707 by upperagent excluded from the salvage?**
gold: `violating this PR's UI-read-only invariant`
10. **What was the root cause of the failure in Python tests slice 4/12 for PR #86597?**
gold: `AssertionError: assert 't2' == 't1'`
11. **What did the fix for issue #79157 in PR #86589 involve?**
gold: `pane sash grab band made asymmetric 1px/7px`
12. **According to the root cause analysis for #73793, which two files spliced the mid-turn user message at streamIndex?**
gold: `use-prompt-actions/index.ts and session-tile-actions.ts`
13. **What was the head SHA for the 'salvage/desktop-busy-state' branch in PR #86604?**
gold: `bddadfe9e21e24b3d52e2b15f138c42474dede42`
14. **Why was the merge of PR #86589 aborted during the 'Merge all' command?**
gold: `GraphQL: Pull Request has merge conflicts (mergePullRequest)`
15. **What specific file was modified to fix the 'artifacts page timestamps render 1970' issue via PR #86749?**
gold: `apps/desktop/src/app/session/hooks/use-session-actions/utils.ts`
</details>
### Transcript: prmerge
| policy | recall | tokens before | tokens after | compress s |
|---|---|---|---|---|
| uncompacted_control | 96.7% | 499,663 | 499,663 | — |
| current | 33.3% | 499,663 | 155,399 | 14.9 |
| lean | 23.3% | 499,663 | 44,419 | 105.4 |
| lean+recovery | 43.3% | 499,663 | 44,977 | 95.8 |
<details><summary>15 exam questions (questions-703ae2774a.json)</summary>
1. **Which PR number added the public subagent lifecycle API?**
gold: `#63359`
2. **What is the name of the typed service added to PluginContext for launching and monitoring child sessions?**
gold: `subagent_lifecycle`
3. **How many contract and security tests were included with the subagent lifecycle API PR?**
gold: `42`
4. **What specific gap was identified regarding the `ctx.inject_message()` function in gateway sessions?**
gold: `cannot currently trigger a turn in an existing gateway session`
5. **Which PR implements gateway-safe plugin injection by extending `ctx.inject_message()` with a keyword-only `session_key`?**
gold: `#64436`
6. **What are the two specific constraints placed on redaction patterns in the pattern registry to prevent exposing data?**
gold: `must compile, must start with ≥2 literal characters`
7. **Which contributor authorized sustained help for the Phase 0–1 expansion track?**
gold: `Daniel`
8. **What is the issue number for the disposition gap concerning `pre_command` middleware and MCP tool access?**
gold: `#64204`
9. **What configuration setting is required to opt-in to reasoning deltas in streaming output?**
gold: `plugins.stream_reasoning_deltas: true`
10. **How many additions and across how many files were made in PR #63359?**
gold: `650 additions across 4 files`
11. **What is the name of the reference plugin shipped with the redaction pattern registry?**
gold: `nvapi-redaction`
12. **List the four observer-only streaming output plugin hooks added in PR #64317.**
gold: `on_stream_start, on_stream_delta, on_stream_end, on_interim_message`
13. **What was addressed in the update to PR #58541 regarding lifecycle hooks?**
gold: `created-hook timing and added kanban_task_promoted`
14. **Which sub-issue number is associated with the 'developer tooling' (scaffold + Plugin Doctor + test harness)?**
gold: `#64230`
15. **What was the Round 3 review's outcome for PR #63359 and @asimons81?**
gold: `sub-issue #65447`
</details>
### Transcript: acp
| policy | recall | tokens before | tokens after | compress s |
|---|---|---|---|---|
| uncompacted_control | 100.0% | 498,906 | 498,906 | — |
| current | 30.0% | 498,906 | 160,223 | 15.8 |
| lean | 36.7% | 498,906 | 49,523 | 143.3 |
| lean+recovery | 80.0% | 498,906 | 49,721 | 135.6 |
<details><summary>15 exam questions (questions-f45358df19.json)</summary>
1. **What was the specific reason Teknium gave for reverting PR #30179 in July 2026?**
gold: `WTF??? REVERT! DAMMIT`
2. **On which specific PR did Teknium say, 'tf are you saying to me. Stop giving me such random verbose details'?**
gold: `PR #6391`
3. **Which file path should be checked for the canonical list of provider models?**
gold: `hermes_cli/models.py`
4. **What was the identified bug in PR #2314 regarding provider names?**
gold: `checking for "alibaba-coding-plan"`
5. **What is the mandatory line limit for PR reviews requested by Teknium?**
gold: `<= 15 lines`
6. **What exact error message did the agent receive when attempting to checkout a worktree while in the live source directory?**
gold: `Blocked: `git checkout` would rewrite Hermes's live source checkout (/home/teknium/.hermes/hermes-agent) and can mix mod`
7. **Why was PR #74658 necessary to fix Slack 'broken on main'?**
gold: `SlackResponse isn't a dict subclass, so every gate is always False.`
8. **What was the final merge commit SHA for the Slack SDK response fix on main?**
gold: `24ba86627515ad5fda69a39ef338c365713448bc`
9. **In the 'Pop-laboratory' style infographic for the Auxiliary Client fix, what were the two specific outcomes shown in cell 2?**
gold: `Messages wrapper keeps /anthropic and OpenAI fallback keeps /v1`
10. **What specific SQL update was added to the migration path in hermes_cli/kanban_db.py to prevent losing active wake on upgrade?**
gold: `UPDATE kanban_notify_subs SET delivery_mode = 'notify+wake' WHERE platform != 'tui'`
11. **Which test failed in CI slice 5/12 for the kanban delivery modes PR?**
gold: `tests/gateway/test_kanban_notifier_apiserver_wake.py::test_apiserver_sub_wakes_real_session_via_self_post`
12. **According to the transcript, why is squash merging banned as of July 2026?**
gold: `DevOps policy`
13. **Which contributor authored the first fix for issue #73030 in July?**
gold: `@Tranquil-Flow`
14. **What was the 'Superman-style' shield error in the first generation of the Kanban infographic?**
gold: `red "S" inside the diamond shield`
15. **What specific file was modified to add the 'scope_id_for_chat' method for Slack?**
gold: `plugins/platforms/slack/adapter.py`
</details>
## Methodology notes
- Transcripts: 4 real session lineages reconstructed from a state.db copy
(sweep campaign 42 rotations / GUI desktop 34 / PR-merge 17 / ACP review
17), chronological 500K-token prefix, tool-group aligned.
- Question generation: main model, from the region the CURRENT policy would
summarize (most conservative boundary), cached per transcript so every
policy answers the identical exam.
- Answering: fresh LLM sees ONLY the post-compaction context (closed-book) or
context + one FTS5+BM25 search round-trip over the archived region
(+recovery). Judge sees gold; answerer never does. Scoring 2/1/0.
- Known caveats: 15 questions/transcript => +-1 question ~ 3.3pts noise;
sweep/gui current-policy rows predate a question-bank regeneration
(prmerge/acp are same-bank across all arms); the recovery sim conservatively
approximates production session_search (same engine, no windowing).
- Cost shape: lean compaction = ~25 aux-model digest calls (~2min, one-time
per compaction) vs 1 call today; every post-compaction turn is ~110K input
tokens cheaper. Break-even ~1 turn.

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Build a self-contained HTML report comparing compaction runs.
Usage: build_report.py <runs_dir> <out_html>
Expects runs/<checkout>_<session>.json pairs from run_compaction.py.
"""
import html
import json
import sys
from pathlib import Path
RUNS = Path(sys.argv[1])
OUT = sys.argv[2]
pairs = {}
for f in sorted(RUNS.glob("*.json")):
co, sid = f.stem.split("_", 1)
if co == "main":
co, sid = "main-co", f.stem[len("main-co_"):]
elif co == "pr":
co, sid = "pr-co", f.stem[len("pr-co_"):]
data = json.loads(f.read_text(encoding="utf-8"))
pairs.setdefault(sid, {})[co] = data
E = html.escape
def msg_class(m):
role = m.get("role", "?")
c = m.get("content") or ""
if isinstance(c, str):
if "[CONTEXT COMPACTION" in c or "[CONTEXT SUMMARY" in c:
return "summary"
if "SKILL_PRUNED" in c:
return "skillpruned"
if "SKILL POLICY DIGEST" in c or "SKILL_POLICY_DIGEST" in c:
return "digest"
if "preserved across context compression" in c:
return "todosnap"
return role
def render_msg(m, idx):
role = m.get("role", "?")
c = m.get("content")
if not isinstance(c, str):
c = json.dumps(c, default=str)[:2000]
tool = m.get("tool_name") or ""
tcs = m.get("tool_calls") or []
tc_names = ", ".join(
(t.get("function", {}) or {}).get("name", "?") for t in tcs if isinstance(t, dict)
)
cls = msg_class(m)
nchars = len(c)
label = role
if tool:
label += f" · {tool}"
if tc_names:
label += f" → {tc_names}"
preview = c[:180].replace("\n", " ")
full = c if nchars <= 20000 else c[:20000] + f"\n…[{nchars-20000:,} more chars]"
return (
f'<details class="msg {cls}"><summary><span class="idx">#{idx}</span>'
f'<span class="role">{E(label)}</span>'
f'<span class="chars">{nchars:,}ch</span>'
f'<span class="preview">{E(preview)}</span></summary>'
f"<pre>{E(full)}</pre></details>"
)
def render_column(title, data, key):
meta = data["meta"]
msgs = data[key]
body = "".join(render_msg(m, i) for i, m in enumerate(msgs))
return (
f'<div class="col"><div class="colhead"><h3>{E(title)}</h3>'
f'<div class="stats">{meta[key.replace("before","before_msgs").replace("after","after_msgs")] if False else len(msgs)} msgs · '
f'~{(meta["before_tokens_est"] if key=="before" else meta["after_tokens_est"]):,} tok</div></div>'
f'<div class="msgs">{body}</div></div>'
)
def survival_stats(before, after):
after_texts = set()
for m in after:
c = m.get("content")
if isinstance(c, str) and c:
after_texts.add(c[:400])
kept = sum(1 for m in before if isinstance(m.get("content"), str) and (m.get("content") or "")[:400] in after_texts)
return kept
sections = []
toc = []
for sid, versions in pairs.items():
if "main-co" not in versions or "pr-co" not in versions:
continue
main_d, pr_d = versions["main-co"], versions["pr-co"]
title = main_d["meta"].get("title") or sid
mm, pm = main_d["meta"], pr_d["meta"]
def count_markers(msgs, needle):
return sum((m.get("content") or "").count(needle) for m in msgs if isinstance(m.get("content"), str))
rows = []
def stat(name, mv, pv):
cls = "diff" if mv != pv else ""
rows.append(f"<tr class='{cls}'><td>{E(name)}</td><td>{E(str(mv))}</td><td>{E(str(pv))}</td></tr>")
stat("Messages after", mm["after_msgs"], pm["after_msgs"])
stat("Est. tokens after", f"{mm['after_tokens_est']:,}", f"{pm['after_tokens_est']:,}")
stat("Reduction", f"{100-100*mm['after_tokens_est']//max(1,mm['before_tokens_est'])}%", f"{100-100*pm['after_tokens_est']//max(1,pm['before_tokens_est'])}%")
stat("Compress time", f"{mm['elapsed_s']}s", f"{pm['elapsed_s']}s")
stat("SKILL_PRUNED markers", count_markers(main_d["after"], "SKILL_PRUNED"), count_markers(pr_d["after"], "SKILL_PRUNED"))
stat("Policy digest blocks", count_markers(main_d["after"], "SKILL POLICY DIGEST") + count_markers(main_d["after"], "SKILL_POLICY_DIGEST"), count_markers(pr_d["after"], "SKILL POLICY DIGEST") + count_markers(pr_d["after"], "SKILL_POLICY_DIGEST"))
stat("Todo snapshot present", "yes" if count_markers(main_d["after"], "preserved across context compression") else "no", "yes" if count_markers(pr_d["after"], "preserved across context compression") else "no")
stat("Kept-verbatim msgs", survival_stats(main_d["before"], main_d["after"]), survival_stats(pr_d["before"], pr_d["after"]))
stat("Summary error", mm.get("summary_error") or "—", pm.get("summary_error") or "—")
todo_html = ""
for label, d in (("main", mm), ("PR #87090", pm)):
tb = d.get("todo_injection_block")
if tb:
todo_html += f"<h4>Todo injection block — {E(label)}</h4><pre class='todoblock'>{E(tb)}</pre>"
anchor = f"s-{sid}"
toc.append(f'<a href="#{anchor}">{E(title)} <span class="dim">({sid})</span></a>')
sections.append(f"""
<section id="{anchor}">
<h2>{E(title)} <span class="dim">{sid}</span></h2>
<table class="stats-table"><tr><th></th><th>main</th><th>PR #87090</th></tr>{"".join(rows)}</table>
{todo_html}
<div class="cols">
{render_column("BEFORE (original transcript)", main_d, "before")}
{render_column("AFTER — main", main_d, "after")}
{render_column("AFTER — PR #87090", pr_d, "after")}
</div>
</section>""")
page = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Compaction comparison — main vs PR #87090</title>
<style>
:root {{ color-scheme: dark; }}
body {{ background:#0d1117; color:#c9d1d9; font:14px/1.45 -apple-system,Segoe UI,sans-serif; margin:0; padding:24px; }}
h1 {{ font-size:22px; }} h2 {{ font-size:18px; border-bottom:1px solid #30363d; padding-bottom:6px; margin-top:48px; }}
.dim {{ color:#8b949e; font-weight:normal; font-size:12px; }}
nav a {{ display:block; color:#58a6ff; margin:2px 0; text-decoration:none; }}
.legend span {{ display:inline-block; padding:2px 10px; margin-right:8px; border-radius:4px; font-size:12px; }}
.stats-table {{ border-collapse:collapse; margin:12px 0; }}
.stats-table td, .stats-table th {{ border:1px solid #30363d; padding:4px 12px; text-align:left; font-size:13px; }}
.stats-table tr.diff td {{ background:#1c2a1c; }}
.cols {{ display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; }}
.col {{ min-width:0; }}
.colhead {{ position:sticky; top:0; background:#161b22; padding:8px; border:1px solid #30363d; border-radius:6px 6px 0 0; z-index:2; }}
.colhead h3 {{ margin:0; font-size:13px; }} .colhead .stats {{ color:#8b949e; font-size:12px; }}
.msgs {{ border:1px solid #30363d; border-top:none; max-height:80vh; overflow-y:auto; }}
.msg {{ border-bottom:1px solid #21262d; }}
.msg summary {{ cursor:pointer; padding:3px 6px; display:flex; gap:6px; align-items:baseline; white-space:nowrap; overflow:hidden; }}
.msg summary::-webkit-details-marker {{ display:none; }}
.idx {{ color:#484f58; font-size:11px; min-width:34px; }}
.role {{ font-size:11px; font-weight:600; min-width:110px; overflow:hidden; text-overflow:ellipsis; }}
.chars {{ color:#8b949e; font-size:11px; min-width:52px; }}
.preview {{ color:#8b949e; font-size:11px; overflow:hidden; text-overflow:ellipsis; flex:1; }}
.msg pre {{ white-space:pre-wrap; word-break:break-word; font-size:11px; background:#161b22; margin:0; padding:8px; max-height:400px; overflow-y:auto; }}
.msg.user summary {{ background:#0d2137; }} .msg.user .role {{ color:#58a6ff; }}
.msg.assistant .role {{ color:#d2a8ff; }}
.msg.tool .role {{ color:#7ee787; }}
.msg.system summary {{ background:#21262d; }} .msg.system .role {{ color:#8b949e; }}
.msg.summary summary {{ background:#3d2e00; }} .msg.summary .role {{ color:#e3b341; }}
.msg.skillpruned summary {{ background:#3d1418; }} .msg.skillpruned .role {{ color:#ff7b72; }}
.msg.digest summary {{ background:#1b3d2e; }} .msg.digest .role {{ color:#56d364; }}
.msg.todosnap summary {{ background:#2d1b3d; }} .msg.todosnap .role {{ color:#d2a8ff; }}
.todoblock {{ background:#1b1230; border:1px solid #6e40c9; padding:10px; white-space:pre-wrap; font-size:12px; }}
</style></head><body>
<h1>Compaction comparison — current main (7619564fb) vs PR #87090 (41fd511f6)</h1>
<p class="dim">Real sessions from state.db (copy), replayed through each checkout's ContextCompressor with force=True. Real LLM summaries. Click any row to expand the full message.</p>
<div class="legend">
<span style="background:#3d2e00;color:#e3b341">compaction summary</span>
<span style="background:#3d1418;color:#ff7b72">SKILL_PRUNED marker</span>
<span style="background:#1b3d2e;color:#56d364">policy digest</span>
<span style="background:#2d1b3d;color:#d2a8ff">todo snapshot</span>
<span style="background:#0d2137;color:#58a6ff">user</span>
</div>
<nav>{"".join(toc)}</nav>
{"".join(sections)}
</body></html>"""
Path(OUT).write_text(page, encoding="utf-8")
print(f"wrote {OUT} ({len(page):,} bytes, {len(sections)} sessions)")

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Reconstruct the full uncompacted transcript of a session LINEAGE.
Rotation children start with a copy of the compressed parent (head + summary +
tail). To rebuild the real history: walk the chain root->leaf, append messages
not seen before (hash of role+content+tool_calls), and skip synthetic
compaction summaries / todo snapshots so we get the organic transcript.
Usage: reconstruct_lineage.py <state_db_copy> <root_session_id> <out_json>
ALWAYS run against a COPY of state.db, never the live file.
"""
import hashlib
import json
import sqlite3
import sys
DB = sys.argv[1]
ROOT = sys.argv[2]
OUT = sys.argv[3]
db = sqlite3.connect(DB)
db.row_factory = sqlite3.Row
# collect the whole descendant tree, chronological by started_at
import collections
children = collections.defaultdict(list)
for r in db.execute(
"SELECT id, parent_session_id FROM sessions WHERE parent_session_id IS NOT NULL"
):
children[r["parent_session_id"]].append(r["id"])
chain = []
frontier = [ROOT]
while frontier:
sid = frontier.pop(0)
chain.append(sid)
frontier.extend(children.get(sid, []))
starts = {r["id"]: r["started_at"] or "" for r in db.execute(
f"SELECT id, started_at FROM sessions WHERE id IN ({','.join('?'*len(chain))})", chain)}
chain.sort(key=lambda s: starts.get(s, ""))
print(f"chain: {len(chain)} sessions")
SYNTH_MARKERS = (
"[CONTEXT COMPACTION", "[CONTEXT SUMMARY", "[PRIOR CONTEXT",
"preserved across context compression",
)
seen = set()
out = []
sysprompt = None
for sid in chain:
if sysprompt is None:
row = db.execute(
"SELECT s.system_prompt, sp.prompt AS dedup_prompt FROM sessions s "
"LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash "
"WHERE s.id=?", (sid,)).fetchone()
if row:
sysprompt = row["system_prompt"] or row["dedup_prompt"] or None
for r in db.execute(
"SELECT * FROM messages WHERE session_id=? ORDER BY id", (sid,)
):
c = r["content"] or ""
if any(m in c for m in SYNTH_MARKERS):
continue # synthetic compaction artifact, not organic history
h = hashlib.md5(
(r["role"] + "\x00" + c + "\x00" + (r["tool_calls"] or "")).encode(
"utf-8", "replace")
).hexdigest()
if h in seen:
continue
seen.add(h)
m = {"role": r["role"], "content": c}
if r["tool_calls"]:
try:
m["tool_calls"] = json.loads(r["tool_calls"])
except Exception:
pass
if r["tool_call_id"]:
m["tool_call_id"] = r["tool_call_id"]
if r["tool_name"]:
m["tool_name"] = r["tool_name"]
out.append(m)
msgs = [{"role": "system", "content": sysprompt or ""}] + out
chars = sum(len(m.get("content") or "") + len(json.dumps(m.get("tool_calls", ""), default=str)) for m in msgs)
print(f"reconstructed: {len(msgs)} msgs, {chars:,} chars (~{chars//4:,} tok)")
json.dump({"root": ROOT, "chain": chain, "messages": msgs}, open(OUT, "w", encoding="utf-8"), default=str)
print(f"wrote {OUT}")

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Replay a ~500K-token prefix of a reconstructed lineage through compaction.
Usage: run_lineage_compaction.py <checkout> <lineage_json> <out_json> [cap_tokens]
Takes the chronological prefix of the lineage at the token cap (default 500K =
the 50% trigger on a 1M-context model), aligned to a tool-group boundary, and
runs ContextCompressor.compress() exactly as the live trigger would.
"""
import copy
import json
import sys
import time
from pathlib import Path
CHECKOUT = sys.argv[1]
LINEAGE = sys.argv[2]
OUT = sys.argv[3]
CAP = int(sys.argv[4]) if len(sys.argv) > 4 else 500_000
sys.path.insert(0, CHECKOUT)
data = json.load(open(LINEAGE, encoding="utf-8"))
msgs = data["messages"]
def tok(m):
t = len(m.get("content") or "") // 4
tc = m.get("tool_calls")
if tc:
t += len(json.dumps(tc, default=str)) // 4
return t
# chronological prefix up to CAP tokens
prefix = []
total = 0
for m in msgs:
t = tok(m)
if total + t > CAP and len(prefix) > 10:
break
prefix.append(m)
total += t
# align the end: never end on an assistant msg with tool_calls whose results
# were cut off; drop trailing orphans
while prefix and prefix[-1].get("tool_calls"):
prefix.pop()
# also drop trailing tool results with no preceding assistant tool_calls kept
# (compress()'s _sanitize_tool_pairs would handle it, but keep input clean)
before_tokens = sum(tok(m) for m in prefix)
print(f"[{Path(CHECKOUT).name}] {Path(LINEAGE).stem}: prefix {len(prefix)} msgs ~{before_tokens:,} tok (cap {CAP:,})")
from agent.context_compressor import ContextCompressor # noqa: E402
model = "anthropic/claude-fable-5"
comp = ContextCompressor(model=model, quiet_mode=True)
before = copy.deepcopy(prefix)
t0 = time.time()
compressed = comp.compress(prefix, current_tokens=before_tokens, force=True)
dt = time.time() - t0
after_tokens = sum(tok(m) for m in compressed)
print(f" -> {len(compressed)} msgs ~{after_tokens:,} tok in {dt:.1f}s (err={getattr(comp,'_last_summary_error',None)})")
json.dump({
"meta": {
"checkout": Path(CHECKOUT).name,
"session_id": data["root"],
"title": f"lineage {data['root']} ({len(data['chain'])} rotations)",
"model": model,
"elapsed_s": round(dt, 1),
"before_msgs": len(before),
"after_msgs": len(compressed),
"before_tokens_est": before_tokens,
"after_tokens_est": after_tokens,
"summary_error": getattr(comp, "_last_summary_error", None),
"todo_injection_block": None,
},
"before": before,
"after": compressed,
}, open(OUT, "w", encoding="utf-8"), default=str)
print(f" wrote {OUT}")