diff --git a/.codearbiter/open-tasks.md b/.codearbiter/open-tasks.md
index 700a18f..8f2c6f1 100644
--- a/.codearbiter/open-tasks.md
+++ b/.codearbiter/open-tasks.md
@@ -41,7 +41,7 @@ per task. Schema and the count rule: see `plugins/ca/hooks/init-codearbiter.py`
- [x] v2.release.0005 - Assert the notes-file `## vX.Y.Z` heading matches the tag before `gh release create` (LOW 11), so a stale notes-file can't publish the wrong section under the right tag. (from release-skill red-team 2026-06-23) (done 2026-06-26)
- [x] v2.release.0006 - Confirm pre-release/beta tag exclusion in baseline resolution (LOW 12) is fully covered by the new `grep -Ev '-(beta|rc|alpha)'` filter; close if so, else tighten. (from release-skill red-team 2026-06-23) (done 2026-06-26)
- [ ] v2.docs.0003 - Decision-log reconciliation form of the forge-allowlist drift check (CONFIRM-05-dependent). The shipped site check is self-consistency only (slug maps to a real command file); the stronger form reconciles PREVIEW_COMMANDS against a recorded promotion in the decision log and belongs in /ca:doctor (reaches outside site/). Blocked on CONFIRM-05 (the --farm promotion bar). (from sprint release-hardening-debt-paydown)
-- [ ] v2.prune.0001 - [MED] The prune metric conflates file-bytes-freed with context-tokens-freed, overstating the model-context benefit that feeds the dry->on go/no-go.
+- [x] v2.prune.0001 - [MED] The prune metric conflates file-bytes-freed with context-tokens-freed, overstating the model-context benefit that feeds the dry->on go/no-go. (done 2026-07-20)
- Desc: hook_run / append_dry_metrics / append_audit_log report bytes_before/after, freed_bytes, est_tokens (= bytes//4 over the WHOLE file) and per-strategy byte deltas. But sidecar-collapse (the single biggest contributor — 522KB of 890KB in session ff1c472d's PreCompact run) trims the top-level `toolUseResult` sidecar, which is Claude Code bookkeeping NOT sent to the model. Verified against the pre-prune backup: for large Reads the sidecar duplicates the model-visible message.content tool_result (line 214: sidecar 59.8KB ~= message 67KB); for others it holds bulk the model never received (line 475: sidecar 13.8KB vs message 0.26KB). So est_tokens overstates the compaction-relevant / cold-re-cache-cost benefit. That run's headline was -223k est-tokens (40.3%) but the actual model-context slice was ~87k (aged-result-condense only); the ~131k sidecar slice is disk/IO/resume-parse hygiene, not context. prune-dry.jsonl — the evidence base for flipping prune to `on` — accrues this conflated number.
- Done when: prune metrics separate file_bytes_freed from context_bytes_freed (strategies tagged by whether they edit message.content vs sidecar/bookkeeping), the dry-run records + statusline surface the context-relevant figure distinctly labeled, and the dry->on go/no-go is reassessed on the corrected number.
- Boundaries: none (observability/metrics; no enforcement surface)
diff --git a/.codearbiter/reports/2026-07-20-prune-metric-reassessment.md b/.codearbiter/reports/2026-07-20-prune-metric-reassessment.md
new file mode 100644
index 0000000..29ea946
--- /dev/null
+++ b/.codearbiter/reports/2026-07-20-prune-metric-reassessment.md
@@ -0,0 +1,28 @@
+# Prune metric reassessment — 2026-07-20
+
+Scope: aggregate-only recalculation of the local
+`~/.codearbiter/metrics/prune-dry.jsonl` evidence. No transcript content,
+repository identity, paths, or session identifiers were read into this report.
+
+## Evidence
+
+- Rows: 9 total; 9 `dry-run` with zero validation errors.
+- Legacy whole-file estimate: 4,545,576 bytes, approximately 1,136,394 tokens.
+- File-only `sidecar-collapse`: 2,860,049 bytes.
+- Model-visible strategies: 1,611,362 bytes, approximately 402,840 tokens.
+- Individual rows clearing the default 80,000 context-token nudge floor: 0.
+
+Strategy deltas do not sum exactly to the serialized file delta because JSON
+container punctuation and reserialization overhead are outside the replaced
+payloads. The context estimate deliberately counts only strategy deltas whose
+targets live in model-visible message content.
+
+## Decision
+
+The safety evidence remains clean, but the earlier benefit headline was about
+64.5% too high because it treated file-only bookkeeping as model context. This
+sample does not justify arming the cold-cache nudge at its default threshold and
+does not support graduating pruning from preview on benefit evidence alone.
+Keep the feature off by default, collect new dry records with the corrected
+schema, and base any later `dry` to `on` decision on
+`context_est_tokens_freed`, not the legacy whole-file estimate.
diff --git a/.github/scripts/test_prune_policy_parity.py b/.github/scripts/test_prune_policy_parity.py
index e5c8929..3d7da9f 100644
--- a/.github/scripts/test_prune_policy_parity.py
+++ b/.github/scripts/test_prune_policy_parity.py
@@ -15,6 +15,7 @@
from _prunepolicy import ( # noqa: E402
PrunePolicy,
SemanticEntry,
+ STRATEGY_METRIC_SCOPES,
audit_outcomes,
has_marker,
marker_for,
@@ -110,11 +111,25 @@ def test_markers_tiers_metrics_and_plans_are_idempotent(self):
self.assertEqual(once.metrics["entries_before"], 5)
self.assertEqual(once.metrics["candidate_entries"], 4)
self.assertEqual(once.audit_codes, ("CA-PRUNE-PLAN",))
- self.assertEqual(reduction_metrics(1000, 600), {
+ self.assertEqual(set(STRATEGY_METRIC_SCOPES), set(select_strategies("aggressive")))
+ self.assertEqual(STRATEGY_METRIC_SCOPES["sidecar-collapse"], "file-only")
+ self.assertTrue(all(
+ scope == "context"
+ for name, scope in STRATEGY_METRIC_SCOPES.items()
+ if name != "sidecar-collapse"
+ ))
+ self.assertEqual(reduction_metrics(1000, 600, {
+ "sidecar-collapse": 300,
+ "aged-result-condense": 100,
+ }), {
"bytes_before": 1000, "bytes_after": 600, "freed_bytes": 400,
"est_tokens_before": 250, "est_tokens_after": 150, "pct": 40.0,
+ "file_bytes_freed": 400, "file_est_tokens_freed": 100,
+ "file_est_tokens_before": 250, "file_est_tokens_after": 150,
+ "file_pct": 40.0, "context_bytes_freed": 100,
+ "context_est_tokens_freed": 25,
})
- self.assertEqual(reduction_metrics(100, 120)["freed_bytes"], -20)
+ self.assertEqual(reduction_metrics(100, 120, {})["freed_bytes"], -20)
self.assertEqual(audit_outcomes(parse_errors=1, orphans=0, unpaired=2),
("FAIL", "OK", "WARN", "OK"))
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b5bfc5b..1361804 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,12 @@ predate the plugin rewrite and are grouped by date.
affirmative host project trust; unknown or foreign tool replacements fail
closed.
+### Fixed
+
+- Prune dry-run records, audit logs, CLI, footer, and cold-cache metrics now separate
+ model-visible context savings from file-only sidecar cleanup; sidecar bytes
+ no longer inflate the context-benefit decision or arm the cold-cache nudge.
+
## [2.9.0] — 2026-07-12
Statusline customization and correctness hardening across concurrent sessions.
diff --git a/README.md b/README.md
index edde6e9..7de0d85 100644
--- a/README.md
+++ b/README.md
@@ -422,7 +422,7 @@ Some features are built, tested, and shipping in the box, but not yet *blessed*.
| Pluggable execution farm | /ca:sprint --farm | `preview` | run it on a real sprint, report results |
| ca-sandbox (local Codespace) | install the `ca-sandbox` plugin | `preview` | explore real repos in it; run `--with-claude` and report |
-**Live transcript pruning.** Long sessions bloat the transcript until Claude Code compacts early and you lose working headroom; `CODEARBITER_PRUNE=dry` computes every prune it would make and logs the evidence without touching your transcript. It's preview because the `dry → on` go/no-go needs that real-session evidence first. Details and tuning knobs: [What's in the Forge](https://arbiterforge.github.io/codeArbiter/feature-forge/whats-in-the-forge/).
+**Live transcript pruning.** Long sessions bloat the transcript until Claude Code compacts early and you lose working headroom; `CODEARBITER_PRUNE=dry` computes every prune it would make and logs the evidence without touching your transcript. The evidence separates model-visible context savings from file-only sidecar cleanup, and only the context figure informs the `dry → on` benefit decision. It's preview because that go/no-go needs real-session evidence first. Details and tuning knobs: [What's in the Forge](https://arbiterforge.github.io/codeArbiter/feature-forge/whats-in-the-forge/).
#### Pluggable execution farm
diff --git a/core/pysrc/_prunelib.py b/core/pysrc/_prunelib.py
index 332b48b..357d97a 100644
--- a/core/pysrc/_prunelib.py
+++ b/core/pysrc/_prunelib.py
@@ -202,7 +202,12 @@ def _is_small_scalar(v, limit=200):
# --------------------------------------------------------------------------- #
def _record(report, name, touched, before, after):
- report[name] = {"lines": touched, "bytes_before": before, "bytes_after": after}
+ report[name] = {
+ "lines": touched,
+ "bytes_before": before,
+ "bytes_after": after,
+ "metric_scope": _policy.STRATEGY_METRIC_SCOPES[name],
+ }
def s_sidecar_collapse(lines, index, cfg, report):
@@ -224,13 +229,15 @@ def s_sidecar_collapse(lines, index, cfg, report):
new = dict(kept)
new["_ca_condensed"] = _marker(orig)
new_s = _dumps(new)
- if len(new_s) >= len(orig): # net-negative guard
+ if len(new_s) >= len(orig): # preserve legacy transformation eligibility
continue
+ orig_size = len(orig.encode("utf-8"))
+ new_size = len(new_s.encode("utf-8"))
ln.obj["toolUseResult"] = new
ln.dirty = True
touched += 1
- before += len(orig)
- after += len(new_s)
+ before += orig_size
+ after += new_size
_record(report, "sidecar-collapse", touched, before, after)
@@ -348,7 +355,7 @@ def s_reasoning_fold(lines, index, cfg, report):
if not keep: # never empty a message
continue
for b in thinking:
- before += len(_dumps(b))
+ before += len(_dumps(b).encode("utf-8"))
ln.obj["message"]["content"] = keep
ln.dirty = True
touched += 1
@@ -406,11 +413,14 @@ def s_mcp_payload_condense(lines, index, cfg, report):
if isinstance(inp, dict) else {})
new = dict(kept)
new["_ca_condensed"] = _marker(orig)
- if len(_dumps(new)) >= len(orig):
+ new_s = _dumps(new)
+ if len(new_s) >= len(orig): # preserve legacy transformation eligibility
continue
+ orig_size = len(orig.encode("utf-8"))
+ new_size = len(new_s.encode("utf-8"))
blk["input"] = new
- before += len(orig)
- after += len(_dumps(new))
+ before += orig_size
+ after += new_size
changed = True
if changed:
ln.dirty = True
@@ -1112,14 +1122,12 @@ def _idle_seconds(data, now):
def _nudge_advisory(rec):
"""Build the advisory string from state-record numbers only.
- Never includes transcript content — only sizes / est-tokens / percent."""
- freed = int(rec.get("freed_bytes", 0) or 0)
- pruned = int(rec.get("last_pruned_size", 0) or 0)
- approx_tokens = est_tokens(pruned + freed) # ~ in-memory (bloated) size
- pct = rec.get("pct", 0)
- return (f"Cold cache miss imminent on ~{approx_tokens // 1000}k tokens. "
- f"/compact or exit + --resume lands that re-cache on pruned context "
- f"(~{pct}% smaller). Submit again to proceed.")
+ Never includes transcript content — only context-byte/token aggregates."""
+ context_freed = int(rec.get("context_bytes_freed", 0) or 0)
+ context_tokens = est_tokens(context_freed)
+ return (f"Cold cache miss can re-cache ~{context_tokens // 1000}k avoidable "
+ f"context tokens. /compact or exit + --resume lands that re-cache "
+ f"on pruned context. Submit again to proceed.")
def nudge_decision(rec, idle_secs, e):
@@ -1151,8 +1159,8 @@ def _num(key, default):
return (False, "", nr)
return (False, "", rec)
- freed = rec.get("freed_bytes", 0)
- if not isinstance(freed, (int, float)) or est_tokens(int(freed)) < min_tokens:
+ freed = rec.get("context_bytes_freed")
+ if type(freed) is not int or freed < 0 or est_tokens(freed) < min_tokens:
return (False, "", rec)
if rec.get("cold_nudged"): # already fired this cold window
@@ -1252,7 +1260,11 @@ def hook_run(payload, env=None):
except Exception: # noqa: BLE001 — never let pruning break the turn
return 0
b0, b1 = res["bytes_before"], res["bytes_after"]
- reduction = _policy.reduction_metrics(b0, b1)
+ strategy_savings = {
+ name: row["bytes_before"] - row["bytes_after"]
+ for name, row in res["strategies"].items()
+ }
+ reduction = _policy.reduction_metrics(b0, b1, strategy_savings)
if not cfg.execute:
# Dry mode: persist the would-be outcome to the shared data-collection
# log so confidence accrues across sessions before flipping to on.
@@ -1266,6 +1278,8 @@ def hook_run(payload, env=None):
"validation_errors": len(res["validation_errors"]),
"strategies": {k: v["bytes_before"] - v["bytes_after"]
for k, v in res["strategies"].items()},
+ "strategy_scopes": {k: v["metric_scope"]
+ for k, v in res["strategies"].items()},
}, env=e)
# Carry cold_nudged forward so the once-per-cold-window marker survives a
# normal (non-armed) prune run. Re-read from state in case the nudge block
@@ -1278,6 +1292,11 @@ def hook_run(payload, env=None):
"last_run_ts": int(time.time()),
"pct": reduction["pct"],
"freed_bytes": reduction["freed_bytes"],
+ "file_pct": reduction["file_pct"],
+ "file_bytes_freed": reduction["file_bytes_freed"],
+ "file_est_tokens_freed": reduction["file_est_tokens_freed"],
+ "context_bytes_freed": reduction["context_bytes_freed"],
+ "context_est_tokens_freed": reduction["context_est_tokens_freed"],
"verdict": res["verdict"],
}
if _cold_nudged:
@@ -1335,13 +1354,15 @@ def run(path, cfg, session="session", data=None, lines=None, pre_stat=None):
new_bytes = serialize(lines)
errs = validate(orig_bytes, new_bytes, lines, cfg)
- reduction = _policy.reduction_metrics(len(orig_bytes), len(new_bytes))
+ strategy_savings = {
+ name: row["bytes_before"] - row["bytes_after"]
+ for name, row in report.items()
+ }
+ reduction = _policy.reduction_metrics(
+ len(orig_bytes), len(new_bytes), strategy_savings)
result = {
"path": path,
- "bytes_before": reduction["bytes_before"],
- "bytes_after": reduction["bytes_after"],
- "est_tokens_before": reduction["est_tokens_before"],
- "est_tokens_after": reduction["est_tokens_after"],
+ **reduction,
"strategies": report,
"validation_errors": errs,
"executed": False,
@@ -1357,9 +1378,10 @@ def run(path, cfg, session="session", data=None, lines=None, pre_stat=None):
append_audit_log({
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"session": session, "path": path,
- "bytes_before": len(orig_bytes), "bytes_after": len(new_bytes),
+ **reduction,
"strategies": {k: v["bytes_before"] - v["bytes_after"]
for k, v in report.items()},
+ "strategy_scopes": {k: v["metric_scope"] for k, v in report.items()},
"verdict": verdict,
})
return result
diff --git a/core/pysrc/_prunepolicy.py b/core/pysrc/_prunepolicy.py
index 25aa1b0..eb6305d 100644
--- a/core/pysrc/_prunepolicy.py
+++ b/core/pysrc/_prunepolicy.py
@@ -7,8 +7,9 @@
"""
#
# Public API:
-# reduction_metrics(bytes_before, bytes_after) -> dict dry/run reduction metrics
-# with the shared token estimate
+# reduction_metrics(bytes_before, bytes_after, strategy_savings) -> dict
+# file and model-context reductions
+# with shared token estimates
# audit_outcomes(parse_errors, orphans, unpaired) -> tuple host-neutral integrity
# levels; codecs retain native messages
# marker_for(original_text) -> str deterministic condensed-content marker;
@@ -50,6 +51,17 @@
"aged-result-condense",
"oversize-result-clamp",
)
+STRATEGY_METRIC_SCOPES = {
+ "sidecar-collapse": "file-only",
+ "reasoning-fold": "context",
+ "mcp-payload-condense": "context",
+ "shell-tail-keep": "context",
+ "superseded-read-condense": "context",
+ "repeat-reminder-fold": "context",
+ "inline-image-evict": "context",
+ "aged-result-condense": "context",
+ "oversize-result-clamp": "context",
+}
@dataclass(frozen=True)
@@ -85,19 +97,36 @@ class PrunePlan:
STANDARD_POLICY = PrunePolicy(tier="standard")
-def reduction_metrics(bytes_before, bytes_after):
- """Host-neutral dry/run reduction metrics with the shared token estimate."""
+def reduction_metrics(bytes_before, bytes_after, strategy_savings):
+ """Host-neutral file and model-context reduction metrics."""
if type(bytes_before) is not int or type(bytes_after) is not int \
or bytes_before < 0 or bytes_after < 0:
raise ValueError("prune byte metrics are invalid")
+ if not isinstance(strategy_savings, dict):
+ raise ValueError("prune strategy savings are invalid")
+ context_bytes_freed = 0
+ for name, value in strategy_savings.items():
+ if name not in STRATEGY_METRIC_SCOPES or type(value) is not int or value < 0:
+ raise ValueError("prune strategy savings are invalid")
+ if STRATEGY_METRIC_SCOPES[name] == "context":
+ context_bytes_freed += value
+ file_bytes_freed = bytes_before - bytes_after
+ file_pct = (round(100.0 * file_bytes_freed / bytes_before, 1)
+ if bytes_before else 0.0)
return {
"bytes_before": bytes_before,
"bytes_after": bytes_after,
- "freed_bytes": bytes_before - bytes_after,
+ "freed_bytes": file_bytes_freed,
"est_tokens_before": bytes_before // 4,
"est_tokens_after": bytes_after // 4,
- "pct": round(100.0 * (bytes_before - bytes_after) / bytes_before, 1)
- if bytes_before else 0.0,
+ "pct": file_pct,
+ "file_bytes_freed": file_bytes_freed,
+ "file_est_tokens_before": bytes_before // 4,
+ "file_est_tokens_after": bytes_after // 4,
+ "file_est_tokens_freed": file_bytes_freed // 4,
+ "file_pct": file_pct,
+ "context_bytes_freed": context_bytes_freed,
+ "context_est_tokens_freed": context_bytes_freed // 4,
}
diff --git a/core/pysrc/_segmentslib.py b/core/pysrc/_segmentslib.py
index 45fe8c3..48371c3 100644
--- a/core/pysrc/_segmentslib.py
+++ b/core/pysrc/_segmentslib.py
@@ -242,7 +242,7 @@ def seg_pr(data):
def seg_prune(data, sid):
- """Transcript-pruner indicator: cumulative reduction and age of the last
+ """Transcript-pruner indicator: model-context reduction and age of the last
prune for this session, read from ~/.codearbiter/prune-state.json (written
by prune-transcript.py). Fail-soft — returns None on any problem, and never
raises, so it can never break statusline rendering."""
@@ -255,11 +255,16 @@ def seg_prune(data, sid):
rec = st.get(sid) if isinstance(st, dict) else None
if not isinstance(rec, dict):
return None
- pct = rec.get("pct") or 0
- if pct <= 0:
+ file_freed = num(rec.get("file_bytes_freed", rec.get("freed_bytes")), 0)
+ if file_freed <= 0:
return None
age = human_dur(max(0, time.time() - num(rec.get("last_run_ts"), time.time())))
- return f"{GREY}✂{RESET} {WHITE}{pct:.0f}%{RESET} {GREY}{age}{RESET}"
+ if "context_est_tokens_freed" not in rec:
+ pct = num(rec.get("file_pct", rec.get("pct")), 0)
+ return f"{GREY}✂{RESET} {WHITE}file:{pct:.0f}%{RESET} {GREY}{age}{RESET}"
+ context_tokens = max(0, num(rec.get("context_est_tokens_freed"), 0))
+ return (f"{GREY}✂{RESET} {WHITE}ctx:{fmt_tok(context_tokens)}{RESET} "
+ f"{GREY}{age}{RESET}")
except Exception: # noqa: BLE001
return None
diff --git a/core/pysrc/prune-transcript.py b/core/pysrc/prune-transcript.py
index b4f61f3..60723a2 100644
--- a/core/pysrc/prune-transcript.py
+++ b/core/pysrc/prune-transcript.py
@@ -94,15 +94,18 @@ def _fmt_bytes(n):
def print_report(res):
rows = res["strategies"]
if rows:
- print(f"{'strategy':<24}{'lines':>7}{'before':>12}{'after':>12}{'est≈ saved':>12}")
+ print(f"{'strategy':<24}{'scope':>11}{'lines':>7}{'before':>12}"
+ f"{'after':>12}{'est≈ saved':>12}")
for name, r in rows.items():
saved = est_tokens(r["bytes_before"] - r["bytes_after"])
- print(f"{name:<24}{r['lines']:>7}{r['bytes_before']:>12}"
+ print(f"{name:<24}{r['metric_scope']:>11}{r['lines']:>7}"
+ f"{r['bytes_before']:>12}"
f"{r['bytes_after']:>12}{saved:>12}")
- b0, b1 = res["bytes_before"], res["bytes_after"]
- pct = (100.0 * (b0 - b1) / b0) if b0 else 0.0
- print(f"\ntotal: {_fmt_bytes(b0)} -> {_fmt_bytes(b1)} "
- f"({pct:.1f}% smaller, est≈{est_tokens(b0 - b1)} tokens freed)")
+ print(f"\nfile: {_fmt_bytes(res['bytes_before'])} -> "
+ f"{_fmt_bytes(res['bytes_after'])} "
+ f"({res['file_pct']:.1f}% smaller, "
+ f"{res['file_bytes_freed']} bytes freed)")
+ print(f"context: {res['context_est_tokens_freed']} estimated tokens freed")
if res["validation_errors"]:
print("VALIDATION FAILED:", file=sys.stderr)
for e in res["validation_errors"]:
diff --git a/core/surface/commands/prune.md b/core/surface/commands/prune.md
index 3671cd7..1b32d63 100644
--- a/core/surface/commands/prune.md
+++ b/core/surface/commands/prune.md
@@ -25,8 +25,14 @@ Pi session file.
`~/.codearbiter/metrics/prune-dry.jsonl` (override with `CODEARBITER_PRUNE_METRICS`). That log is
the evidence base for the `dry`→`on` decision: a clean record (every row `verdict: dry-run`,
`validation_errors: 0`) over a representative set of sessions is the signal that enabling is safe.
+ Read `context_bytes_freed` / `context_est_tokens_freed` for model-context benefit and
+ `file_bytes_freed` / `file_pct` for disk and resume-parse benefit. `sidecar-collapse` is explicitly
+ `file-only`; it must not count toward the context-benefit or cold-cache decision.
+ The legacy `freed_bytes`, `pct`, and `est_tokens_before` / `est_tokens_after` fields remain
+ whole-file compatibility aliases; do not use them as model-context evidence.
- `dry` — create a read-only semantic plan (or analyze a scratch copy where the host exposes a
- serialized transcript); present the per-strategy reduction table. Never writes the active session.
+ serialized transcript); present the per-strategy reduction table with every strategy labeled
+ `context` or `file-only`. Never writes the active session.
- `run ` — prune the target with `--execute`. Targets a **copy or an old/inactive
transcript only** — the tool refuses an active or recently-modified target by construction.
- `audit ` — read-only integrity report: line-parse, uuid chain, tool-pair coverage,
@@ -64,10 +70,12 @@ Pi session file.
**Cold-miss nudge** [Feature Forge — `preview`]: when `CODEARBITER_PRUNE` is `on`, an
optional submit-time speed bump warns once before a cold cache re-cache lands on bloated
context. Enable with `CODEARBITER_PRUNE_NUDGE=on` (default `off`). When all arming conditions
- hold (idle ≥ `CODEARBITER_PRUNE_NUDGE_IDLE_SECS`, default 240 s; estimated freed tokens ≥
+ hold (idle ≥ `CODEARBITER_PRUNE_NUDGE_IDLE_SECS`, default 240 s; estimated model-context
+ tokens freed ≥
`CODEARBITER_PRUNE_NUDGE_MIN_TOKENS`, default 80 000), the hook blocks the submit once with
- an advisory on stderr and returns exit code 2. The advisory names the approximate token count,
- saving percentage, and the host-native actions that move the re-cache to pruned context:
+ an advisory on stderr and returns exit code 2. File-only sidecar reduction never arms it. The
+ advisory names the approximate avoidable context-token count and the host-native actions that
+ move the re-cache to pruned context:
native compaction or a normal exit + resume/restart. Resubmitting immediately proceeds. The block fires at most
once per cold window; a subsequent warm submit (idle < floor) resets the window so the next
genuine cold stretch re-arms. The gate is strictly opt-in, never fires in `dry`/`off` mode,
diff --git a/plugins/ca-codex/CHANGELOG.md b/plugins/ca-codex/CHANGELOG.md
index e8480e8..9f8469d 100644
--- a/plugins/ca-codex/CHANGELOG.md
+++ b/plugins/ca-codex/CHANGELOG.md
@@ -7,6 +7,9 @@ All notable changes to the **ca-codex** plugin are recorded here. Format follows
## [0.3.0] — 2026-07-12 — Shared-state concurrency hardening
### Fixed
+- Prune metrics now separate model-visible context savings from file-only
+ sidecar cleanup, preventing sidecar bytes from inflating the context-benefit
+ decision or cold-cache nudge.
- Shared statusline ledger records use ownership-safe atomic shards, so
concurrent host activity cannot discard session token/cost state.
- Linked-worktree branch metadata is parsed from Git pointer files instead of
diff --git a/plugins/ca-codex/hooks/_prunelib.py b/plugins/ca-codex/hooks/_prunelib.py
index 332b48b..357d97a 100644
--- a/plugins/ca-codex/hooks/_prunelib.py
+++ b/plugins/ca-codex/hooks/_prunelib.py
@@ -202,7 +202,12 @@ def _is_small_scalar(v, limit=200):
# --------------------------------------------------------------------------- #
def _record(report, name, touched, before, after):
- report[name] = {"lines": touched, "bytes_before": before, "bytes_after": after}
+ report[name] = {
+ "lines": touched,
+ "bytes_before": before,
+ "bytes_after": after,
+ "metric_scope": _policy.STRATEGY_METRIC_SCOPES[name],
+ }
def s_sidecar_collapse(lines, index, cfg, report):
@@ -224,13 +229,15 @@ def s_sidecar_collapse(lines, index, cfg, report):
new = dict(kept)
new["_ca_condensed"] = _marker(orig)
new_s = _dumps(new)
- if len(new_s) >= len(orig): # net-negative guard
+ if len(new_s) >= len(orig): # preserve legacy transformation eligibility
continue
+ orig_size = len(orig.encode("utf-8"))
+ new_size = len(new_s.encode("utf-8"))
ln.obj["toolUseResult"] = new
ln.dirty = True
touched += 1
- before += len(orig)
- after += len(new_s)
+ before += orig_size
+ after += new_size
_record(report, "sidecar-collapse", touched, before, after)
@@ -348,7 +355,7 @@ def s_reasoning_fold(lines, index, cfg, report):
if not keep: # never empty a message
continue
for b in thinking:
- before += len(_dumps(b))
+ before += len(_dumps(b).encode("utf-8"))
ln.obj["message"]["content"] = keep
ln.dirty = True
touched += 1
@@ -406,11 +413,14 @@ def s_mcp_payload_condense(lines, index, cfg, report):
if isinstance(inp, dict) else {})
new = dict(kept)
new["_ca_condensed"] = _marker(orig)
- if len(_dumps(new)) >= len(orig):
+ new_s = _dumps(new)
+ if len(new_s) >= len(orig): # preserve legacy transformation eligibility
continue
+ orig_size = len(orig.encode("utf-8"))
+ new_size = len(new_s.encode("utf-8"))
blk["input"] = new
- before += len(orig)
- after += len(_dumps(new))
+ before += orig_size
+ after += new_size
changed = True
if changed:
ln.dirty = True
@@ -1112,14 +1122,12 @@ def _idle_seconds(data, now):
def _nudge_advisory(rec):
"""Build the advisory string from state-record numbers only.
- Never includes transcript content — only sizes / est-tokens / percent."""
- freed = int(rec.get("freed_bytes", 0) or 0)
- pruned = int(rec.get("last_pruned_size", 0) or 0)
- approx_tokens = est_tokens(pruned + freed) # ~ in-memory (bloated) size
- pct = rec.get("pct", 0)
- return (f"Cold cache miss imminent on ~{approx_tokens // 1000}k tokens. "
- f"/compact or exit + --resume lands that re-cache on pruned context "
- f"(~{pct}% smaller). Submit again to proceed.")
+ Never includes transcript content — only context-byte/token aggregates."""
+ context_freed = int(rec.get("context_bytes_freed", 0) or 0)
+ context_tokens = est_tokens(context_freed)
+ return (f"Cold cache miss can re-cache ~{context_tokens // 1000}k avoidable "
+ f"context tokens. /compact or exit + --resume lands that re-cache "
+ f"on pruned context. Submit again to proceed.")
def nudge_decision(rec, idle_secs, e):
@@ -1151,8 +1159,8 @@ def _num(key, default):
return (False, "", nr)
return (False, "", rec)
- freed = rec.get("freed_bytes", 0)
- if not isinstance(freed, (int, float)) or est_tokens(int(freed)) < min_tokens:
+ freed = rec.get("context_bytes_freed")
+ if type(freed) is not int or freed < 0 or est_tokens(freed) < min_tokens:
return (False, "", rec)
if rec.get("cold_nudged"): # already fired this cold window
@@ -1252,7 +1260,11 @@ def hook_run(payload, env=None):
except Exception: # noqa: BLE001 — never let pruning break the turn
return 0
b0, b1 = res["bytes_before"], res["bytes_after"]
- reduction = _policy.reduction_metrics(b0, b1)
+ strategy_savings = {
+ name: row["bytes_before"] - row["bytes_after"]
+ for name, row in res["strategies"].items()
+ }
+ reduction = _policy.reduction_metrics(b0, b1, strategy_savings)
if not cfg.execute:
# Dry mode: persist the would-be outcome to the shared data-collection
# log so confidence accrues across sessions before flipping to on.
@@ -1266,6 +1278,8 @@ def hook_run(payload, env=None):
"validation_errors": len(res["validation_errors"]),
"strategies": {k: v["bytes_before"] - v["bytes_after"]
for k, v in res["strategies"].items()},
+ "strategy_scopes": {k: v["metric_scope"]
+ for k, v in res["strategies"].items()},
}, env=e)
# Carry cold_nudged forward so the once-per-cold-window marker survives a
# normal (non-armed) prune run. Re-read from state in case the nudge block
@@ -1278,6 +1292,11 @@ def hook_run(payload, env=None):
"last_run_ts": int(time.time()),
"pct": reduction["pct"],
"freed_bytes": reduction["freed_bytes"],
+ "file_pct": reduction["file_pct"],
+ "file_bytes_freed": reduction["file_bytes_freed"],
+ "file_est_tokens_freed": reduction["file_est_tokens_freed"],
+ "context_bytes_freed": reduction["context_bytes_freed"],
+ "context_est_tokens_freed": reduction["context_est_tokens_freed"],
"verdict": res["verdict"],
}
if _cold_nudged:
@@ -1335,13 +1354,15 @@ def run(path, cfg, session="session", data=None, lines=None, pre_stat=None):
new_bytes = serialize(lines)
errs = validate(orig_bytes, new_bytes, lines, cfg)
- reduction = _policy.reduction_metrics(len(orig_bytes), len(new_bytes))
+ strategy_savings = {
+ name: row["bytes_before"] - row["bytes_after"]
+ for name, row in report.items()
+ }
+ reduction = _policy.reduction_metrics(
+ len(orig_bytes), len(new_bytes), strategy_savings)
result = {
"path": path,
- "bytes_before": reduction["bytes_before"],
- "bytes_after": reduction["bytes_after"],
- "est_tokens_before": reduction["est_tokens_before"],
- "est_tokens_after": reduction["est_tokens_after"],
+ **reduction,
"strategies": report,
"validation_errors": errs,
"executed": False,
@@ -1357,9 +1378,10 @@ def run(path, cfg, session="session", data=None, lines=None, pre_stat=None):
append_audit_log({
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"session": session, "path": path,
- "bytes_before": len(orig_bytes), "bytes_after": len(new_bytes),
+ **reduction,
"strategies": {k: v["bytes_before"] - v["bytes_after"]
for k, v in report.items()},
+ "strategy_scopes": {k: v["metric_scope"] for k, v in report.items()},
"verdict": verdict,
})
return result
diff --git a/plugins/ca-codex/hooks/_prunepolicy.py b/plugins/ca-codex/hooks/_prunepolicy.py
index 25aa1b0..eb6305d 100644
--- a/plugins/ca-codex/hooks/_prunepolicy.py
+++ b/plugins/ca-codex/hooks/_prunepolicy.py
@@ -7,8 +7,9 @@
"""
#
# Public API:
-# reduction_metrics(bytes_before, bytes_after) -> dict dry/run reduction metrics
-# with the shared token estimate
+# reduction_metrics(bytes_before, bytes_after, strategy_savings) -> dict
+# file and model-context reductions
+# with shared token estimates
# audit_outcomes(parse_errors, orphans, unpaired) -> tuple host-neutral integrity
# levels; codecs retain native messages
# marker_for(original_text) -> str deterministic condensed-content marker;
@@ -50,6 +51,17 @@
"aged-result-condense",
"oversize-result-clamp",
)
+STRATEGY_METRIC_SCOPES = {
+ "sidecar-collapse": "file-only",
+ "reasoning-fold": "context",
+ "mcp-payload-condense": "context",
+ "shell-tail-keep": "context",
+ "superseded-read-condense": "context",
+ "repeat-reminder-fold": "context",
+ "inline-image-evict": "context",
+ "aged-result-condense": "context",
+ "oversize-result-clamp": "context",
+}
@dataclass(frozen=True)
@@ -85,19 +97,36 @@ class PrunePlan:
STANDARD_POLICY = PrunePolicy(tier="standard")
-def reduction_metrics(bytes_before, bytes_after):
- """Host-neutral dry/run reduction metrics with the shared token estimate."""
+def reduction_metrics(bytes_before, bytes_after, strategy_savings):
+ """Host-neutral file and model-context reduction metrics."""
if type(bytes_before) is not int or type(bytes_after) is not int \
or bytes_before < 0 or bytes_after < 0:
raise ValueError("prune byte metrics are invalid")
+ if not isinstance(strategy_savings, dict):
+ raise ValueError("prune strategy savings are invalid")
+ context_bytes_freed = 0
+ for name, value in strategy_savings.items():
+ if name not in STRATEGY_METRIC_SCOPES or type(value) is not int or value < 0:
+ raise ValueError("prune strategy savings are invalid")
+ if STRATEGY_METRIC_SCOPES[name] == "context":
+ context_bytes_freed += value
+ file_bytes_freed = bytes_before - bytes_after
+ file_pct = (round(100.0 * file_bytes_freed / bytes_before, 1)
+ if bytes_before else 0.0)
return {
"bytes_before": bytes_before,
"bytes_after": bytes_after,
- "freed_bytes": bytes_before - bytes_after,
+ "freed_bytes": file_bytes_freed,
"est_tokens_before": bytes_before // 4,
"est_tokens_after": bytes_after // 4,
- "pct": round(100.0 * (bytes_before - bytes_after) / bytes_before, 1)
- if bytes_before else 0.0,
+ "pct": file_pct,
+ "file_bytes_freed": file_bytes_freed,
+ "file_est_tokens_before": bytes_before // 4,
+ "file_est_tokens_after": bytes_after // 4,
+ "file_est_tokens_freed": file_bytes_freed // 4,
+ "file_pct": file_pct,
+ "context_bytes_freed": context_bytes_freed,
+ "context_est_tokens_freed": context_bytes_freed // 4,
}
diff --git a/plugins/ca-codex/hooks/_segmentslib.py b/plugins/ca-codex/hooks/_segmentslib.py
index 45fe8c3..48371c3 100644
--- a/plugins/ca-codex/hooks/_segmentslib.py
+++ b/plugins/ca-codex/hooks/_segmentslib.py
@@ -242,7 +242,7 @@ def seg_pr(data):
def seg_prune(data, sid):
- """Transcript-pruner indicator: cumulative reduction and age of the last
+ """Transcript-pruner indicator: model-context reduction and age of the last
prune for this session, read from ~/.codearbiter/prune-state.json (written
by prune-transcript.py). Fail-soft — returns None on any problem, and never
raises, so it can never break statusline rendering."""
@@ -255,11 +255,16 @@ def seg_prune(data, sid):
rec = st.get(sid) if isinstance(st, dict) else None
if not isinstance(rec, dict):
return None
- pct = rec.get("pct") or 0
- if pct <= 0:
+ file_freed = num(rec.get("file_bytes_freed", rec.get("freed_bytes")), 0)
+ if file_freed <= 0:
return None
age = human_dur(max(0, time.time() - num(rec.get("last_run_ts"), time.time())))
- return f"{GREY}✂{RESET} {WHITE}{pct:.0f}%{RESET} {GREY}{age}{RESET}"
+ if "context_est_tokens_freed" not in rec:
+ pct = num(rec.get("file_pct", rec.get("pct")), 0)
+ return f"{GREY}✂{RESET} {WHITE}file:{pct:.0f}%{RESET} {GREY}{age}{RESET}"
+ context_tokens = max(0, num(rec.get("context_est_tokens_freed"), 0))
+ return (f"{GREY}✂{RESET} {WHITE}ctx:{fmt_tok(context_tokens)}{RESET} "
+ f"{GREY}{age}{RESET}")
except Exception: # noqa: BLE001
return None
diff --git a/plugins/ca-codex/hooks/prune-transcript.py b/plugins/ca-codex/hooks/prune-transcript.py
index b4f61f3..60723a2 100644
--- a/plugins/ca-codex/hooks/prune-transcript.py
+++ b/plugins/ca-codex/hooks/prune-transcript.py
@@ -94,15 +94,18 @@ def _fmt_bytes(n):
def print_report(res):
rows = res["strategies"]
if rows:
- print(f"{'strategy':<24}{'lines':>7}{'before':>12}{'after':>12}{'est≈ saved':>12}")
+ print(f"{'strategy':<24}{'scope':>11}{'lines':>7}{'before':>12}"
+ f"{'after':>12}{'est≈ saved':>12}")
for name, r in rows.items():
saved = est_tokens(r["bytes_before"] - r["bytes_after"])
- print(f"{name:<24}{r['lines']:>7}{r['bytes_before']:>12}"
+ print(f"{name:<24}{r['metric_scope']:>11}{r['lines']:>7}"
+ f"{r['bytes_before']:>12}"
f"{r['bytes_after']:>12}{saved:>12}")
- b0, b1 = res["bytes_before"], res["bytes_after"]
- pct = (100.0 * (b0 - b1) / b0) if b0 else 0.0
- print(f"\ntotal: {_fmt_bytes(b0)} -> {_fmt_bytes(b1)} "
- f"({pct:.1f}% smaller, est≈{est_tokens(b0 - b1)} tokens freed)")
+ print(f"\nfile: {_fmt_bytes(res['bytes_before'])} -> "
+ f"{_fmt_bytes(res['bytes_after'])} "
+ f"({res['file_pct']:.1f}% smaller, "
+ f"{res['file_bytes_freed']} bytes freed)")
+ print(f"context: {res['context_est_tokens_freed']} estimated tokens freed")
if res["validation_errors"]:
print("VALIDATION FAILED:", file=sys.stderr)
for e in res["validation_errors"]:
diff --git a/plugins/ca-pi/CHANGELOG.md b/plugins/ca-pi/CHANGELOG.md
index ff8c215..f5f6d34 100644
--- a/plugins/ca-pi/CHANGELOG.md
+++ b/plugins/ca-pi/CHANGELOG.md
@@ -4,6 +4,12 @@ All notable changes to `ca-pi` are documented in this file.
## [0.1.1] - 2026-07-18
+### Fixed
+
+- Shared prune metrics now distinguish model-visible context savings from
+ file-only sidecar cleanup, including explicit strategy scopes and corrected
+ footer and cold-cache decisions.
+
### Changed
- Promote the verified Pi host window through exact Pi 0.80.10.
diff --git a/plugins/ca-pi/hooks/_prunelib.py b/plugins/ca-pi/hooks/_prunelib.py
index 332b48b..357d97a 100644
--- a/plugins/ca-pi/hooks/_prunelib.py
+++ b/plugins/ca-pi/hooks/_prunelib.py
@@ -202,7 +202,12 @@ def _is_small_scalar(v, limit=200):
# --------------------------------------------------------------------------- #
def _record(report, name, touched, before, after):
- report[name] = {"lines": touched, "bytes_before": before, "bytes_after": after}
+ report[name] = {
+ "lines": touched,
+ "bytes_before": before,
+ "bytes_after": after,
+ "metric_scope": _policy.STRATEGY_METRIC_SCOPES[name],
+ }
def s_sidecar_collapse(lines, index, cfg, report):
@@ -224,13 +229,15 @@ def s_sidecar_collapse(lines, index, cfg, report):
new = dict(kept)
new["_ca_condensed"] = _marker(orig)
new_s = _dumps(new)
- if len(new_s) >= len(orig): # net-negative guard
+ if len(new_s) >= len(orig): # preserve legacy transformation eligibility
continue
+ orig_size = len(orig.encode("utf-8"))
+ new_size = len(new_s.encode("utf-8"))
ln.obj["toolUseResult"] = new
ln.dirty = True
touched += 1
- before += len(orig)
- after += len(new_s)
+ before += orig_size
+ after += new_size
_record(report, "sidecar-collapse", touched, before, after)
@@ -348,7 +355,7 @@ def s_reasoning_fold(lines, index, cfg, report):
if not keep: # never empty a message
continue
for b in thinking:
- before += len(_dumps(b))
+ before += len(_dumps(b).encode("utf-8"))
ln.obj["message"]["content"] = keep
ln.dirty = True
touched += 1
@@ -406,11 +413,14 @@ def s_mcp_payload_condense(lines, index, cfg, report):
if isinstance(inp, dict) else {})
new = dict(kept)
new["_ca_condensed"] = _marker(orig)
- if len(_dumps(new)) >= len(orig):
+ new_s = _dumps(new)
+ if len(new_s) >= len(orig): # preserve legacy transformation eligibility
continue
+ orig_size = len(orig.encode("utf-8"))
+ new_size = len(new_s.encode("utf-8"))
blk["input"] = new
- before += len(orig)
- after += len(_dumps(new))
+ before += orig_size
+ after += new_size
changed = True
if changed:
ln.dirty = True
@@ -1112,14 +1122,12 @@ def _idle_seconds(data, now):
def _nudge_advisory(rec):
"""Build the advisory string from state-record numbers only.
- Never includes transcript content — only sizes / est-tokens / percent."""
- freed = int(rec.get("freed_bytes", 0) or 0)
- pruned = int(rec.get("last_pruned_size", 0) or 0)
- approx_tokens = est_tokens(pruned + freed) # ~ in-memory (bloated) size
- pct = rec.get("pct", 0)
- return (f"Cold cache miss imminent on ~{approx_tokens // 1000}k tokens. "
- f"/compact or exit + --resume lands that re-cache on pruned context "
- f"(~{pct}% smaller). Submit again to proceed.")
+ Never includes transcript content — only context-byte/token aggregates."""
+ context_freed = int(rec.get("context_bytes_freed", 0) or 0)
+ context_tokens = est_tokens(context_freed)
+ return (f"Cold cache miss can re-cache ~{context_tokens // 1000}k avoidable "
+ f"context tokens. /compact or exit + --resume lands that re-cache "
+ f"on pruned context. Submit again to proceed.")
def nudge_decision(rec, idle_secs, e):
@@ -1151,8 +1159,8 @@ def _num(key, default):
return (False, "", nr)
return (False, "", rec)
- freed = rec.get("freed_bytes", 0)
- if not isinstance(freed, (int, float)) or est_tokens(int(freed)) < min_tokens:
+ freed = rec.get("context_bytes_freed")
+ if type(freed) is not int or freed < 0 or est_tokens(freed) < min_tokens:
return (False, "", rec)
if rec.get("cold_nudged"): # already fired this cold window
@@ -1252,7 +1260,11 @@ def hook_run(payload, env=None):
except Exception: # noqa: BLE001 — never let pruning break the turn
return 0
b0, b1 = res["bytes_before"], res["bytes_after"]
- reduction = _policy.reduction_metrics(b0, b1)
+ strategy_savings = {
+ name: row["bytes_before"] - row["bytes_after"]
+ for name, row in res["strategies"].items()
+ }
+ reduction = _policy.reduction_metrics(b0, b1, strategy_savings)
if not cfg.execute:
# Dry mode: persist the would-be outcome to the shared data-collection
# log so confidence accrues across sessions before flipping to on.
@@ -1266,6 +1278,8 @@ def hook_run(payload, env=None):
"validation_errors": len(res["validation_errors"]),
"strategies": {k: v["bytes_before"] - v["bytes_after"]
for k, v in res["strategies"].items()},
+ "strategy_scopes": {k: v["metric_scope"]
+ for k, v in res["strategies"].items()},
}, env=e)
# Carry cold_nudged forward so the once-per-cold-window marker survives a
# normal (non-armed) prune run. Re-read from state in case the nudge block
@@ -1278,6 +1292,11 @@ def hook_run(payload, env=None):
"last_run_ts": int(time.time()),
"pct": reduction["pct"],
"freed_bytes": reduction["freed_bytes"],
+ "file_pct": reduction["file_pct"],
+ "file_bytes_freed": reduction["file_bytes_freed"],
+ "file_est_tokens_freed": reduction["file_est_tokens_freed"],
+ "context_bytes_freed": reduction["context_bytes_freed"],
+ "context_est_tokens_freed": reduction["context_est_tokens_freed"],
"verdict": res["verdict"],
}
if _cold_nudged:
@@ -1335,13 +1354,15 @@ def run(path, cfg, session="session", data=None, lines=None, pre_stat=None):
new_bytes = serialize(lines)
errs = validate(orig_bytes, new_bytes, lines, cfg)
- reduction = _policy.reduction_metrics(len(orig_bytes), len(new_bytes))
+ strategy_savings = {
+ name: row["bytes_before"] - row["bytes_after"]
+ for name, row in report.items()
+ }
+ reduction = _policy.reduction_metrics(
+ len(orig_bytes), len(new_bytes), strategy_savings)
result = {
"path": path,
- "bytes_before": reduction["bytes_before"],
- "bytes_after": reduction["bytes_after"],
- "est_tokens_before": reduction["est_tokens_before"],
- "est_tokens_after": reduction["est_tokens_after"],
+ **reduction,
"strategies": report,
"validation_errors": errs,
"executed": False,
@@ -1357,9 +1378,10 @@ def run(path, cfg, session="session", data=None, lines=None, pre_stat=None):
append_audit_log({
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"session": session, "path": path,
- "bytes_before": len(orig_bytes), "bytes_after": len(new_bytes),
+ **reduction,
"strategies": {k: v["bytes_before"] - v["bytes_after"]
for k, v in report.items()},
+ "strategy_scopes": {k: v["metric_scope"] for k, v in report.items()},
"verdict": verdict,
})
return result
diff --git a/plugins/ca-pi/hooks/_prunepolicy.py b/plugins/ca-pi/hooks/_prunepolicy.py
index 25aa1b0..eb6305d 100644
--- a/plugins/ca-pi/hooks/_prunepolicy.py
+++ b/plugins/ca-pi/hooks/_prunepolicy.py
@@ -7,8 +7,9 @@
"""
#
# Public API:
-# reduction_metrics(bytes_before, bytes_after) -> dict dry/run reduction metrics
-# with the shared token estimate
+# reduction_metrics(bytes_before, bytes_after, strategy_savings) -> dict
+# file and model-context reductions
+# with shared token estimates
# audit_outcomes(parse_errors, orphans, unpaired) -> tuple host-neutral integrity
# levels; codecs retain native messages
# marker_for(original_text) -> str deterministic condensed-content marker;
@@ -50,6 +51,17 @@
"aged-result-condense",
"oversize-result-clamp",
)
+STRATEGY_METRIC_SCOPES = {
+ "sidecar-collapse": "file-only",
+ "reasoning-fold": "context",
+ "mcp-payload-condense": "context",
+ "shell-tail-keep": "context",
+ "superseded-read-condense": "context",
+ "repeat-reminder-fold": "context",
+ "inline-image-evict": "context",
+ "aged-result-condense": "context",
+ "oversize-result-clamp": "context",
+}
@dataclass(frozen=True)
@@ -85,19 +97,36 @@ class PrunePlan:
STANDARD_POLICY = PrunePolicy(tier="standard")
-def reduction_metrics(bytes_before, bytes_after):
- """Host-neutral dry/run reduction metrics with the shared token estimate."""
+def reduction_metrics(bytes_before, bytes_after, strategy_savings):
+ """Host-neutral file and model-context reduction metrics."""
if type(bytes_before) is not int or type(bytes_after) is not int \
or bytes_before < 0 or bytes_after < 0:
raise ValueError("prune byte metrics are invalid")
+ if not isinstance(strategy_savings, dict):
+ raise ValueError("prune strategy savings are invalid")
+ context_bytes_freed = 0
+ for name, value in strategy_savings.items():
+ if name not in STRATEGY_METRIC_SCOPES or type(value) is not int or value < 0:
+ raise ValueError("prune strategy savings are invalid")
+ if STRATEGY_METRIC_SCOPES[name] == "context":
+ context_bytes_freed += value
+ file_bytes_freed = bytes_before - bytes_after
+ file_pct = (round(100.0 * file_bytes_freed / bytes_before, 1)
+ if bytes_before else 0.0)
return {
"bytes_before": bytes_before,
"bytes_after": bytes_after,
- "freed_bytes": bytes_before - bytes_after,
+ "freed_bytes": file_bytes_freed,
"est_tokens_before": bytes_before // 4,
"est_tokens_after": bytes_after // 4,
- "pct": round(100.0 * (bytes_before - bytes_after) / bytes_before, 1)
- if bytes_before else 0.0,
+ "pct": file_pct,
+ "file_bytes_freed": file_bytes_freed,
+ "file_est_tokens_before": bytes_before // 4,
+ "file_est_tokens_after": bytes_after // 4,
+ "file_est_tokens_freed": file_bytes_freed // 4,
+ "file_pct": file_pct,
+ "context_bytes_freed": context_bytes_freed,
+ "context_est_tokens_freed": context_bytes_freed // 4,
}
diff --git a/plugins/ca-pi/hooks/_segmentslib.py b/plugins/ca-pi/hooks/_segmentslib.py
index 45fe8c3..48371c3 100644
--- a/plugins/ca-pi/hooks/_segmentslib.py
+++ b/plugins/ca-pi/hooks/_segmentslib.py
@@ -242,7 +242,7 @@ def seg_pr(data):
def seg_prune(data, sid):
- """Transcript-pruner indicator: cumulative reduction and age of the last
+ """Transcript-pruner indicator: model-context reduction and age of the last
prune for this session, read from ~/.codearbiter/prune-state.json (written
by prune-transcript.py). Fail-soft — returns None on any problem, and never
raises, so it can never break statusline rendering."""
@@ -255,11 +255,16 @@ def seg_prune(data, sid):
rec = st.get(sid) if isinstance(st, dict) else None
if not isinstance(rec, dict):
return None
- pct = rec.get("pct") or 0
- if pct <= 0:
+ file_freed = num(rec.get("file_bytes_freed", rec.get("freed_bytes")), 0)
+ if file_freed <= 0:
return None
age = human_dur(max(0, time.time() - num(rec.get("last_run_ts"), time.time())))
- return f"{GREY}✂{RESET} {WHITE}{pct:.0f}%{RESET} {GREY}{age}{RESET}"
+ if "context_est_tokens_freed" not in rec:
+ pct = num(rec.get("file_pct", rec.get("pct")), 0)
+ return f"{GREY}✂{RESET} {WHITE}file:{pct:.0f}%{RESET} {GREY}{age}{RESET}"
+ context_tokens = max(0, num(rec.get("context_est_tokens_freed"), 0))
+ return (f"{GREY}✂{RESET} {WHITE}ctx:{fmt_tok(context_tokens)}{RESET} "
+ f"{GREY}{age}{RESET}")
except Exception: # noqa: BLE001
return None
diff --git a/plugins/ca-pi/hooks/prune-transcript.py b/plugins/ca-pi/hooks/prune-transcript.py
index b4f61f3..60723a2 100644
--- a/plugins/ca-pi/hooks/prune-transcript.py
+++ b/plugins/ca-pi/hooks/prune-transcript.py
@@ -94,15 +94,18 @@ def _fmt_bytes(n):
def print_report(res):
rows = res["strategies"]
if rows:
- print(f"{'strategy':<24}{'lines':>7}{'before':>12}{'after':>12}{'est≈ saved':>12}")
+ print(f"{'strategy':<24}{'scope':>11}{'lines':>7}{'before':>12}"
+ f"{'after':>12}{'est≈ saved':>12}")
for name, r in rows.items():
saved = est_tokens(r["bytes_before"] - r["bytes_after"])
- print(f"{name:<24}{r['lines']:>7}{r['bytes_before']:>12}"
+ print(f"{name:<24}{r['metric_scope']:>11}{r['lines']:>7}"
+ f"{r['bytes_before']:>12}"
f"{r['bytes_after']:>12}{saved:>12}")
- b0, b1 = res["bytes_before"], res["bytes_after"]
- pct = (100.0 * (b0 - b1) / b0) if b0 else 0.0
- print(f"\ntotal: {_fmt_bytes(b0)} -> {_fmt_bytes(b1)} "
- f"({pct:.1f}% smaller, est≈{est_tokens(b0 - b1)} tokens freed)")
+ print(f"\nfile: {_fmt_bytes(res['bytes_before'])} -> "
+ f"{_fmt_bytes(res['bytes_after'])} "
+ f"({res['file_pct']:.1f}% smaller, "
+ f"{res['file_bytes_freed']} bytes freed)")
+ print(f"context: {res['context_est_tokens_freed']} estimated tokens freed")
if res["validation_errors"]:
print("VALIDATION FAILED:", file=sys.stderr)
for e in res["validation_errors"]:
diff --git a/plugins/ca-pi/skills/ca-prune/SKILL.md b/plugins/ca-pi/skills/ca-prune/SKILL.md
index 62fa96a..0a73def 100644
--- a/plugins/ca-pi/skills/ca-prune/SKILL.md
+++ b/plugins/ca-pi/skills/ca-prune/SKILL.md
@@ -26,8 +26,14 @@ Pi session file.
`~/.codearbiter/metrics/prune-dry.jsonl` (override with `CODEARBITER_PRUNE_METRICS`). That log is
the evidence base for the `dry`→`on` decision: a clean record (every row `verdict: dry-run`,
`validation_errors: 0`) over a representative set of sessions is the signal that enabling is safe.
+ Read `context_bytes_freed` / `context_est_tokens_freed` for model-context benefit and
+ `file_bytes_freed` / `file_pct` for disk and resume-parse benefit. `sidecar-collapse` is explicitly
+ `file-only`; it must not count toward the context-benefit or cold-cache decision.
+ The legacy `freed_bytes`, `pct`, and `est_tokens_before` / `est_tokens_after` fields remain
+ whole-file compatibility aliases; do not use them as model-context evidence.
- `dry` — create a read-only semantic plan (or analyze a scratch copy where the host exposes a
- serialized transcript); present the per-strategy reduction table. Never writes the active session.
+ serialized transcript); present the per-strategy reduction table with every strategy labeled
+ `context` or `file-only`. Never writes the active session.
- `run ` — prune the target with `--execute`. Targets a **copy or an old/inactive
transcript only** — the tool refuses an active or recently-modified target by construction.
- `audit ` — read-only integrity report: line-parse, uuid chain, tool-pair coverage,
@@ -65,10 +71,12 @@ Pi session file.
**Cold-miss nudge** [Feature Forge — `preview`]: when `CODEARBITER_PRUNE` is `on`, an
optional submit-time speed bump warns once before a cold cache re-cache lands on bloated
context. Enable with `CODEARBITER_PRUNE_NUDGE=on` (default `off`). When all arming conditions
- hold (idle ≥ `CODEARBITER_PRUNE_NUDGE_IDLE_SECS`, default 240 s; estimated freed tokens ≥
+ hold (idle ≥ `CODEARBITER_PRUNE_NUDGE_IDLE_SECS`, default 240 s; estimated model-context
+ tokens freed ≥
`CODEARBITER_PRUNE_NUDGE_MIN_TOKENS`, default 80 000), the hook blocks the submit once with
- an advisory on stderr and returns exit code 2. The advisory names the approximate token count,
- saving percentage, and the host-native actions that move the re-cache to pruned context:
+ an advisory on stderr and returns exit code 2. File-only sidecar reduction never arms it. The
+ advisory names the approximate avoidable context-token count and the host-native actions that
+ move the re-cache to pruned context:
native compaction or a normal exit + resume/restart. Resubmitting immediately proceeds. The block fires at most
once per cold window; a subsequent warm submit (idle < floor) resets the window so the next
genuine cold stretch re-arms. The gate is strictly opt-in, never fires in `dry`/`off` mode,
diff --git a/plugins/ca/README.md b/plugins/ca/README.md
index fd0b912..b3d57e3 100644
--- a/plugins/ca/README.md
+++ b/plugins/ca/README.md
@@ -29,8 +29,9 @@ Full documentation, install instructions, and the command catalog live in the
Some features ship in the box but aren't yet *blessed*: off by default, fully dormant until you opt
in, labeled `preview` until real-world data earns them a stable promotion. In the forge now: **live
transcript pruning** (`CODEARBITER_PRUNE`), which trims redundant transcript clutter to extend a
-session. Run it in `dry` mode (`export CODEARBITER_PRUNE=dry`) and it logs what it *would* prune
-(sizes and verdicts only, no transcript content) to `~/.codearbiter/metrics/prune-dry.jsonl`. Sending
+session. Run it in `dry` mode (`export CODEARBITER_PRUNE=dry`) and it logs what it *would* prune,
+separating model-context savings from file-only sidecar cleanup (aggregate sizes and verdicts only,
+no transcript content), to `~/.codearbiter/metrics/prune-dry.jsonl`. Sending
that log back ([open a prune-data issue](https://github.com/arbiterForge/codeArbiter/issues/new?title=Feature+Forge%3A+prune+data&labels=feature-forge,prune))
is what moves it toward `on`. Also in the forge: the **farm** (`/ca:sprint --farm`, needs
`FARM_API_KEY`), a pluggable backend that runs the *implementation* step on cheap OpenAI-compatible
diff --git a/plugins/ca/commands/prune.md b/plugins/ca/commands/prune.md
index a5e42a3..f7deb0e 100644
--- a/plugins/ca/commands/prune.md
+++ b/plugins/ca/commands/prune.md
@@ -25,8 +25,14 @@ Pi session file.
`~/.codearbiter/metrics/prune-dry.jsonl` (override with `CODEARBITER_PRUNE_METRICS`). That log is
the evidence base for the `dry`→`on` decision: a clean record (every row `verdict: dry-run`,
`validation_errors: 0`) over a representative set of sessions is the signal that enabling is safe.
+ Read `context_bytes_freed` / `context_est_tokens_freed` for model-context benefit and
+ `file_bytes_freed` / `file_pct` for disk and resume-parse benefit. `sidecar-collapse` is explicitly
+ `file-only`; it must not count toward the context-benefit or cold-cache decision.
+ The legacy `freed_bytes`, `pct`, and `est_tokens_before` / `est_tokens_after` fields remain
+ whole-file compatibility aliases; do not use them as model-context evidence.
- `dry` — create a read-only semantic plan (or analyze a scratch copy where the host exposes a
- serialized transcript); present the per-strategy reduction table. Never writes the active session.
+ serialized transcript); present the per-strategy reduction table with every strategy labeled
+ `context` or `file-only`. Never writes the active session.
- `run ` — prune the target with `--execute`. Targets a **copy or an old/inactive
transcript only** — the tool refuses an active or recently-modified target by construction.
- `audit ` — read-only integrity report: line-parse, uuid chain, tool-pair coverage,
@@ -64,10 +70,12 @@ Pi session file.
**Cold-miss nudge** [Feature Forge — `preview`]: when `CODEARBITER_PRUNE` is `on`, an
optional submit-time speed bump warns once before a cold cache re-cache lands on bloated
context. Enable with `CODEARBITER_PRUNE_NUDGE=on` (default `off`). When all arming conditions
- hold (idle ≥ `CODEARBITER_PRUNE_NUDGE_IDLE_SECS`, default 240 s; estimated freed tokens ≥
+ hold (idle ≥ `CODEARBITER_PRUNE_NUDGE_IDLE_SECS`, default 240 s; estimated model-context
+ tokens freed ≥
`CODEARBITER_PRUNE_NUDGE_MIN_TOKENS`, default 80 000), the hook blocks the submit once with
- an advisory on stderr and returns exit code 2. The advisory names the approximate token count,
- saving percentage, and the host-native actions that move the re-cache to pruned context:
+ an advisory on stderr and returns exit code 2. File-only sidecar reduction never arms it. The
+ advisory names the approximate avoidable context-token count and the host-native actions that
+ move the re-cache to pruned context:
native compaction or a normal exit + resume/restart. Resubmitting immediately proceeds. The block fires at most
once per cold window; a subsequent warm submit (idle < floor) resets the window so the next
genuine cold stretch re-arms. The gate is strictly opt-in, never fires in `dry`/`off` mode,
diff --git a/plugins/ca/hooks/_prunelib.py b/plugins/ca/hooks/_prunelib.py
index 332b48b..357d97a 100644
--- a/plugins/ca/hooks/_prunelib.py
+++ b/plugins/ca/hooks/_prunelib.py
@@ -202,7 +202,12 @@ def _is_small_scalar(v, limit=200):
# --------------------------------------------------------------------------- #
def _record(report, name, touched, before, after):
- report[name] = {"lines": touched, "bytes_before": before, "bytes_after": after}
+ report[name] = {
+ "lines": touched,
+ "bytes_before": before,
+ "bytes_after": after,
+ "metric_scope": _policy.STRATEGY_METRIC_SCOPES[name],
+ }
def s_sidecar_collapse(lines, index, cfg, report):
@@ -224,13 +229,15 @@ def s_sidecar_collapse(lines, index, cfg, report):
new = dict(kept)
new["_ca_condensed"] = _marker(orig)
new_s = _dumps(new)
- if len(new_s) >= len(orig): # net-negative guard
+ if len(new_s) >= len(orig): # preserve legacy transformation eligibility
continue
+ orig_size = len(orig.encode("utf-8"))
+ new_size = len(new_s.encode("utf-8"))
ln.obj["toolUseResult"] = new
ln.dirty = True
touched += 1
- before += len(orig)
- after += len(new_s)
+ before += orig_size
+ after += new_size
_record(report, "sidecar-collapse", touched, before, after)
@@ -348,7 +355,7 @@ def s_reasoning_fold(lines, index, cfg, report):
if not keep: # never empty a message
continue
for b in thinking:
- before += len(_dumps(b))
+ before += len(_dumps(b).encode("utf-8"))
ln.obj["message"]["content"] = keep
ln.dirty = True
touched += 1
@@ -406,11 +413,14 @@ def s_mcp_payload_condense(lines, index, cfg, report):
if isinstance(inp, dict) else {})
new = dict(kept)
new["_ca_condensed"] = _marker(orig)
- if len(_dumps(new)) >= len(orig):
+ new_s = _dumps(new)
+ if len(new_s) >= len(orig): # preserve legacy transformation eligibility
continue
+ orig_size = len(orig.encode("utf-8"))
+ new_size = len(new_s.encode("utf-8"))
blk["input"] = new
- before += len(orig)
- after += len(_dumps(new))
+ before += orig_size
+ after += new_size
changed = True
if changed:
ln.dirty = True
@@ -1112,14 +1122,12 @@ def _idle_seconds(data, now):
def _nudge_advisory(rec):
"""Build the advisory string from state-record numbers only.
- Never includes transcript content — only sizes / est-tokens / percent."""
- freed = int(rec.get("freed_bytes", 0) or 0)
- pruned = int(rec.get("last_pruned_size", 0) or 0)
- approx_tokens = est_tokens(pruned + freed) # ~ in-memory (bloated) size
- pct = rec.get("pct", 0)
- return (f"Cold cache miss imminent on ~{approx_tokens // 1000}k tokens. "
- f"/compact or exit + --resume lands that re-cache on pruned context "
- f"(~{pct}% smaller). Submit again to proceed.")
+ Never includes transcript content — only context-byte/token aggregates."""
+ context_freed = int(rec.get("context_bytes_freed", 0) or 0)
+ context_tokens = est_tokens(context_freed)
+ return (f"Cold cache miss can re-cache ~{context_tokens // 1000}k avoidable "
+ f"context tokens. /compact or exit + --resume lands that re-cache "
+ f"on pruned context. Submit again to proceed.")
def nudge_decision(rec, idle_secs, e):
@@ -1151,8 +1159,8 @@ def _num(key, default):
return (False, "", nr)
return (False, "", rec)
- freed = rec.get("freed_bytes", 0)
- if not isinstance(freed, (int, float)) or est_tokens(int(freed)) < min_tokens:
+ freed = rec.get("context_bytes_freed")
+ if type(freed) is not int or freed < 0 or est_tokens(freed) < min_tokens:
return (False, "", rec)
if rec.get("cold_nudged"): # already fired this cold window
@@ -1252,7 +1260,11 @@ def hook_run(payload, env=None):
except Exception: # noqa: BLE001 — never let pruning break the turn
return 0
b0, b1 = res["bytes_before"], res["bytes_after"]
- reduction = _policy.reduction_metrics(b0, b1)
+ strategy_savings = {
+ name: row["bytes_before"] - row["bytes_after"]
+ for name, row in res["strategies"].items()
+ }
+ reduction = _policy.reduction_metrics(b0, b1, strategy_savings)
if not cfg.execute:
# Dry mode: persist the would-be outcome to the shared data-collection
# log so confidence accrues across sessions before flipping to on.
@@ -1266,6 +1278,8 @@ def hook_run(payload, env=None):
"validation_errors": len(res["validation_errors"]),
"strategies": {k: v["bytes_before"] - v["bytes_after"]
for k, v in res["strategies"].items()},
+ "strategy_scopes": {k: v["metric_scope"]
+ for k, v in res["strategies"].items()},
}, env=e)
# Carry cold_nudged forward so the once-per-cold-window marker survives a
# normal (non-armed) prune run. Re-read from state in case the nudge block
@@ -1278,6 +1292,11 @@ def hook_run(payload, env=None):
"last_run_ts": int(time.time()),
"pct": reduction["pct"],
"freed_bytes": reduction["freed_bytes"],
+ "file_pct": reduction["file_pct"],
+ "file_bytes_freed": reduction["file_bytes_freed"],
+ "file_est_tokens_freed": reduction["file_est_tokens_freed"],
+ "context_bytes_freed": reduction["context_bytes_freed"],
+ "context_est_tokens_freed": reduction["context_est_tokens_freed"],
"verdict": res["verdict"],
}
if _cold_nudged:
@@ -1335,13 +1354,15 @@ def run(path, cfg, session="session", data=None, lines=None, pre_stat=None):
new_bytes = serialize(lines)
errs = validate(orig_bytes, new_bytes, lines, cfg)
- reduction = _policy.reduction_metrics(len(orig_bytes), len(new_bytes))
+ strategy_savings = {
+ name: row["bytes_before"] - row["bytes_after"]
+ for name, row in report.items()
+ }
+ reduction = _policy.reduction_metrics(
+ len(orig_bytes), len(new_bytes), strategy_savings)
result = {
"path": path,
- "bytes_before": reduction["bytes_before"],
- "bytes_after": reduction["bytes_after"],
- "est_tokens_before": reduction["est_tokens_before"],
- "est_tokens_after": reduction["est_tokens_after"],
+ **reduction,
"strategies": report,
"validation_errors": errs,
"executed": False,
@@ -1357,9 +1378,10 @@ def run(path, cfg, session="session", data=None, lines=None, pre_stat=None):
append_audit_log({
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"session": session, "path": path,
- "bytes_before": len(orig_bytes), "bytes_after": len(new_bytes),
+ **reduction,
"strategies": {k: v["bytes_before"] - v["bytes_after"]
for k, v in report.items()},
+ "strategy_scopes": {k: v["metric_scope"] for k, v in report.items()},
"verdict": verdict,
})
return result
diff --git a/plugins/ca/hooks/_prunepolicy.py b/plugins/ca/hooks/_prunepolicy.py
index 25aa1b0..eb6305d 100644
--- a/plugins/ca/hooks/_prunepolicy.py
+++ b/plugins/ca/hooks/_prunepolicy.py
@@ -7,8 +7,9 @@
"""
#
# Public API:
-# reduction_metrics(bytes_before, bytes_after) -> dict dry/run reduction metrics
-# with the shared token estimate
+# reduction_metrics(bytes_before, bytes_after, strategy_savings) -> dict
+# file and model-context reductions
+# with shared token estimates
# audit_outcomes(parse_errors, orphans, unpaired) -> tuple host-neutral integrity
# levels; codecs retain native messages
# marker_for(original_text) -> str deterministic condensed-content marker;
@@ -50,6 +51,17 @@
"aged-result-condense",
"oversize-result-clamp",
)
+STRATEGY_METRIC_SCOPES = {
+ "sidecar-collapse": "file-only",
+ "reasoning-fold": "context",
+ "mcp-payload-condense": "context",
+ "shell-tail-keep": "context",
+ "superseded-read-condense": "context",
+ "repeat-reminder-fold": "context",
+ "inline-image-evict": "context",
+ "aged-result-condense": "context",
+ "oversize-result-clamp": "context",
+}
@dataclass(frozen=True)
@@ -85,19 +97,36 @@ class PrunePlan:
STANDARD_POLICY = PrunePolicy(tier="standard")
-def reduction_metrics(bytes_before, bytes_after):
- """Host-neutral dry/run reduction metrics with the shared token estimate."""
+def reduction_metrics(bytes_before, bytes_after, strategy_savings):
+ """Host-neutral file and model-context reduction metrics."""
if type(bytes_before) is not int or type(bytes_after) is not int \
or bytes_before < 0 or bytes_after < 0:
raise ValueError("prune byte metrics are invalid")
+ if not isinstance(strategy_savings, dict):
+ raise ValueError("prune strategy savings are invalid")
+ context_bytes_freed = 0
+ for name, value in strategy_savings.items():
+ if name not in STRATEGY_METRIC_SCOPES or type(value) is not int or value < 0:
+ raise ValueError("prune strategy savings are invalid")
+ if STRATEGY_METRIC_SCOPES[name] == "context":
+ context_bytes_freed += value
+ file_bytes_freed = bytes_before - bytes_after
+ file_pct = (round(100.0 * file_bytes_freed / bytes_before, 1)
+ if bytes_before else 0.0)
return {
"bytes_before": bytes_before,
"bytes_after": bytes_after,
- "freed_bytes": bytes_before - bytes_after,
+ "freed_bytes": file_bytes_freed,
"est_tokens_before": bytes_before // 4,
"est_tokens_after": bytes_after // 4,
- "pct": round(100.0 * (bytes_before - bytes_after) / bytes_before, 1)
- if bytes_before else 0.0,
+ "pct": file_pct,
+ "file_bytes_freed": file_bytes_freed,
+ "file_est_tokens_before": bytes_before // 4,
+ "file_est_tokens_after": bytes_after // 4,
+ "file_est_tokens_freed": file_bytes_freed // 4,
+ "file_pct": file_pct,
+ "context_bytes_freed": context_bytes_freed,
+ "context_est_tokens_freed": context_bytes_freed // 4,
}
diff --git a/plugins/ca/hooks/_segmentslib.py b/plugins/ca/hooks/_segmentslib.py
index 45fe8c3..48371c3 100644
--- a/plugins/ca/hooks/_segmentslib.py
+++ b/plugins/ca/hooks/_segmentslib.py
@@ -242,7 +242,7 @@ def seg_pr(data):
def seg_prune(data, sid):
- """Transcript-pruner indicator: cumulative reduction and age of the last
+ """Transcript-pruner indicator: model-context reduction and age of the last
prune for this session, read from ~/.codearbiter/prune-state.json (written
by prune-transcript.py). Fail-soft — returns None on any problem, and never
raises, so it can never break statusline rendering."""
@@ -255,11 +255,16 @@ def seg_prune(data, sid):
rec = st.get(sid) if isinstance(st, dict) else None
if not isinstance(rec, dict):
return None
- pct = rec.get("pct") or 0
- if pct <= 0:
+ file_freed = num(rec.get("file_bytes_freed", rec.get("freed_bytes")), 0)
+ if file_freed <= 0:
return None
age = human_dur(max(0, time.time() - num(rec.get("last_run_ts"), time.time())))
- return f"{GREY}✂{RESET} {WHITE}{pct:.0f}%{RESET} {GREY}{age}{RESET}"
+ if "context_est_tokens_freed" not in rec:
+ pct = num(rec.get("file_pct", rec.get("pct")), 0)
+ return f"{GREY}✂{RESET} {WHITE}file:{pct:.0f}%{RESET} {GREY}{age}{RESET}"
+ context_tokens = max(0, num(rec.get("context_est_tokens_freed"), 0))
+ return (f"{GREY}✂{RESET} {WHITE}ctx:{fmt_tok(context_tokens)}{RESET} "
+ f"{GREY}{age}{RESET}")
except Exception: # noqa: BLE001
return None
diff --git a/plugins/ca/hooks/prune-transcript.py b/plugins/ca/hooks/prune-transcript.py
index b4f61f3..60723a2 100644
--- a/plugins/ca/hooks/prune-transcript.py
+++ b/plugins/ca/hooks/prune-transcript.py
@@ -94,15 +94,18 @@ def _fmt_bytes(n):
def print_report(res):
rows = res["strategies"]
if rows:
- print(f"{'strategy':<24}{'lines':>7}{'before':>12}{'after':>12}{'est≈ saved':>12}")
+ print(f"{'strategy':<24}{'scope':>11}{'lines':>7}{'before':>12}"
+ f"{'after':>12}{'est≈ saved':>12}")
for name, r in rows.items():
saved = est_tokens(r["bytes_before"] - r["bytes_after"])
- print(f"{name:<24}{r['lines']:>7}{r['bytes_before']:>12}"
+ print(f"{name:<24}{r['metric_scope']:>11}{r['lines']:>7}"
+ f"{r['bytes_before']:>12}"
f"{r['bytes_after']:>12}{saved:>12}")
- b0, b1 = res["bytes_before"], res["bytes_after"]
- pct = (100.0 * (b0 - b1) / b0) if b0 else 0.0
- print(f"\ntotal: {_fmt_bytes(b0)} -> {_fmt_bytes(b1)} "
- f"({pct:.1f}% smaller, est≈{est_tokens(b0 - b1)} tokens freed)")
+ print(f"\nfile: {_fmt_bytes(res['bytes_before'])} -> "
+ f"{_fmt_bytes(res['bytes_after'])} "
+ f"({res['file_pct']:.1f}% smaller, "
+ f"{res['file_bytes_freed']} bytes freed)")
+ print(f"context: {res['context_est_tokens_freed']} estimated tokens freed")
if res["validation_errors"]:
print("VALIDATION FAILED:", file=sys.stderr)
for e in res["validation_errors"]:
diff --git a/plugins/ca/hooks/tests/test_hook.py b/plugins/ca/hooks/tests/test_hook.py
index 85044a5..77dcf12 100644
--- a/plugins/ca/hooks/tests/test_hook.py
+++ b/plugins/ca/hooks/tests/test_hook.py
@@ -168,8 +168,28 @@ def test_dry_mode_appends_metrics_and_never_writes_transcript(self):
self.assertEqual(r["validation_errors"], 0)
self.assertGreater(r["bytes_before"], r["bytes_after"])
self.assertIn("strategies", r)
+ self.assertIn("file_bytes_freed", r)
+ self.assertIn("context_bytes_freed", r)
+ self.assertIn("context_est_tokens_freed", r)
+ self.assertIn("strategy_scopes", r)
self.assertIn("ts", r)
+ def test_sidecar_only_dry_metrics_report_zero_context_savings(self):
+ before = os.path.getsize(self.path)
+ rc = P.hook_run(self.payload(), env=self.env(
+ CODEARBITER_PRUNE="dry",
+ CODEARBITER_PRUNE_STRATEGIES="sidecar-collapse",
+ ))
+ self.assertEqual(rc, 0)
+ self.assertEqual(os.path.getsize(self.path), before)
+ record = self._read_jsonl(P.dry_metrics_path(self.env()))[0]
+ self.assertGreater(record["file_bytes_freed"], 0)
+ self.assertEqual(record["context_bytes_freed"], 0)
+ self.assertEqual(record["context_est_tokens_freed"], 0)
+ self.assertEqual(record["strategy_scopes"], {
+ "sidecar-collapse": "file-only",
+ })
+
def test_dry_only_on_mode_does_not_write_dry_metrics(self):
rc = P.hook_run(self.payload(), env=self.env(CODEARBITER_PRUNE="on"))
self.assertEqual(rc, 0)
diff --git a/plugins/ca/hooks/tests/test_prune_cli.py b/plugins/ca/hooks/tests/test_prune_cli.py
index e9eb5cf..b070daf 100644
--- a/plugins/ca/hooks/tests/test_prune_cli.py
+++ b/plugins/ca/hooks/tests/test_prune_cli.py
@@ -147,6 +147,48 @@ def test_report_contains_token_estimate(self):
or "≈" in output)
+class TestPruneReductionReport(unittest.TestCase):
+ def test_file_and_context_savings_are_labeled_separately(self):
+ result = {
+ "bytes_before": 1000,
+ "bytes_after": 600,
+ "file_bytes_freed": 400,
+ "file_est_tokens_freed": 100,
+ "file_pct": 40.0,
+ "context_bytes_freed": 100,
+ "context_est_tokens_freed": 25,
+ "strategies": {
+ "sidecar-collapse": {
+ "lines": 1,
+ "bytes_before": 400,
+ "bytes_after": 100,
+ "metric_scope": "file-only",
+ },
+ "aged-result-condense": {
+ "lines": 1,
+ "bytes_before": 200,
+ "bytes_after": 100,
+ "metric_scope": "context",
+ },
+ },
+ "validation_errors": [],
+ "verdict": "dry-run",
+ }
+ output = io.StringIO()
+ old_stdout = sys.stdout
+ sys.stdout = output
+ try:
+ pt.print_report(result)
+ finally:
+ sys.stdout = old_stdout
+ rendered = output.getvalue()
+ self.assertIn("scope", rendered)
+ self.assertIn("file-only", rendered)
+ self.assertIn("file:", rendered)
+ self.assertIn("context:", rendered)
+ self.assertIn("25 estimated tokens freed", rendered)
+
+
class TestIsLive(unittest.TestCase):
"""is_live(path): True for recently-modified file, False for old one."""
diff --git a/plugins/ca/hooks/tests/test_prune_nudge.py b/plugins/ca/hooks/tests/test_prune_nudge.py
index 3789fd3..0381d6a 100644
--- a/plugins/ca/hooks/tests/test_prune_nudge.py
+++ b/plugins/ca/hooks/tests/test_prune_nudge.py
@@ -46,10 +46,11 @@ def _make_transcript_with_ts(n_pairs=4, result_bytes=5000,
def _big_rec(cold_nudged=False):
- """A session state record with a large freed_bytes (> 80 000 tokens @ 4 B)."""
+ """A state record with large context savings (> 80 000 tokens at 4 B)."""
# 80 000 tokens * 4 bytes/token = 320 000 bytes freed — well above the floor.
return {
"freed_bytes": 400_000,
+ "context_bytes_freed": 400_000,
"last_pruned_size": 1_200_000,
"pct": 33.0,
"cold_nudged": cold_nudged,
@@ -139,13 +140,27 @@ def test_transcript_untouched_in_dry_mode(self):
# ---------------------------------------------------------------------------
-# O3 — cold + freed_bytes below floor → not-armed
+# O3 — cold + context_bytes_freed below floor → not-armed
# ---------------------------------------------------------------------------
class TestO3SmallDelta(unittest.TestCase):
- def test_below_floor_freed_bytes(self):
- # est_tokens(freed_bytes) < 80 000 when freed_bytes < 320 000
- rec = {"freed_bytes": 100_000, "last_pruned_size": 500_000, "pct": 20.0}
+ def test_file_only_savings_never_arm_context_recache_nudge(self):
+ rec = {
+ "freed_bytes": 800_000,
+ "context_bytes_freed": 0,
+ "last_pruned_size": 1_000_000,
+ "pct": 44.4,
+ }
+ armed, advisory, _ = P.nudge_decision(
+ rec, idle_secs=300,
+ e={"CODEARBITER_PRUNE_NUDGE": "on"})
+ self.assertFalse(armed)
+ self.assertEqual(advisory, "")
+
+ def test_below_floor_context_bytes(self):
+ # est_tokens(context_bytes_freed) stays below the 80 000-token floor.
+ rec = {"freed_bytes": 500_000, "context_bytes_freed": 100_000,
+ "last_pruned_size": 500_000, "pct": 20.0}
armed, _, _ = P.nudge_decision(
rec, idle_secs=300,
e={"CODEARBITER_PRUNE_NUDGE": "on"})
@@ -153,15 +168,17 @@ def test_below_floor_freed_bytes(self):
def test_exactly_at_floor_tokens_passes(self):
# 80 000 tokens * 4 = 320 000 bytes — on the threshold; should arm
- rec = {"freed_bytes": 320_000, "last_pruned_size": 1_000_000, "pct": 32.0}
+ rec = {"freed_bytes": 900_000, "context_bytes_freed": 320_000,
+ "last_pruned_size": 1_000_000, "pct": 32.0}
armed, _, _ = P.nudge_decision(
rec, idle_secs=300,
e={"CODEARBITER_PRUNE_NUDGE": "on"})
self.assertTrue(armed)
def test_custom_min_tokens_env(self):
- # With MIN_TOKENS=50 000 tokens, freed_bytes=210_000 (52 500 tokens) arms.
- rec = {"freed_bytes": 210_000, "last_pruned_size": 600_000, "pct": 35.0}
+ # With MIN_TOKENS=50 000, 210_000 context bytes (52 500 tokens) arms.
+ rec = {"freed_bytes": 700_000, "context_bytes_freed": 210_000,
+ "last_pruned_size": 600_000, "pct": 35.0}
armed, _, _ = P.nudge_decision(
rec, idle_secs=300,
e={"CODEARBITER_PRUNE_NUDGE": "on",
@@ -270,14 +287,19 @@ def test_nudge_decision_none_rec(self):
except Exception as ex:
self.fail(f"nudge_decision(None, ...) raised {ex!r}")
- def test_nudge_decision_freed_bytes_non_int(self):
- rec = {"freed_bytes": "not-a-number", "pct": 10.0}
- try:
- armed, _, _ = P.nudge_decision(rec, idle_secs=300,
- e={"CODEARBITER_PRUNE_NUDGE": "on"})
- self.assertFalse(armed)
- except Exception as ex:
- self.fail(f"nudge_decision with bad freed_bytes raised {ex!r}")
+ def test_nudge_decision_invalid_context_bytes_fail_open(self):
+ for value in ("not-a-number", True, float("nan"), -1):
+ with self.subTest(value=value):
+ rec = {"freed_bytes": 400_000,
+ "context_bytes_freed": value, "pct": 10.0}
+ try:
+ armed, _, _ = P.nudge_decision(
+ rec, idle_secs=300,
+ e={"CODEARBITER_PRUNE_NUDGE": "on"})
+ self.assertFalse(armed)
+ except Exception as ex:
+ self.fail(
+ f"nudge_decision with bad context_bytes_freed raised {ex!r}")
def test_last_assistant_ts_bad_json(self):
result = P._last_assistant_ts(b"{bad json\n")
@@ -331,23 +353,26 @@ def _boom():
# ---------------------------------------------------------------------------
class TestO9Advisory(unittest.TestCase):
- def test_advisory_contains_token_count_and_pct(self):
- rec = {"freed_bytes": 400_000, "last_pruned_size": 800_000,
- "pct": 33.0}
+ def test_advisory_contains_context_token_count(self):
+ rec = {"freed_bytes": 800_000, "context_bytes_freed": 400_000,
+ "last_pruned_size": 800_000, "pct": 33.0}
advisory = P._nudge_advisory(rec)
- # ~(400_000 + 800_000) / 4 = 300_000 tokens → 300k
- self.assertIn("300k", advisory)
- self.assertIn("33", advisory)
+ # 400_000 model-visible bytes / 4 = 100_000 tokens.
+ self.assertIn("100k", advisory)
+ self.assertIn("context tokens", advisory)
+ self.assertNotIn("33", advisory)
def test_advisory_contains_action_words(self):
- rec = {"freed_bytes": 400_000, "last_pruned_size": 800_000, "pct": 33.0}
+ rec = {"freed_bytes": 800_000, "context_bytes_freed": 400_000,
+ "last_pruned_size": 800_000, "pct": 33.0}
advisory = P._nudge_advisory(rec)
self.assertIn("/compact", advisory)
self.assertIn("--resume", advisory)
self.assertIn("Submit again", advisory)
def test_advisory_no_transcript_filler(self):
- rec = {"freed_bytes": 400_000, "last_pruned_size": 800_000, "pct": 33.0}
+ rec = {"freed_bytes": 800_000, "context_bytes_freed": 400_000,
+ "last_pruned_size": 800_000, "pct": 33.0}
advisory = P._nudge_advisory(rec)
# The filler used in make_transcript is "X" * result_bytes
self.assertNotIn("X" * 20, advisory,
@@ -355,7 +380,8 @@ def test_advisory_no_transcript_filler(self):
def test_advisory_pure_function_of_numbers(self):
# Same rec → same advisory regardless of call order
- rec = {"freed_bytes": 500_000, "last_pruned_size": 1_000_000, "pct": 50.0}
+ rec = {"freed_bytes": 900_000, "context_bytes_freed": 500_000,
+ "last_pruned_size": 1_000_000, "pct": 50.0}
a1 = P._nudge_advisory(rec)
a2 = P._nudge_advisory(rec)
self.assertEqual(a1, a2)
@@ -514,7 +540,7 @@ def test_idle_seconds_none_when_ts_absent(self):
class _ArmedHarness(unittest.TestCase):
"""setUp builds an arbiter repo + a cold transcript whose state record has a
- large freed_bytes and a SMALL last_pruned_size (so a later warm submit's
+ large context_bytes_freed and a SMALL last_pruned_size (so a later warm submit's
grown transcript actually prunes, exercising the carry-forward path)."""
def setUp(self):
@@ -528,7 +554,8 @@ def setUp(self):
os.makedirs(self.claude_dir, exist_ok=True)
self.path = os.path.join(self.claude_dir, "sess.jsonl")
self._write_transcript("2026-06-17T00:00:00Z") # cold
- P.save_state({"sess": {"freed_bytes": 400_000,
+ P.save_state({"sess": {"freed_bytes": 800_000,
+ "context_bytes_freed": 400_000,
"last_pruned_size": 1000, "pct": 33.0}})
def tearDown(self):
diff --git a/plugins/ca/hooks/tests/test_prune_status_metric.py b/plugins/ca/hooks/tests/test_prune_status_metric.py
new file mode 100644
index 0000000..d621cac
--- /dev/null
+++ b/plugins/ca/hooks/tests/test_prune_status_metric.py
@@ -0,0 +1,63 @@
+"""Statusline contract for context-relevant prune savings."""
+
+import json
+import os
+import re
+import sys
+import tempfile
+import time
+import unittest
+
+
+_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
+_HOOKS_DIR = os.path.dirname(_TESTS_DIR)
+sys.path.insert(0, _HOOKS_DIR)
+sys.path.insert(0, _TESTS_DIR)
+
+import _segmentslib as S # noqa: E402
+from _helpers import redirect_home, restore_home # noqa: E402
+
+
+ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
+
+
+class PruneStatusMetricTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.saved_home = redirect_home(self.tmp.name)
+ os.makedirs(os.path.join(self.tmp.name, ".codearbiter"))
+
+ def tearDown(self):
+ restore_home(self.saved_home)
+ self.tmp.cleanup()
+
+ def _render(self, record):
+ path = os.path.join(self.tmp.name, ".codearbiter", "prune-state.json")
+ with open(path, "w", encoding="utf-8") as handle:
+ json.dump({"session": record}, handle)
+ return ANSI_RE.sub("", S.seg_prune({}, "session") or "")
+
+ def test_new_records_render_context_tokens_not_file_percentage(self):
+ rendered = self._render({
+ "file_bytes_freed": 800_000,
+ "file_pct": 40.0,
+ "context_bytes_freed": 8_000,
+ "context_est_tokens_freed": 2_000,
+ "last_run_ts": time.time(),
+ })
+ self.assertIn("ctx:2.0K", rendered)
+ self.assertNotIn("40%", rendered)
+
+ def test_sidecar_only_record_makes_zero_context_savings_visible(self):
+ rendered = self._render({
+ "file_bytes_freed": 800_000,
+ "file_pct": 40.0,
+ "context_bytes_freed": 0,
+ "context_est_tokens_freed": 0,
+ "last_run_ts": time.time(),
+ })
+ self.assertIn("ctx:0", rendered)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/plugins/ca/hooks/tests/test_strategies.py b/plugins/ca/hooks/tests/test_strategies.py
index 10ea950..adf9e27 100644
--- a/plugins/ca/hooks/tests/test_strategies.py
+++ b/plugins/ca/hooks/tests/test_strategies.py
@@ -24,6 +24,7 @@ def test_sidecar_collapse_shrinks_and_keeps_scalars(self):
out, report, errs = prune(data, strategies=["sidecar-collapse"], keep_recent=0)
self.assertEqual(errs, [])
self.assertGreater(report["sidecar-collapse"]["lines"], 0)
+ self.assertEqual(report["sidecar-collapse"]["metric_scope"], "file-only")
# The condensed sidecar keeps small scalars and drops bulk.
objs = P._parts_objs(out)
u2 = next(o for o in objs if isinstance(o, dict) and o.get("uuid") == "u2")
@@ -34,12 +35,25 @@ def test_sidecar_collapse_shrinks_and_keeps_scalars(self):
self.assertNotIn("prompt", tur) # bulky string dropped
self.assertLess(len(out), len(data))
+ def test_sidecar_net_negative_eligibility_is_unchanged_for_unicode(self):
+ data = (
+ '{"type":"user","uuid":"u1","parentUuid":null,'
+ '"toolUseResult":"' + ("😀" * 20) + '",'
+ '"message":{"role":"user","content":"x"}}\n'
+ ).encode("utf-8")
+ out, report, errs = prune(
+ data, strategies=["sidecar-collapse"], keep_recent=0)
+ self.assertEqual(errs, [])
+ self.assertEqual(out, data)
+ self.assertEqual(report["sidecar-collapse"]["lines"], 0)
+
def test_oversize_result_clamp_truncates_list_text(self):
data = fixture("synthetic-small.jsonl")
out, report, errs = prune(data, strategies=["oversize-result-clamp"],
keep_recent=0, max_bytes=40)
self.assertEqual(errs, [])
self.assertGreater(report["oversize-result-clamp"]["lines"], 0)
+ self.assertEqual(report["oversize-result-clamp"]["metric_scope"], "context")
objs = P._parts_objs(out)
u2 = next(o for o in objs if isinstance(o, dict) and o.get("uuid") == "u2")
text = u2["message"]["content"][0]["content"][0]["text"]
diff --git a/plugins/ca/hooks/tests/test_strategies2.py b/plugins/ca/hooks/tests/test_strategies2.py
index d114305..4530355 100644
--- a/plugins/ca/hooks/tests/test_strategies2.py
+++ b/plugins/ca/hooks/tests/test_strategies2.py
@@ -77,6 +77,23 @@ def test_mcp_payload_condense(self):
self.assertEqual(inp.get("n"), 3) # small scalar kept
self.assertNotIn("query", inp) # bulky dropped
+ def test_mcp_net_negative_eligibility_is_unchanged_for_unicode(self):
+ objs = [
+ {"type": "user", "uuid": "u0", "parentUuid": None,
+ "message": {"role": "user", "content": "go"}},
+ asst("a0", "u0", [{"type": "tool_use", "id": "t0",
+ "name": "mcp__github__search_code",
+ "input": {"payload": [chr(0x1F600) * 20]}}]),
+ user_result("u1", "a0", "t0", "ok"),
+ asst("afinal", "u1", [{"type": "text", "text": "done"}]),
+ ]
+ data = to_bytes(objs)
+ out, report, errs = prune(
+ data, strategies=["mcp-payload-condense"], keep_recent=0)
+ self.assertEqual(errs, [])
+ self.assertEqual(out, data)
+ self.assertEqual(report["mcp-payload-condense"]["lines"], 0)
+
def test_shell_tail_keep_keeps_verdict(self):
body = "\n".join(f"line {i}" for i in range(200)) + "\nEXIT 0"
objs = [