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 = [