@arbiterforge/ca-pi 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +558 -0
- package/package.json +35 -0
- package/plugins/ca-pi/CHANGELOG.md +1030 -0
- package/plugins/ca-pi/COMMANDS.md +90 -0
- package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
- package/plugins/ca-pi/SKILLS.md +47 -0
- package/plugins/ca-pi/SPRINT.md +142 -0
- package/plugins/ca-pi/agents/INDEX.md +31 -0
- package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
- package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
- package/plugins/ca-pi/agents/backend-author.md +60 -0
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
- package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
- package/plugins/ca-pi/agents/decision-challenger.md +116 -0
- package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
- package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
- package/plugins/ca-pi/agents/finding-triage.md +86 -0
- package/plugins/ca-pi/agents/frontend-author.md +64 -0
- package/plugins/ca-pi/agents/grader.md +173 -0
- package/plugins/ca-pi/agents/infra-author.md +64 -0
- package/plugins/ca-pi/agents/map-deps.md +35 -0
- package/plugins/ca-pi/agents/map-structure.md +37 -0
- package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
- package/plugins/ca-pi/agents/scout.md +127 -0
- package/plugins/ca-pi/agents/security-reviewer.md +72 -0
- package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
- package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
- package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
- package/plugins/ca-pi/generated/command-catalog.json +197 -0
- package/plugins/ca-pi/generated/roles.json +213 -0
- package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
- package/plugins/ca-pi/hooks/_activationlib.py +196 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
- package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
- package/plugins/ca-pi/hooks/_boxlib.py +131 -0
- package/plugins/ca-pi/hooks/_colorlib.py +304 -0
- package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
- package/plugins/ca-pi/hooks/_entrylib.py +41 -0
- package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
- package/plugins/ca-pi/hooks/_gitexec.py +45 -0
- package/plugins/ca-pi/hooks/_githooks.py +920 -0
- package/plugins/ca-pi/hooks/_gitlib.py +110 -0
- package/plugins/ca-pi/hooks/_hooklib.py +595 -0
- package/plugins/ca-pi/hooks/_host.py +115 -0
- package/plugins/ca-pi/hooks/_intentlib.py +242 -0
- package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
- package/plugins/ca-pi/hooks/_metricslib.py +709 -0
- package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
- package/plugins/ca-pi/hooks/_previewlib.py +193 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
- package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
- package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
- package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
- package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
- package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
- package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
- package/plugins/ca-pi/hooks/_scopelib.py +262 -0
- package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
- package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
- package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
- package/plugins/ca-pi/hooks/_sloplib.py +244 -0
- package/plugins/ca-pi/hooks/_standuplib.py +214 -0
- package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
- package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
- package/plugins/ca-pi/hooks/_updatelib.py +278 -0
- package/plugins/ca-pi/hooks/babysit.py +47 -0
- package/plugins/ca-pi/hooks/boardsync.py +129 -0
- package/plugins/ca-pi/hooks/doctor.py +420 -0
- package/plugins/ca-pi/hooks/git-enforce.py +325 -0
- package/plugins/ca-pi/hooks/hostapi.py +460 -0
- package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
- package/plugins/ca-pi/hooks/metrics.py +62 -0
- package/plugins/ca-pi/hooks/migration-pass.py +129 -0
- package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
- package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
- package/plugins/ca-pi/hooks/pre-bash.py +90 -0
- package/plugins/ca-pi/hooks/pre-edit.py +284 -0
- package/plugins/ca-pi/hooks/pre-read.py +81 -0
- package/plugins/ca-pi/hooks/pre-write.py +217 -0
- package/plugins/ca-pi/hooks/preview.py +69 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
- package/plugins/ca-pi/hooks/releasehash.py +216 -0
- package/plugins/ca-pi/hooks/security-pass.py +139 -0
- package/plugins/ca-pi/hooks/session-start.py +1218 -0
- package/plugins/ca-pi/hooks/statusline.py +736 -0
- package/plugins/ca-pi/hooks/taskwrite.py +351 -0
- package/plugins/ca-pi/hooks/update-refresh.py +51 -0
- package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
- package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
- package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
- package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
- package/plugins/ca-pi/includes/compaction-charter.md +16 -0
- package/plugins/ca-pi/includes/cut-docs.md +16 -0
- package/plugins/ca-pi/includes/dev-mode.md +30 -0
- package/plugins/ca-pi/includes/farm.md +237 -0
- package/plugins/ca-pi/includes/fresh-verification.md +14 -0
- package/plugins/ca-pi/includes/harvest.md +69 -0
- package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
- package/plugins/ca-pi/includes/redirect.md +69 -0
- package/plugins/ca-pi/includes/reference-map.md +22 -0
- package/plugins/ca-pi/includes/review-matrix.md +14 -0
- package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
- package/plugins/ca-pi/includes/routing-table.md +47 -0
- package/plugins/ca-pi/includes/security-gate-record.md +22 -0
- package/plugins/ca-pi/includes/smarts/core.md +90 -0
- package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
- package/plugins/ca-pi/routines/INDEX.md +32 -0
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
- package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
- package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
- package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
- package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
- package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
- package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
- package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
- package/plugins/ca-pi/routines/release/SKILL.md +315 -0
- package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
- package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
- package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
- package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
- package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
- package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
- package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
- package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
- package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
- package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
- package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
- package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
- package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
- package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
- package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
- package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
- package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
- package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
- package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
- package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
- package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
- package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
- package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
- package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
- package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — session-transcript pruner core (the safe, testable engine).
|
|
3
|
+
#
|
|
4
|
+
# Claude Code session transcripts (~/.claude/projects/<slug>/<session>.jsonl)
|
|
5
|
+
# accumulate clutter — bulky `toolUseResult` sidecars, oversized tool_result
|
|
6
|
+
# bodies, thinking blocks, MCP/shell noise — that shortens how long a session
|
|
7
|
+
# stays usable before compaction. This module trims that clutter while treating
|
|
8
|
+
# transcript integrity as sacred.
|
|
9
|
+
#
|
|
10
|
+
# Two structural guarantees make corruption hard:
|
|
11
|
+
# 1. We never edit bytes. A line is parsed, its object mutated, and ONLY THEN
|
|
12
|
+
# re-serialized. Lines we don't touch are emitted as their original raw
|
|
13
|
+
# bytes — so unknown line types, exotic key order, and odd escapes survive
|
|
14
|
+
# byte-identical. Re-serialization drift can only affect lines we chose to
|
|
15
|
+
# edit.
|
|
16
|
+
# 2. Phases 1-4 are stub-in-place only: every line survives and the
|
|
17
|
+
# uuid/parentUuid chain is never altered. Whole-line deletion (with
|
|
18
|
+
# re-linking) is a separate, gated capability and is NOT in this module yet.
|
|
19
|
+
#
|
|
20
|
+
# Stdlib only (hooks must run on a stock interpreter — see _hooklib.py).
|
|
21
|
+
|
|
22
|
+
import calendar
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import math
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import shutil
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
import _hooklib
|
|
33
|
+
import _prunepolicy as _policy
|
|
34
|
+
|
|
35
|
+
BOM = b"\xef\xbb\xbf"
|
|
36
|
+
MARKER_PREFIX = _policy.MARKER_PREFIX
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _dumps(o):
|
|
40
|
+
"""Compact, UTF-8-preserving serialization used for every line we rewrite."""
|
|
41
|
+
return json.dumps(o, ensure_ascii=False, separators=(",", ":"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _marker(orig_text):
|
|
45
|
+
"""Self-describing elision marker. The 8-hex sha doubles as the idempotency
|
|
46
|
+
guard: a strategy skips any content already carrying a marker, so
|
|
47
|
+
prune(prune(x)) == prune(x)."""
|
|
48
|
+
return _policy.marker_for(orig_text)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _has_marker(s):
|
|
52
|
+
return _policy.has_marker(s)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def est_tokens(nbytes):
|
|
56
|
+
"""Dependency-free token estimate. Deliberately labeled `est≈` everywhere it
|
|
57
|
+
surfaces — mirrors statusline's honest `api≈` convention."""
|
|
58
|
+
return nbytes // 4
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --------------------------------------------------------------------------- #
|
|
62
|
+
# Line model + load/serialize (the byte-identity backbone)
|
|
63
|
+
# --------------------------------------------------------------------------- #
|
|
64
|
+
|
|
65
|
+
class Line:
|
|
66
|
+
__slots__ = ("idx", "raw", "obj", "dirty", "bom", "fp0")
|
|
67
|
+
|
|
68
|
+
def __init__(self, idx, raw):
|
|
69
|
+
self.idx = idx
|
|
70
|
+
self.bom = raw.startswith(BOM)
|
|
71
|
+
body = raw[len(BOM):] if self.bom else raw
|
|
72
|
+
self.raw = raw
|
|
73
|
+
self.dirty = False
|
|
74
|
+
try:
|
|
75
|
+
self.obj = json.loads(body) if body.strip() else None
|
|
76
|
+
except Exception: # noqa: BLE001 — a malformed line is preserved verbatim
|
|
77
|
+
self.obj = None
|
|
78
|
+
# Original structural fingerprint, captured BEFORE any strategy mutates
|
|
79
|
+
# obj, so validators compare against the true original.
|
|
80
|
+
if isinstance(self.obj, dict):
|
|
81
|
+
self.fp0 = (self.obj.get("type"), self.obj.get("uuid"),
|
|
82
|
+
self.obj.get("parentUuid"))
|
|
83
|
+
else:
|
|
84
|
+
self.fp0 = None
|
|
85
|
+
|
|
86
|
+
def out_bytes(self):
|
|
87
|
+
if not self.dirty:
|
|
88
|
+
return self.raw
|
|
89
|
+
body = _dumps(self.obj).encode("utf-8")
|
|
90
|
+
return (BOM + body) if self.bom else body
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_lines(data):
|
|
94
|
+
"""Split on b'\\n' keeping every part — including a trailing empty part for a
|
|
95
|
+
newline-terminated file and any blank interior lines — so b'\\n'.join of the
|
|
96
|
+
untouched parts reproduces the input exactly."""
|
|
97
|
+
return [Line(i, raw) for i, raw in enumerate(data.split(b"\n"))]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def serialize(lines):
|
|
101
|
+
return b"\n".join(ln.out_bytes() for ln in lines)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --------------------------------------------------------------------------- #
|
|
105
|
+
# Index + protected tail
|
|
106
|
+
# --------------------------------------------------------------------------- #
|
|
107
|
+
|
|
108
|
+
def _tool_use_ids(o):
|
|
109
|
+
ids = set()
|
|
110
|
+
msg = o.get("message") if isinstance(o, dict) else None
|
|
111
|
+
if isinstance(msg, dict) and isinstance(msg.get("content"), list):
|
|
112
|
+
for b in msg["content"]:
|
|
113
|
+
if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id"):
|
|
114
|
+
ids.add(b["id"])
|
|
115
|
+
return ids
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _tool_result_ids(o):
|
|
119
|
+
ids = set()
|
|
120
|
+
msg = o.get("message") if isinstance(o, dict) else None
|
|
121
|
+
if isinstance(msg, dict) and isinstance(msg.get("content"), list):
|
|
122
|
+
for b in msg["content"]:
|
|
123
|
+
if isinstance(b, dict) and b.get("type") == "tool_result" and b.get("tool_use_id"):
|
|
124
|
+
ids.add(b["tool_use_id"])
|
|
125
|
+
return ids
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Index:
|
|
129
|
+
__slots__ = ("protected_from", "last_assistant_idx", "tu_ids", "tr_ids",
|
|
130
|
+
"edited_paths", "edited_at", "tool_meta")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_index(lines, cfg):
|
|
134
|
+
"""Compute the protected tail and pre-strategy tool-id sets. The protected
|
|
135
|
+
tail is everything at/after the earlier of (a) the start of the K-th most
|
|
136
|
+
recent tool-bearing TURN and (b) the last assistant message (whose thinking
|
|
137
|
+
signature must never be touched).
|
|
138
|
+
|
|
139
|
+
keep_recent counts TURNS, not lines: a turn is anchored by its assistant
|
|
140
|
+
tool_use line, and protecting that line's index also protects the
|
|
141
|
+
tool_result lines that follow it. (Counting result lines too would silently
|
|
142
|
+
halve the protection an operator asked for via KEEP_RECENT.)"""
|
|
143
|
+
idx = Index()
|
|
144
|
+
last_assistant = -1
|
|
145
|
+
tu, tr = set(), set()
|
|
146
|
+
edited = set()
|
|
147
|
+
edited_at = {} # file_path -> highest line idx that Wrote/Edited it
|
|
148
|
+
tool_meta = {} # tool_use id -> {"name", "path", "idx"}
|
|
149
|
+
for ln in lines:
|
|
150
|
+
o = ln.obj
|
|
151
|
+
if not isinstance(o, dict):
|
|
152
|
+
continue
|
|
153
|
+
if o.get("type") == "assistant":
|
|
154
|
+
last_assistant = ln.idx
|
|
155
|
+
a, b = _tool_use_ids(o), _tool_result_ids(o)
|
|
156
|
+
tu |= a
|
|
157
|
+
tr |= b
|
|
158
|
+
msg = o.get("message")
|
|
159
|
+
if isinstance(msg, dict) and isinstance(msg.get("content"), list):
|
|
160
|
+
for blk in msg["content"]:
|
|
161
|
+
if not isinstance(blk, dict) or blk.get("type") != "tool_use":
|
|
162
|
+
continue
|
|
163
|
+
name = blk.get("name")
|
|
164
|
+
path = (blk.get("input") or {}).get("file_path")
|
|
165
|
+
if blk.get("id"):
|
|
166
|
+
tool_meta[blk["id"]] = {"name": name, "path": path, "idx": ln.idx}
|
|
167
|
+
# Track files a later Write/Edit superseded (Phase-3 strategy).
|
|
168
|
+
if name in ("Write", "Edit") and path:
|
|
169
|
+
edited.add(path)
|
|
170
|
+
edited_at[path] = max(edited_at.get(path, -1), ln.idx)
|
|
171
|
+
semantic = []
|
|
172
|
+
for ln in lines:
|
|
173
|
+
o = ln.obj if isinstance(ln.obj, dict) else {}
|
|
174
|
+
semantic.append(_policy.SemanticEntry(
|
|
175
|
+
id=str(ln.idx), ordinal=ln.idx, role=str(o.get("type", "other")),
|
|
176
|
+
kind=("tool-result" if _tool_result_ids(o) else "message"),
|
|
177
|
+
byte_size=len(ln.raw), tool_bearing=bool(_tool_use_ids(o)),
|
|
178
|
+
marked=_has_marker(_dumps(o)) if o else False,
|
|
179
|
+
))
|
|
180
|
+
prot = _policy.protected_ordinal(semantic, cfg.keep_recent)
|
|
181
|
+
idx.protected_from = prot
|
|
182
|
+
idx.last_assistant_idx = last_assistant
|
|
183
|
+
idx.tu_ids = tu
|
|
184
|
+
idx.tr_ids = tr
|
|
185
|
+
idx.edited_paths = edited
|
|
186
|
+
idx.edited_at = edited_at
|
|
187
|
+
idx.tool_meta = tool_meta
|
|
188
|
+
return idx
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_small_scalar(v, limit=200):
|
|
192
|
+
if isinstance(v, bool) or v is None or isinstance(v, (int, float)):
|
|
193
|
+
return True
|
|
194
|
+
if isinstance(v, str):
|
|
195
|
+
return len(v) <= limit
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# --------------------------------------------------------------------------- #
|
|
200
|
+
# Strategies (Phase 1). Each mutates obj, sets dirty, records a report row.
|
|
201
|
+
# All obey the net-negative guard: a strategy is a no-op on any unit whose
|
|
202
|
+
# replacement would not be strictly smaller than the original.
|
|
203
|
+
# --------------------------------------------------------------------------- #
|
|
204
|
+
|
|
205
|
+
def _record(report, name, touched, before, after):
|
|
206
|
+
report[name] = {
|
|
207
|
+
"lines": touched,
|
|
208
|
+
"bytes_before": before,
|
|
209
|
+
"bytes_after": after,
|
|
210
|
+
"metric_scope": _policy.STRATEGY_METRIC_SCOPES[name],
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def s_sidecar_collapse(lines, index, cfg, report):
|
|
215
|
+
"""Replace the bulky top-level `toolUseResult` sidecar with a marker plus the
|
|
216
|
+
small scalar fields worth keeping (status, exit codes, agentId, paths)."""
|
|
217
|
+
touched = before = after = 0
|
|
218
|
+
for ln in lines:
|
|
219
|
+
if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
|
|
220
|
+
continue
|
|
221
|
+
tur = ln.obj.get("toolUseResult")
|
|
222
|
+
if not isinstance(tur, (dict, list, str)):
|
|
223
|
+
continue
|
|
224
|
+
orig = _dumps(tur)
|
|
225
|
+
if _has_marker(orig):
|
|
226
|
+
continue
|
|
227
|
+
kept = {}
|
|
228
|
+
if isinstance(tur, dict):
|
|
229
|
+
kept = {k: v for k, v in tur.items() if _is_small_scalar(v)}
|
|
230
|
+
new = dict(kept)
|
|
231
|
+
new["_ca_condensed"] = _marker(orig)
|
|
232
|
+
new_s = _dumps(new)
|
|
233
|
+
if len(new_s) >= len(orig): # preserve legacy transformation eligibility
|
|
234
|
+
continue
|
|
235
|
+
orig_size = len(orig.encode("utf-8"))
|
|
236
|
+
new_size = len(new_s.encode("utf-8"))
|
|
237
|
+
ln.obj["toolUseResult"] = new
|
|
238
|
+
ln.dirty = True
|
|
239
|
+
touched += 1
|
|
240
|
+
before += orig_size
|
|
241
|
+
after += new_size
|
|
242
|
+
_record(report, "sidecar-collapse", touched, before, after)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _clamp_text(s, max_bytes, max_lines):
|
|
246
|
+
"""Return (clamped_or_None). None means leave as-is (under threshold)."""
|
|
247
|
+
b = s.encode("utf-8")
|
|
248
|
+
if len(b) <= max_bytes and s.count("\n") < max_lines:
|
|
249
|
+
return None
|
|
250
|
+
head = b[:max_bytes].decode("utf-8", "ignore")
|
|
251
|
+
clamped = head + "\n" + _marker(s)
|
|
252
|
+
if len(clamped.encode("utf-8")) >= len(b): # net-negative guard
|
|
253
|
+
return None
|
|
254
|
+
return clamped
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def s_oversize_result_clamp(lines, index, cfg, report):
|
|
258
|
+
"""Truncate tool_result bodies over MAXBYTES or >100 lines to a head + marker.
|
|
259
|
+
Handles both the string form and the list-of-{type:text} form."""
|
|
260
|
+
touched = before = after = 0
|
|
261
|
+
mb, ml = cfg.max_bytes, 100
|
|
262
|
+
for ln in lines:
|
|
263
|
+
if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
|
|
264
|
+
continue
|
|
265
|
+
msg = ln.obj.get("message")
|
|
266
|
+
if not isinstance(msg, dict) or not isinstance(msg.get("content"), list):
|
|
267
|
+
continue
|
|
268
|
+
changed = False
|
|
269
|
+
for blk in msg["content"]:
|
|
270
|
+
if not isinstance(blk, dict) or blk.get("type") != "tool_result":
|
|
271
|
+
continue
|
|
272
|
+
c = blk.get("content")
|
|
273
|
+
if isinstance(c, str):
|
|
274
|
+
if _has_marker(c):
|
|
275
|
+
continue
|
|
276
|
+
clamped = _clamp_text(c, mb, ml)
|
|
277
|
+
if clamped is not None:
|
|
278
|
+
before += len(c.encode("utf-8"))
|
|
279
|
+
after += len(clamped.encode("utf-8"))
|
|
280
|
+
blk["content"] = clamped
|
|
281
|
+
changed = True
|
|
282
|
+
elif isinstance(c, list):
|
|
283
|
+
for tb in c:
|
|
284
|
+
if not isinstance(tb, dict) or tb.get("type") != "text":
|
|
285
|
+
continue
|
|
286
|
+
txt = tb.get("text")
|
|
287
|
+
if not isinstance(txt, str) or _has_marker(txt):
|
|
288
|
+
continue
|
|
289
|
+
clamped = _clamp_text(txt, mb, ml)
|
|
290
|
+
if clamped is not None:
|
|
291
|
+
before += len(txt.encode("utf-8"))
|
|
292
|
+
after += len(clamped.encode("utf-8"))
|
|
293
|
+
tb["text"] = clamped
|
|
294
|
+
changed = True
|
|
295
|
+
if changed:
|
|
296
|
+
ln.dirty = True
|
|
297
|
+
touched += 1
|
|
298
|
+
_record(report, "oversize-result-clamp", touched, before, after)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _content_list(o):
|
|
302
|
+
msg = o.get("message")
|
|
303
|
+
if isinstance(msg, dict) and isinstance(msg.get("content"), list):
|
|
304
|
+
return msg["content"]
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _clamp_in_block_content(c, max_bytes, max_lines):
|
|
309
|
+
"""Clamp a tool_result `content` (str or list-of-{text}) in place. Returns
|
|
310
|
+
(changed, bytes_before, bytes_after)."""
|
|
311
|
+
before = after = 0
|
|
312
|
+
changed = False
|
|
313
|
+
if isinstance(c, str):
|
|
314
|
+
if not _has_marker(c):
|
|
315
|
+
clamped = _clamp_text(c, max_bytes, max_lines)
|
|
316
|
+
if clamped is not None:
|
|
317
|
+
before += len(c.encode("utf-8"))
|
|
318
|
+
after += len(clamped.encode("utf-8"))
|
|
319
|
+
return True, before, after, clamped
|
|
320
|
+
return False, 0, 0, c
|
|
321
|
+
if isinstance(c, list):
|
|
322
|
+
for tb in c:
|
|
323
|
+
if not isinstance(tb, dict) or tb.get("type") != "text":
|
|
324
|
+
continue
|
|
325
|
+
txt = tb.get("text")
|
|
326
|
+
if not isinstance(txt, str) or _has_marker(txt):
|
|
327
|
+
continue
|
|
328
|
+
clamped = _clamp_text(txt, max_bytes, max_lines)
|
|
329
|
+
if clamped is not None:
|
|
330
|
+
before += len(txt.encode("utf-8"))
|
|
331
|
+
after += len(clamped.encode("utf-8"))
|
|
332
|
+
tb["text"] = clamped
|
|
333
|
+
changed = True
|
|
334
|
+
return changed, before, after, c
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def s_reasoning_fold(lines, index, cfg, report):
|
|
338
|
+
"""Drop thinking blocks from older assistant turns. We REMOVE the block
|
|
339
|
+
rather than leave an empty-signature stub: a thinking block with an invalid
|
|
340
|
+
signature can be rejected on resume, whereas an absent one never is. The
|
|
341
|
+
most recent assistant turn is always inside the protected tail."""
|
|
342
|
+
touched = before = 0
|
|
343
|
+
for ln in lines:
|
|
344
|
+
if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
|
|
345
|
+
continue
|
|
346
|
+
if ln.obj.get("type") != "assistant":
|
|
347
|
+
continue
|
|
348
|
+
content = _content_list(ln.obj)
|
|
349
|
+
if not content:
|
|
350
|
+
continue
|
|
351
|
+
thinking = [b for b in content
|
|
352
|
+
if isinstance(b, dict) and b.get("type") == "thinking"]
|
|
353
|
+
if not thinking:
|
|
354
|
+
continue
|
|
355
|
+
keep = [b for b in content if b not in thinking]
|
|
356
|
+
if not keep: # never empty a message
|
|
357
|
+
continue
|
|
358
|
+
for b in thinking:
|
|
359
|
+
before += len(_dumps(b).encode("utf-8"))
|
|
360
|
+
ln.obj["message"]["content"] = keep
|
|
361
|
+
ln.dirty = True
|
|
362
|
+
touched += 1
|
|
363
|
+
_record(report, "reasoning-fold", touched, before, 0)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def s_aged_result_condense(lines, index, cfg, report):
|
|
367
|
+
"""Condense any remaining (unmarked) older tool_result body to a small head
|
|
368
|
+
+ marker — harder than the gentle clamp, for results past the protected
|
|
369
|
+
tail. Specific handlers (shell/superseded) run earlier and mark their own."""
|
|
370
|
+
touched = before = after = 0
|
|
371
|
+
for ln in lines:
|
|
372
|
+
if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
|
|
373
|
+
continue
|
|
374
|
+
content = _content_list(ln.obj)
|
|
375
|
+
if not content:
|
|
376
|
+
continue
|
|
377
|
+
changed = False
|
|
378
|
+
for blk in content:
|
|
379
|
+
if not isinstance(blk, dict) or blk.get("type") != "tool_result":
|
|
380
|
+
continue
|
|
381
|
+
ch, b, a, newc = _clamp_in_block_content(blk.get("content"), 200, 10 ** 9)
|
|
382
|
+
if ch:
|
|
383
|
+
blk["content"] = newc
|
|
384
|
+
before += b
|
|
385
|
+
after += a
|
|
386
|
+
changed = True
|
|
387
|
+
if changed:
|
|
388
|
+
ln.dirty = True
|
|
389
|
+
touched += 1
|
|
390
|
+
_record(report, "aged-result-condense", touched, before, after)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def s_mcp_payload_condense(lines, index, cfg, report):
|
|
394
|
+
"""Condense the bulky `input` of mcp__ tool_use blocks (older turns)."""
|
|
395
|
+
touched = before = after = 0
|
|
396
|
+
for ln in lines:
|
|
397
|
+
if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
|
|
398
|
+
continue
|
|
399
|
+
content = _content_list(ln.obj)
|
|
400
|
+
if not content:
|
|
401
|
+
continue
|
|
402
|
+
changed = False
|
|
403
|
+
for blk in content:
|
|
404
|
+
if (not isinstance(blk, dict) or blk.get("type") != "tool_use"
|
|
405
|
+
or not str(blk.get("name", "")).startswith("mcp__")):
|
|
406
|
+
continue
|
|
407
|
+
inp = blk.get("input")
|
|
408
|
+
if not isinstance(inp, (dict, list, str)):
|
|
409
|
+
continue
|
|
410
|
+
orig = _dumps(inp)
|
|
411
|
+
if _has_marker(orig):
|
|
412
|
+
continue
|
|
413
|
+
kept = ({k: v for k, v in inp.items() if _is_small_scalar(v)}
|
|
414
|
+
if isinstance(inp, dict) else {})
|
|
415
|
+
new = dict(kept)
|
|
416
|
+
new["_ca_condensed"] = _marker(orig)
|
|
417
|
+
new_s = _dumps(new)
|
|
418
|
+
if len(new_s) >= len(orig): # preserve legacy transformation eligibility
|
|
419
|
+
continue
|
|
420
|
+
orig_size = len(orig.encode("utf-8"))
|
|
421
|
+
new_size = len(new_s.encode("utf-8"))
|
|
422
|
+
blk["input"] = new
|
|
423
|
+
before += orig_size
|
|
424
|
+
after += new_size
|
|
425
|
+
changed = True
|
|
426
|
+
if changed:
|
|
427
|
+
ln.dirty = True
|
|
428
|
+
touched += 1
|
|
429
|
+
_record(report, "mcp-payload-condense", touched, before, after)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def s_shell_tail_keep(lines, index, cfg, report):
|
|
433
|
+
"""For Bash/PowerShell results, keep only the last N lines (the tail carries
|
|
434
|
+
the exit verdict). Claims its targets before the generic condenser."""
|
|
435
|
+
keep_lines = 30
|
|
436
|
+
touched = before = after = 0
|
|
437
|
+
for ln in lines:
|
|
438
|
+
if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
|
|
439
|
+
continue
|
|
440
|
+
content = _content_list(ln.obj)
|
|
441
|
+
if not content:
|
|
442
|
+
continue
|
|
443
|
+
changed = False
|
|
444
|
+
for blk in content:
|
|
445
|
+
if not isinstance(blk, dict) or blk.get("type") != "tool_result":
|
|
446
|
+
continue
|
|
447
|
+
meta = index.tool_meta.get(blk.get("tool_use_id"))
|
|
448
|
+
if not meta or meta.get("name") not in ("Bash", "PowerShell", "Shell"):
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
def tail(s):
|
|
452
|
+
if _has_marker(s):
|
|
453
|
+
return None
|
|
454
|
+
parts = s.split("\n")
|
|
455
|
+
if len(parts) <= keep_lines:
|
|
456
|
+
return None
|
|
457
|
+
new = _marker(s) + "\n" + "\n".join(parts[-keep_lines:])
|
|
458
|
+
if len(new.encode("utf-8")) >= len(s.encode("utf-8")):
|
|
459
|
+
return None
|
|
460
|
+
return new
|
|
461
|
+
c = blk.get("content")
|
|
462
|
+
if isinstance(c, str):
|
|
463
|
+
nt = tail(c)
|
|
464
|
+
if nt is not None:
|
|
465
|
+
before += len(c.encode("utf-8"))
|
|
466
|
+
after += len(nt.encode("utf-8"))
|
|
467
|
+
blk["content"] = nt
|
|
468
|
+
changed = True
|
|
469
|
+
elif isinstance(c, list):
|
|
470
|
+
for tb in c:
|
|
471
|
+
if isinstance(tb, dict) and tb.get("type") == "text" \
|
|
472
|
+
and isinstance(tb.get("text"), str):
|
|
473
|
+
nt = tail(tb["text"])
|
|
474
|
+
if nt is not None:
|
|
475
|
+
before += len(tb["text"].encode("utf-8"))
|
|
476
|
+
after += len(nt.encode("utf-8"))
|
|
477
|
+
tb["text"] = nt
|
|
478
|
+
changed = True
|
|
479
|
+
if changed:
|
|
480
|
+
ln.dirty = True
|
|
481
|
+
touched += 1
|
|
482
|
+
_record(report, "shell-tail-keep", touched, before, after)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def s_superseded_read_condense(lines, index, cfg, report):
|
|
486
|
+
"""Condense a Read result whose file was Written/Edited later in the
|
|
487
|
+
transcript — that snapshot is stale; the later edit is the source of truth."""
|
|
488
|
+
touched = before = after = 0
|
|
489
|
+
for ln in lines:
|
|
490
|
+
if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
|
|
491
|
+
continue
|
|
492
|
+
content = _content_list(ln.obj)
|
|
493
|
+
if not content:
|
|
494
|
+
continue
|
|
495
|
+
changed = False
|
|
496
|
+
for blk in content:
|
|
497
|
+
if not isinstance(blk, dict) or blk.get("type") != "tool_result":
|
|
498
|
+
continue
|
|
499
|
+
meta = index.tool_meta.get(blk.get("tool_use_id"))
|
|
500
|
+
if not meta or meta.get("name") != "Read":
|
|
501
|
+
continue
|
|
502
|
+
path = meta.get("path")
|
|
503
|
+
if not path or index.edited_at.get(path, -1) <= meta.get("idx", -1):
|
|
504
|
+
continue
|
|
505
|
+
ch, b, a, newc = _clamp_in_block_content(blk.get("content"), 80, 10 ** 9)
|
|
506
|
+
if ch:
|
|
507
|
+
blk["content"] = newc
|
|
508
|
+
before += b
|
|
509
|
+
after += a
|
|
510
|
+
changed = True
|
|
511
|
+
if changed:
|
|
512
|
+
ln.dirty = True
|
|
513
|
+
touched += 1
|
|
514
|
+
_record(report, "superseded-read-condense", touched, before, after)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def s_repeat_reminder_fold(lines, index, cfg, report):
|
|
518
|
+
"""Dedup repeated identical <system-reminder> text blocks — keep the first,
|
|
519
|
+
fold later copies to a marker."""
|
|
520
|
+
seen = set()
|
|
521
|
+
touched = before = after = 0
|
|
522
|
+
for ln in lines:
|
|
523
|
+
if not isinstance(ln.obj, dict):
|
|
524
|
+
continue
|
|
525
|
+
content = _content_list(ln.obj)
|
|
526
|
+
if not content:
|
|
527
|
+
continue
|
|
528
|
+
changed = False
|
|
529
|
+
for blk in content:
|
|
530
|
+
if not isinstance(blk, dict) or blk.get("type") != "text":
|
|
531
|
+
continue
|
|
532
|
+
txt = blk.get("text")
|
|
533
|
+
if not isinstance(txt, str) or "<system-reminder>" not in txt or _has_marker(txt):
|
|
534
|
+
continue
|
|
535
|
+
key = hashlib.sha256(txt.encode("utf-8")).hexdigest()
|
|
536
|
+
if key not in seen:
|
|
537
|
+
seen.add(key)
|
|
538
|
+
continue
|
|
539
|
+
# A later duplicate: fold it (only past the protected tail).
|
|
540
|
+
if ln.idx >= index.protected_from:
|
|
541
|
+
continue
|
|
542
|
+
marker = _marker(txt)
|
|
543
|
+
if len(marker.encode("utf-8")) >= len(txt.encode("utf-8")):
|
|
544
|
+
continue
|
|
545
|
+
before += len(txt.encode("utf-8"))
|
|
546
|
+
after += len(marker.encode("utf-8"))
|
|
547
|
+
blk["text"] = marker
|
|
548
|
+
changed = True
|
|
549
|
+
if changed:
|
|
550
|
+
ln.dirty = True
|
|
551
|
+
touched += 1
|
|
552
|
+
_record(report, "repeat-reminder-fold", touched, before, after)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def s_inline_image_evict(lines, index, cfg, report):
|
|
556
|
+
"""Replace base64 image payloads (older turns) with a marker."""
|
|
557
|
+
touched = before = after = 0
|
|
558
|
+
|
|
559
|
+
def walk(blocks):
|
|
560
|
+
nonlocal before, after
|
|
561
|
+
changed = False
|
|
562
|
+
for blk in blocks:
|
|
563
|
+
if not isinstance(blk, dict):
|
|
564
|
+
continue
|
|
565
|
+
if blk.get("type") == "image" and isinstance(blk.get("source"), dict):
|
|
566
|
+
src = blk["source"]
|
|
567
|
+
data = src.get("data")
|
|
568
|
+
if isinstance(data, str) and not _has_marker(data) and len(data) > 64:
|
|
569
|
+
before += len(data)
|
|
570
|
+
src["data"] = _marker(data)
|
|
571
|
+
after += len(src["data"])
|
|
572
|
+
changed = True
|
|
573
|
+
# tool_result content may itself be a list with image blocks
|
|
574
|
+
if isinstance(blk.get("content"), list):
|
|
575
|
+
if walk(blk["content"]):
|
|
576
|
+
changed = True
|
|
577
|
+
return changed
|
|
578
|
+
for ln in lines:
|
|
579
|
+
if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
|
|
580
|
+
continue
|
|
581
|
+
content = _content_list(ln.obj)
|
|
582
|
+
if not content:
|
|
583
|
+
continue
|
|
584
|
+
if walk(content):
|
|
585
|
+
ln.dirty = True
|
|
586
|
+
touched += 1
|
|
587
|
+
_record(report, "inline-image-evict", touched, before, after)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# name -> (tier, function). The pinned order matters: specific result handlers
|
|
591
|
+
# (shell tail, superseded read) and payload condensers claim and mark their
|
|
592
|
+
# targets BEFORE the generic aged/oversize condensers run, so each result is
|
|
593
|
+
# trimmed by the most appropriate strategy exactly once (the marker enforces
|
|
594
|
+
# single-processing). Selection preserves this order.
|
|
595
|
+
TIERS = _policy.TIERS
|
|
596
|
+
STRATEGIES = {
|
|
597
|
+
"sidecar-collapse": ("gentle", s_sidecar_collapse),
|
|
598
|
+
"reasoning-fold": ("standard", s_reasoning_fold),
|
|
599
|
+
"mcp-payload-condense": ("standard", s_mcp_payload_condense),
|
|
600
|
+
"shell-tail-keep": ("standard", s_shell_tail_keep),
|
|
601
|
+
"superseded-read-condense": ("aggressive", s_superseded_read_condense),
|
|
602
|
+
"repeat-reminder-fold": ("aggressive", s_repeat_reminder_fold),
|
|
603
|
+
"inline-image-evict": ("aggressive", s_inline_image_evict),
|
|
604
|
+
"aged-result-condense": ("standard", s_aged_result_condense),
|
|
605
|
+
"oversize-result-clamp": ("gentle", s_oversize_result_clamp),
|
|
606
|
+
}
|
|
607
|
+
STRATEGY_ORDER = list(_policy.STRATEGY_ORDER)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def selected_strategies(cfg):
|
|
611
|
+
return list(_policy.select_strategies(cfg.tier, cfg.strategies))
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
# --------------------------------------------------------------------------- #
|
|
615
|
+
# Config
|
|
616
|
+
# --------------------------------------------------------------------------- #
|
|
617
|
+
|
|
618
|
+
class Config:
|
|
619
|
+
def __init__(self, tier="gentle", strategies=None, max_bytes=8192,
|
|
620
|
+
keep_recent=10, min_size=1 << 20, min_growth=1 << 18,
|
|
621
|
+
backups=3, live_secs=90, execute=False):
|
|
622
|
+
self.tier = tier
|
|
623
|
+
self.strategies = strategies
|
|
624
|
+
self.max_bytes = max_bytes
|
|
625
|
+
self.keep_recent = keep_recent
|
|
626
|
+
self.min_size = min_size
|
|
627
|
+
self.min_growth = min_growth
|
|
628
|
+
self.backups = backups
|
|
629
|
+
self.live_secs = live_secs
|
|
630
|
+
self.execute = execute
|
|
631
|
+
|
|
632
|
+
@classmethod
|
|
633
|
+
def from_env(cls, env=None, **over):
|
|
634
|
+
e = env if env is not None else os.environ
|
|
635
|
+
|
|
636
|
+
def num(key, default):
|
|
637
|
+
try:
|
|
638
|
+
return int(e[key])
|
|
639
|
+
except Exception: # noqa: BLE001
|
|
640
|
+
return default
|
|
641
|
+
cfg = cls(
|
|
642
|
+
tier=e.get("CODEARBITER_PRUNE_TIER", "gentle"),
|
|
643
|
+
strategies=([s.strip() for s in e["CODEARBITER_PRUNE_STRATEGIES"].split(",") if s.strip()]
|
|
644
|
+
if e.get("CODEARBITER_PRUNE_STRATEGIES") else None),
|
|
645
|
+
max_bytes=num("CODEARBITER_PRUNE_MAXBYTES", 8192),
|
|
646
|
+
keep_recent=num("CODEARBITER_PRUNE_KEEP_RECENT", 10),
|
|
647
|
+
min_size=num("CODEARBITER_PRUNE_MIN_SIZE", 1 << 20),
|
|
648
|
+
min_growth=num("CODEARBITER_PRUNE_MIN_GROWTH", 1 << 18),
|
|
649
|
+
backups=num("CODEARBITER_PRUNE_BACKUPS", 3),
|
|
650
|
+
live_secs=num("CODEARBITER_PRUNE_LIVE_SECS", 90),
|
|
651
|
+
)
|
|
652
|
+
for k, v in over.items():
|
|
653
|
+
setattr(cfg, k, v)
|
|
654
|
+
return cfg
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
# --------------------------------------------------------------------------- #
|
|
658
|
+
# Pipeline + validation
|
|
659
|
+
# --------------------------------------------------------------------------- #
|
|
660
|
+
|
|
661
|
+
def apply_strategies(lines, index, cfg):
|
|
662
|
+
report = {}
|
|
663
|
+
for name in selected_strategies(cfg):
|
|
664
|
+
STRATEGIES[name][1](lines, index, cfg, report)
|
|
665
|
+
return report
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _parts_objs(data):
|
|
669
|
+
"""Parse each line; returns list where each item is dict/None, or the tuple
|
|
670
|
+
('<<bad>>', err) for an unparseable non-blank line."""
|
|
671
|
+
out = []
|
|
672
|
+
for p in data.split(b"\n"):
|
|
673
|
+
body = p[len(BOM):] if p.startswith(BOM) else p
|
|
674
|
+
if not body.strip():
|
|
675
|
+
out.append(None)
|
|
676
|
+
continue
|
|
677
|
+
try:
|
|
678
|
+
out.append(json.loads(body))
|
|
679
|
+
except Exception as e: # noqa: BLE001
|
|
680
|
+
out.append(("<<bad>>", str(e)))
|
|
681
|
+
return out
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _orphans(objs):
|
|
685
|
+
uuids = {o.get("uuid") for o in objs if isinstance(o, dict) and o.get("uuid")}
|
|
686
|
+
bad = set()
|
|
687
|
+
for o in objs:
|
|
688
|
+
if isinstance(o, dict):
|
|
689
|
+
p = o.get("parentUuid")
|
|
690
|
+
if p is not None and p not in uuids:
|
|
691
|
+
bad.add(p)
|
|
692
|
+
return bad
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def validate(orig_bytes, new_bytes, lines, cfg, phase5=False):
|
|
696
|
+
"""Run the full validator battery. Returns a list of error strings (empty ==
|
|
697
|
+
safe to write). Used both pre-write (in-memory result) and post-write (a
|
|
698
|
+
fresh read of what actually landed on disk)."""
|
|
699
|
+
errs = []
|
|
700
|
+
old_parts = orig_bytes.split(b"\n")
|
|
701
|
+
new_parts = new_bytes.split(b"\n")
|
|
702
|
+
|
|
703
|
+
# v_shrink
|
|
704
|
+
if len(new_bytes) > len(orig_bytes):
|
|
705
|
+
errs.append("v_shrink: output is larger than input")
|
|
706
|
+
|
|
707
|
+
# v_linecount (hard invariant for stub-in-place phases)
|
|
708
|
+
if not phase5 and len(old_parts) != len(new_parts):
|
|
709
|
+
errs.append(f"v_linecount: {len(old_parts)} -> {len(new_parts)} lines")
|
|
710
|
+
return errs # per-line comparisons below would be meaningless
|
|
711
|
+
|
|
712
|
+
new_objs = _parts_objs(new_bytes)
|
|
713
|
+
|
|
714
|
+
# v_parse
|
|
715
|
+
for i, o in enumerate(new_objs):
|
|
716
|
+
if isinstance(o, tuple):
|
|
717
|
+
errs.append(f"v_parse: line {i} unparseable: {o[1]}")
|
|
718
|
+
|
|
719
|
+
# v_identity (byte-identity of untouched lines; structural stability of edits)
|
|
720
|
+
if not phase5:
|
|
721
|
+
for i, ln in enumerate(lines):
|
|
722
|
+
if not ln.dirty:
|
|
723
|
+
if new_parts[i] != ln.raw:
|
|
724
|
+
errs.append(f"v_identity: untouched line {i} changed")
|
|
725
|
+
else:
|
|
726
|
+
no = new_objs[i]
|
|
727
|
+
if isinstance(no, dict) and ln.fp0 is not None:
|
|
728
|
+
fp1 = (no.get("type"), no.get("uuid"), no.get("parentUuid"))
|
|
729
|
+
if fp1 != ln.fp0:
|
|
730
|
+
errs.append(f"v_identity: edited line {i} changed type/uuid/parent")
|
|
731
|
+
|
|
732
|
+
# v_pairs (tool_use / tool_result id sets unchanged)
|
|
733
|
+
new_tu, new_tr = set(), set()
|
|
734
|
+
for o in new_objs:
|
|
735
|
+
if isinstance(o, dict):
|
|
736
|
+
new_tu |= _tool_use_ids(o)
|
|
737
|
+
new_tr |= _tool_result_ids(o)
|
|
738
|
+
old_objs = _parts_objs(orig_bytes)
|
|
739
|
+
old_tu, old_tr = set(), set()
|
|
740
|
+
for o in old_objs:
|
|
741
|
+
if isinstance(o, dict):
|
|
742
|
+
old_tu |= _tool_use_ids(o)
|
|
743
|
+
old_tr |= _tool_result_ids(o)
|
|
744
|
+
if new_tu != old_tu:
|
|
745
|
+
errs.append("v_pairs: tool_use id set changed")
|
|
746
|
+
if new_tr != old_tr:
|
|
747
|
+
errs.append("v_pairs: tool_result id set changed")
|
|
748
|
+
|
|
749
|
+
# v_chain (introduce no NEW parentUuid orphans)
|
|
750
|
+
new_orphans = _orphans(new_objs) - _orphans(old_objs)
|
|
751
|
+
if new_orphans:
|
|
752
|
+
errs.append(f"v_chain: new orphaned parentUuid(s): {sorted(new_orphans)}")
|
|
753
|
+
|
|
754
|
+
return errs
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
# --------------------------------------------------------------------------- #
|
|
758
|
+
# Audit / integrity report (the read-only `audit` subcommand)
|
|
759
|
+
# --------------------------------------------------------------------------- #
|
|
760
|
+
|
|
761
|
+
def audit(data):
|
|
762
|
+
"""Read-only integrity checks on any transcript (touched or not). Returns a
|
|
763
|
+
list of (level, message) — OK / WARN / FAIL, in doctor.py's style."""
|
|
764
|
+
out = []
|
|
765
|
+
objs = _parts_objs(data)
|
|
766
|
+
bad = [i for i, o in enumerate(objs) if isinstance(o, tuple)]
|
|
767
|
+
orph = _orphans(objs)
|
|
768
|
+
tu = set()
|
|
769
|
+
tr = set()
|
|
770
|
+
for o in objs:
|
|
771
|
+
if isinstance(o, dict):
|
|
772
|
+
tu |= _tool_use_ids(o)
|
|
773
|
+
tr |= _tool_result_ids(o)
|
|
774
|
+
unpaired = tr - tu
|
|
775
|
+
levels = _policy.audit_outcomes(len(bad), len(orph), len(unpaired))
|
|
776
|
+
if bad:
|
|
777
|
+
out.append((levels[0], f"{len(bad)} unparseable line(s): {bad[:10]}"))
|
|
778
|
+
else:
|
|
779
|
+
out.append((levels[0], f"all {sum(1 for o in objs if o is not None)} non-blank lines parse"))
|
|
780
|
+
out.append((levels[1],
|
|
781
|
+
f"{len(orph)} orphaned parentUuid(s)" if orph else "uuid/parentUuid chain intact"))
|
|
782
|
+
out.append((levels[2],
|
|
783
|
+
f"{len(unpaired)} tool_result(s) with no tool_use" if unpaired
|
|
784
|
+
else f"{len(tu)} tool_use / {len(tr)} tool_result ids paired"))
|
|
785
|
+
marked = sum(1 for o in objs if MARKER_PREFIX in _dumps(o)) if objs else 0
|
|
786
|
+
out.append((levels[3], f"{marked} line(s) carry ca-condensed markers"))
|
|
787
|
+
return out
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
# --------------------------------------------------------------------------- #
|
|
791
|
+
# Write protocol (live-race-safe; see plan "Biggest risk" §3-§5)
|
|
792
|
+
# --------------------------------------------------------------------------- #
|
|
793
|
+
|
|
794
|
+
def backup_dir():
|
|
795
|
+
return os.path.join(os.path.expanduser("~"), ".codearbiter", "prune-backups")
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _safe_session(s):
|
|
799
|
+
"""Reduce a session id to a safe single-path-component filename. The hook
|
|
800
|
+
payload's `session_id` is external input that feeds the backup filename and a
|
|
801
|
+
glob prefix in _prune_old_backups; an unsanitized value containing `..` or a
|
|
802
|
+
path separator could escape the backup dir. Normally a UUID, but never trust
|
|
803
|
+
it."""
|
|
804
|
+
s = str(s) if s is not None else "session"
|
|
805
|
+
s = re.sub(r"[^A-Za-z0-9._-]", "_", s).strip(".")
|
|
806
|
+
return (s or "session")[:128]
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _prune_old_backups(session, keep):
|
|
810
|
+
d = backup_dir()
|
|
811
|
+
try:
|
|
812
|
+
entries = sorted(f for f in os.listdir(d) if f.startswith(session + "."))
|
|
813
|
+
except Exception: # noqa: BLE001
|
|
814
|
+
return
|
|
815
|
+
for f in entries[:-keep] if keep > 0 else entries:
|
|
816
|
+
try:
|
|
817
|
+
os.remove(os.path.join(d, f))
|
|
818
|
+
except Exception: # noqa: BLE001
|
|
819
|
+
pass
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def self_heal(path, session="session"):
|
|
823
|
+
"""Detect and repair the mid-write crash corpse, before any new prune.
|
|
824
|
+
|
|
825
|
+
write_in_place writes new_bytes (shorter) over the original and THEN
|
|
826
|
+
truncates. A process death between the two leaves the file as
|
|
827
|
+
new_bytes + orig[len(new_bytes):] — the boundary lands inside an old line,
|
|
828
|
+
so exactly one line is unparseable JSON. The newest backup for the session
|
|
829
|
+
holds the original; restore it, preserving any lines a live appender added
|
|
830
|
+
after the crash (they sit beyond len(backup)).
|
|
831
|
+
|
|
832
|
+
Conservative by design: heals ONLY when the damage matches that splice
|
|
833
|
+
signature — one bad line, file at least backup-sized, and the bytes from
|
|
834
|
+
the end of the bad line up to len(backup) identical to the backup's. Any
|
|
835
|
+
other corruption is left alone for a human. Returns (healed, note).
|
|
836
|
+
"""
|
|
837
|
+
if os.path.islink(path):
|
|
838
|
+
return False, "symlink"
|
|
839
|
+
try:
|
|
840
|
+
with open(path, "rb") as f:
|
|
841
|
+
corpse = f.read()
|
|
842
|
+
except OSError as e:
|
|
843
|
+
return False, f"unreadable ({e})"
|
|
844
|
+
parts = corpse.split(b"\n")
|
|
845
|
+
objs = _parts_objs(corpse)
|
|
846
|
+
bad = [i for i, o in enumerate(objs) if isinstance(o, tuple)]
|
|
847
|
+
if not bad:
|
|
848
|
+
return False, "clean"
|
|
849
|
+
session = _safe_session(session)
|
|
850
|
+
d = backup_dir()
|
|
851
|
+
try:
|
|
852
|
+
entries = sorted(f for f in os.listdir(d) if f.startswith(session + "."))
|
|
853
|
+
except OSError:
|
|
854
|
+
entries = []
|
|
855
|
+
if not entries:
|
|
856
|
+
return False, "corrupt, but no backup for this session"
|
|
857
|
+
bpath = os.path.join(d, entries[-1])
|
|
858
|
+
try:
|
|
859
|
+
with open(bpath, "rb") as f:
|
|
860
|
+
backup = f.read()
|
|
861
|
+
except OSError as e:
|
|
862
|
+
return False, f"backup unreadable ({e})"
|
|
863
|
+
if any(isinstance(o, tuple) for o in _parts_objs(backup)):
|
|
864
|
+
return False, "backup itself is corrupt"
|
|
865
|
+
# --- splice-signature checks -------------------------------------------
|
|
866
|
+
if len(bad) != 1:
|
|
867
|
+
return False, "corruption does not match a prune splice (multiple bad lines)"
|
|
868
|
+
if len(corpse) < len(backup):
|
|
869
|
+
return False, "file shorter than backup; not an interrupted truncate"
|
|
870
|
+
# Byte offset just past the bad line (its trailing \n included).
|
|
871
|
+
end_off = sum(len(p) + 1 for p in parts[:bad[0] + 1])
|
|
872
|
+
end_off = min(end_off, len(corpse))
|
|
873
|
+
if end_off > len(backup) or corpse[end_off:len(backup)] != backup[end_off:len(backup)]:
|
|
874
|
+
return False, "corruption does not match a prune splice (tail differs from backup)"
|
|
875
|
+
# --- heal ----------------------------------------------------------------
|
|
876
|
+
# Everything past len(backup) was appended by the live session AFTER the
|
|
877
|
+
# crash (the file was exactly backup-sized when the prune died); keep it.
|
|
878
|
+
tail = corpse[len(backup):]
|
|
879
|
+
healed = backup + tail # len(healed) == len(corpse): no truncate needed,
|
|
880
|
+
# and an append racing this write lands beyond it.
|
|
881
|
+
with open(path, "r+b") as f:
|
|
882
|
+
f.write(healed)
|
|
883
|
+
f.flush()
|
|
884
|
+
os.fsync(f.fileno())
|
|
885
|
+
append_audit_log({
|
|
886
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
887
|
+
"session": session, "path": path,
|
|
888
|
+
"verdict": f"self-healed from {entries[-1]}",
|
|
889
|
+
})
|
|
890
|
+
return True, f"healed from backup {entries[-1]}"
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def write_in_place(path, new_bytes, pre_stat, cfg, session="session",
|
|
894
|
+
_probe=None):
|
|
895
|
+
"""Same-inode, shrink-only rewrite that is safe against a concurrent appender.
|
|
896
|
+
|
|
897
|
+
Returns (ok: bool, verdict: str). `_probe`, if given, is invoked right after
|
|
898
|
+
write+fsync and before the truncate decision — a test seam to simulate a
|
|
899
|
+
concurrent append.
|
|
900
|
+
"""
|
|
901
|
+
# Refuse to follow a symlink: we rewrite in place and back up the target, so
|
|
902
|
+
# a symlinked transcript path would let us rewrite (and copy out) an
|
|
903
|
+
# arbitrary file. Transcripts are always real files under ~/.claude/projects.
|
|
904
|
+
if os.path.islink(path):
|
|
905
|
+
return False, "skipped: refusing to write through a symlink"
|
|
906
|
+
session = _safe_session(session)
|
|
907
|
+
# Re-stat: bail if the session moved on since we read it.
|
|
908
|
+
try:
|
|
909
|
+
now = os.stat(path)
|
|
910
|
+
except OSError as e:
|
|
911
|
+
return False, f"skipped: stat failed ({e})"
|
|
912
|
+
if (now.st_size, now.st_mtime_ns) != (pre_stat.st_size, pre_stat.st_mtime_ns):
|
|
913
|
+
return False, "skipped: file changed since read"
|
|
914
|
+
|
|
915
|
+
old_size = pre_stat.st_size
|
|
916
|
+
with open(path, "rb") as f:
|
|
917
|
+
orig_bytes = f.read()
|
|
918
|
+
|
|
919
|
+
# Backup first.
|
|
920
|
+
d = backup_dir()
|
|
921
|
+
os.makedirs(d, exist_ok=True)
|
|
922
|
+
ts = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
|
|
923
|
+
bpath = os.path.join(d, f"{session}.{ts}.jsonl")
|
|
924
|
+
shutil.copy2(path, bpath)
|
|
925
|
+
|
|
926
|
+
def _restore_prefix():
|
|
927
|
+
# Rewrite the original prefix; leave anything appended past old_size.
|
|
928
|
+
with open(path, "r+b") as f:
|
|
929
|
+
f.write(orig_bytes)
|
|
930
|
+
f.flush()
|
|
931
|
+
os.fsync(f.fileno())
|
|
932
|
+
|
|
933
|
+
with open(path, "r+b") as f:
|
|
934
|
+
f.write(new_bytes)
|
|
935
|
+
f.flush()
|
|
936
|
+
os.fsync(f.fileno())
|
|
937
|
+
if _probe is not None:
|
|
938
|
+
_probe(path)
|
|
939
|
+
grown = os.fstat(f.fileno()).st_size
|
|
940
|
+
if grown > old_size:
|
|
941
|
+
# A concurrent append landed during our write. Truncating now would
|
|
942
|
+
# eat that line. Restore the prefix and bail.
|
|
943
|
+
f.close()
|
|
944
|
+
_restore_prefix()
|
|
945
|
+
_prune_old_backups(session, cfg.backups)
|
|
946
|
+
return False, "rolled-back: concurrent append during write"
|
|
947
|
+
f.truncate(len(new_bytes))
|
|
948
|
+
f.flush()
|
|
949
|
+
os.fsync(f.fileno())
|
|
950
|
+
|
|
951
|
+
# Post-write: re-read and re-validate what actually landed. We have no
|
|
952
|
+
# dirty-flag context here, so this is a phase-agnostic structural check of
|
|
953
|
+
# the disk image against the original.
|
|
954
|
+
with open(path, "rb") as f:
|
|
955
|
+
landed = f.read()
|
|
956
|
+
errs = _post_write_check(orig_bytes, landed)
|
|
957
|
+
if errs:
|
|
958
|
+
# Roll back without eating a concurrent append. At this point the file
|
|
959
|
+
# is new_bytes (we truncated above); a live appender may have added
|
|
960
|
+
# lines at offset len(new_bytes) since. Those bytes sit BELOW
|
|
961
|
+
# len(orig_bytes), so blindly rewriting the original prefix (let alone
|
|
962
|
+
# truncating) would destroy them. Capture the appended tail first,
|
|
963
|
+
# restore the original, and re-append the tail after it.
|
|
964
|
+
with open(path, "r+b") as f:
|
|
965
|
+
cur = os.fstat(f.fileno()).st_size
|
|
966
|
+
tail = b""
|
|
967
|
+
if cur > len(new_bytes):
|
|
968
|
+
f.seek(len(new_bytes))
|
|
969
|
+
tail = f.read()
|
|
970
|
+
f.seek(0)
|
|
971
|
+
f.write(orig_bytes + tail)
|
|
972
|
+
f.flush()
|
|
973
|
+
os.fsync(f.fileno())
|
|
974
|
+
# Mirror the main-path growth guard: truncate only if nothing newer
|
|
975
|
+
# landed during the restore itself.
|
|
976
|
+
end = os.fstat(f.fileno()).st_size
|
|
977
|
+
if end <= len(orig_bytes) + len(tail):
|
|
978
|
+
f.truncate(len(orig_bytes) + len(tail))
|
|
979
|
+
_prune_old_backups(session, cfg.backups)
|
|
980
|
+
return False, "rolled-back: post-write validation failed: " + "; ".join(errs)
|
|
981
|
+
|
|
982
|
+
_prune_old_backups(session, cfg.backups)
|
|
983
|
+
return True, "written"
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _post_write_check(orig_bytes, landed):
|
|
987
|
+
"""Structural re-validation of the on-disk result (no dirty-flag context)."""
|
|
988
|
+
errs = []
|
|
989
|
+
new_objs = _parts_objs(landed)
|
|
990
|
+
for i, o in enumerate(new_objs):
|
|
991
|
+
if isinstance(o, tuple):
|
|
992
|
+
errs.append(f"line {i} unparseable")
|
|
993
|
+
if len(orig_bytes.split(b"\n")) != len(landed.split(b"\n")):
|
|
994
|
+
errs.append("line count changed on disk")
|
|
995
|
+
if len(landed) > len(orig_bytes):
|
|
996
|
+
errs.append("disk image larger than original")
|
|
997
|
+
old_objs = _parts_objs(orig_bytes)
|
|
998
|
+
|
|
999
|
+
def ids(objs):
|
|
1000
|
+
tu, tr = set(), set()
|
|
1001
|
+
for o in objs:
|
|
1002
|
+
if isinstance(o, dict):
|
|
1003
|
+
tu |= _tool_use_ids(o)
|
|
1004
|
+
tr |= _tool_result_ids(o)
|
|
1005
|
+
return tu, tr
|
|
1006
|
+
if ids(new_objs) != ids(old_objs):
|
|
1007
|
+
errs.append("tool id sets changed on disk")
|
|
1008
|
+
if (_orphans(new_objs) - _orphans(old_objs)):
|
|
1009
|
+
errs.append("new orphaned parentUuid on disk")
|
|
1010
|
+
return errs
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def state_path():
|
|
1014
|
+
return os.path.join(os.path.expanduser("~"), ".codearbiter", "prune-state.json")
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def dry_metrics_path(env=None):
|
|
1018
|
+
"""Dedicated dry-run data-collection log. One shared append-only JSONL file
|
|
1019
|
+
every session writes to — the evidence base for the dry->on go/no-go.
|
|
1020
|
+
Defaults under ~/.codearbiter/metrics; CODEARBITER_PRUNE_METRICS overrides
|
|
1021
|
+
the full path (with ~ expansion)."""
|
|
1022
|
+
e = env if env is not None else os.environ
|
|
1023
|
+
override = e.get("CODEARBITER_PRUNE_METRICS")
|
|
1024
|
+
if override:
|
|
1025
|
+
return os.path.expanduser(override)
|
|
1026
|
+
return os.path.join(
|
|
1027
|
+
os.path.expanduser("~"), ".codearbiter", "metrics", "prune-dry.jsonl")
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def append_dry_metrics(record, env=None):
|
|
1031
|
+
"""Append one dry-run record to the shared metrics log. Best-effort: a
|
|
1032
|
+
logging failure must never break the turn (the caller always exits 0)."""
|
|
1033
|
+
p = dry_metrics_path(env)
|
|
1034
|
+
try:
|
|
1035
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
1036
|
+
with open(p, "a", encoding="utf-8") as f:
|
|
1037
|
+
f.write(_dumps(record) + "\n")
|
|
1038
|
+
except Exception: # noqa: BLE001
|
|
1039
|
+
pass
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def load_state():
|
|
1043
|
+
try:
|
|
1044
|
+
with open(state_path(), encoding="utf-8") as f:
|
|
1045
|
+
d = json.load(f)
|
|
1046
|
+
return d if isinstance(d, dict) else {}
|
|
1047
|
+
except Exception: # noqa: BLE001
|
|
1048
|
+
return {}
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def save_state(state):
|
|
1052
|
+
"""Persist the global cross-session prune-state.json atomically
|
|
1053
|
+
(reliability-008): a sibling temp file + os.replace via
|
|
1054
|
+
_hooklib.write_text_atomic, so a crash mid-write leaves the PRIOR valid
|
|
1055
|
+
state file intact instead of a torn/truncated one. Best-effort: any
|
|
1056
|
+
failure (including the simulated one write_text_atomic re-raises) is
|
|
1057
|
+
swallowed here — a prune-state write must never break the user's turn."""
|
|
1058
|
+
d = os.path.dirname(state_path())
|
|
1059
|
+
try:
|
|
1060
|
+
os.makedirs(d, exist_ok=True)
|
|
1061
|
+
_hooklib.write_text_atomic(state_path(), _dumps(state))
|
|
1062
|
+
except Exception: # noqa: BLE001
|
|
1063
|
+
pass
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def tail_is_settled(lines):
|
|
1067
|
+
"""True iff the transcript tail is a clean turn boundary: the most recent
|
|
1068
|
+
assistant turn's tool calls are all resolved and we're not mid queue-op."""
|
|
1069
|
+
last_asst = None
|
|
1070
|
+
tr = set()
|
|
1071
|
+
for ln in lines:
|
|
1072
|
+
if isinstance(ln.obj, dict):
|
|
1073
|
+
if ln.obj.get("type") == "assistant":
|
|
1074
|
+
last_asst = ln
|
|
1075
|
+
tr |= _tool_result_ids(ln.obj)
|
|
1076
|
+
if last_asst is not None and (_tool_use_ids(last_asst.obj) - tr):
|
|
1077
|
+
return False # an open tool loop on the latest assistant turn
|
|
1078
|
+
for ln in reversed(lines):
|
|
1079
|
+
if isinstance(ln.obj, dict):
|
|
1080
|
+
if ln.obj.get("type") == "queue-operation":
|
|
1081
|
+
return False
|
|
1082
|
+
break
|
|
1083
|
+
return True
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
# --------------------------------------------------------------------------- #
|
|
1087
|
+
# Cold-miss nudge helpers (opt-in; `CODEARBITER_PRUNE_NUDGE=on` required)
|
|
1088
|
+
# --------------------------------------------------------------------------- #
|
|
1089
|
+
|
|
1090
|
+
def _parse_iso8601(s):
|
|
1091
|
+
"""Parse a UTC (`Z`-suffixed) ISO 8601 datetime to an epoch int.
|
|
1092
|
+
|
|
1093
|
+
Claude Code transcript timestamps are always UTC `Z`. We deliberately
|
|
1094
|
+
handle only that form plus fractional seconds; a non-`Z` numeric offset
|
|
1095
|
+
(`+HH:MM` / `-HH:MM`) is treated as unknown and returns None rather than
|
|
1096
|
+
being silently misread as UTC. Stdlib-only; never raises (fail open)."""
|
|
1097
|
+
try:
|
|
1098
|
+
s = str(s).strip()
|
|
1099
|
+
if not s.endswith("Z"):
|
|
1100
|
+
return None # offset-bearing or naive timestamp: don't guess → no nudge
|
|
1101
|
+
s = s[:-1].split(".")[0] # drop the Z and any fractional seconds
|
|
1102
|
+
return calendar.timegm(time.strptime(s, "%Y-%m-%dT%H:%M:%S"))
|
|
1103
|
+
except Exception: # noqa: BLE001 — fail open
|
|
1104
|
+
return None
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _last_assistant_ts(data):
|
|
1108
|
+
"""Return epoch secs of the most recent assistant turn's top-level
|
|
1109
|
+
`timestamp`, or None when absent/unparseable."""
|
|
1110
|
+
ts = None
|
|
1111
|
+
for ln in load_lines(data):
|
|
1112
|
+
o = ln.obj
|
|
1113
|
+
if isinstance(o, dict) and o.get("type") == "assistant" and o.get("timestamp"):
|
|
1114
|
+
ts = o.get("timestamp")
|
|
1115
|
+
return _parse_iso8601(ts) if ts is not None else None
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def _idle_seconds(data, now):
|
|
1119
|
+
"""Seconds since the last assistant turn, or None when unknown."""
|
|
1120
|
+
t = _last_assistant_ts(data)
|
|
1121
|
+
return None if t is None else now - t
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _nudge_advisory(rec):
|
|
1125
|
+
"""Build the advisory string from state-record numbers only.
|
|
1126
|
+
Never includes transcript content — only context-byte/token aggregates."""
|
|
1127
|
+
context_freed = int(rec.get("context_bytes_freed", 0) or 0)
|
|
1128
|
+
context_tokens = est_tokens(context_freed)
|
|
1129
|
+
return (f"Cold cache miss can re-cache ~{context_tokens // 1000}k avoidable "
|
|
1130
|
+
f"context tokens. /compact or exit + --resume lands that re-cache "
|
|
1131
|
+
f"on pruned context. Submit again to proceed.")
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def nudge_decision(rec, idle_secs, e):
|
|
1135
|
+
"""Decide whether to fire the cold-miss nudge. Pure + fail-open.
|
|
1136
|
+
|
|
1137
|
+
Returns (armed: bool, advisory: str, new_rec: dict). new_rec is `rec`
|
|
1138
|
+
unchanged unless the cold_nudged marker must flip (arm) or clear (warm
|
|
1139
|
+
reset). `rec` is a prune-state session record; `idle_secs` is seconds
|
|
1140
|
+
since the last assistant turn (None if unknown); `e` is the env mapping.
|
|
1141
|
+
"""
|
|
1142
|
+
rec = rec if isinstance(rec, dict) else {}
|
|
1143
|
+
if (e.get("CODEARBITER_PRUNE_NUDGE", "off") or "off").lower() != "on":
|
|
1144
|
+
return (False, "", rec)
|
|
1145
|
+
|
|
1146
|
+
def _num(key, default):
|
|
1147
|
+
try:
|
|
1148
|
+
return int(e[key])
|
|
1149
|
+
except Exception: # noqa: BLE001
|
|
1150
|
+
return default
|
|
1151
|
+
|
|
1152
|
+
idle_floor = _num("CODEARBITER_PRUNE_NUDGE_IDLE_SECS", 240)
|
|
1153
|
+
min_tokens = _num("CODEARBITER_PRUNE_NUDGE_MIN_TOKENS", 80000)
|
|
1154
|
+
cold = isinstance(idle_secs, (int, float)) and idle_secs >= idle_floor
|
|
1155
|
+
|
|
1156
|
+
if not cold:
|
|
1157
|
+
if rec.get("cold_nudged"): # warm submit resets the window
|
|
1158
|
+
nr = dict(rec)
|
|
1159
|
+
nr["cold_nudged"] = False
|
|
1160
|
+
return (False, "", nr)
|
|
1161
|
+
return (False, "", rec)
|
|
1162
|
+
|
|
1163
|
+
freed = rec.get("context_bytes_freed")
|
|
1164
|
+
if type(freed) is not int or freed < 0 or est_tokens(freed) < min_tokens:
|
|
1165
|
+
return (False, "", rec)
|
|
1166
|
+
|
|
1167
|
+
if rec.get("cold_nudged"): # already fired this cold window
|
|
1168
|
+
return (False, "", rec)
|
|
1169
|
+
|
|
1170
|
+
nr = dict(rec)
|
|
1171
|
+
nr["cold_nudged"] = True
|
|
1172
|
+
return (True, _nudge_advisory(rec), nr)
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def hook_run(payload, env=None):
|
|
1176
|
+
"""Service-mode entry: gate, short-circuit cheaply, prune at a safe point,
|
|
1177
|
+
and record state for the statusline. Normally returns 0; returns 2 only
|
|
1178
|
+
when the cold-miss nudge is armed (opt-in, see nudge_decision). A pruner
|
|
1179
|
+
fault must never block the user's prompt or break the session."""
|
|
1180
|
+
e = env if env is not None else os.environ
|
|
1181
|
+
mode = (e.get("CODEARBITER_PRUNE", "off") or "off").lower()
|
|
1182
|
+
if mode not in ("dry", "on"):
|
|
1183
|
+
return 0
|
|
1184
|
+
path = payload.get("transcript_path")
|
|
1185
|
+
if not path or not os.path.isfile(path):
|
|
1186
|
+
return 0
|
|
1187
|
+
# N-1: containment — transcript must live under ~/.claude/ to prevent an
|
|
1188
|
+
# attacker-controlled payload from pointing the pruner at arbitrary files.
|
|
1189
|
+
try:
|
|
1190
|
+
_claude_home = os.path.normcase(os.path.normpath(
|
|
1191
|
+
os.path.realpath(os.path.expanduser("~/.claude"))
|
|
1192
|
+
))
|
|
1193
|
+
_real_path = os.path.normcase(os.path.normpath(os.path.realpath(path)))
|
|
1194
|
+
if not _real_path.startswith(_claude_home):
|
|
1195
|
+
return 0
|
|
1196
|
+
except Exception: # noqa: BLE001 — realpath can fail on unusual mounts
|
|
1197
|
+
return 0
|
|
1198
|
+
root = payload.get("cwd") or os.getcwd()
|
|
1199
|
+
try:
|
|
1200
|
+
import _hooklib
|
|
1201
|
+
if not _hooklib.arbiter_active(root):
|
|
1202
|
+
return 0
|
|
1203
|
+
except Exception: # noqa: BLE001
|
|
1204
|
+
return 0
|
|
1205
|
+
session = payload.get("session_id") or os.path.splitext(os.path.basename(path))[0]
|
|
1206
|
+
cfg = Config.from_env(e)
|
|
1207
|
+
cfg.execute = (mode == "on")
|
|
1208
|
+
if cfg.execute:
|
|
1209
|
+
# Repair the corpse a prune killed mid write/truncate may have left,
|
|
1210
|
+
# BEFORE any gate reads or short-circuits on the damaged file.
|
|
1211
|
+
try:
|
|
1212
|
+
self_heal(path, session)
|
|
1213
|
+
except Exception: # noqa: BLE001 — never let healing break the turn
|
|
1214
|
+
return 0
|
|
1215
|
+
try:
|
|
1216
|
+
st = os.stat(path)
|
|
1217
|
+
except OSError:
|
|
1218
|
+
return 0
|
|
1219
|
+
if st.st_size < cfg.min_size or st.st_size > (50 << 20):
|
|
1220
|
+
return 0
|
|
1221
|
+
try:
|
|
1222
|
+
state = load_state()
|
|
1223
|
+
except Exception: # noqa: BLE001 — fail open on any state-read fault
|
|
1224
|
+
return 0
|
|
1225
|
+
rec = state.get(session, {})
|
|
1226
|
+
if not isinstance(rec, dict):
|
|
1227
|
+
rec = {}
|
|
1228
|
+
state[session] = rec
|
|
1229
|
+
# Cold-miss nudge (opt-in): the ONLY non-zero return in this hook. Evaluated
|
|
1230
|
+
# before the growth short-circuit so an idle user (no new bytes) still nudges.
|
|
1231
|
+
if cfg.execute and (e.get("CODEARBITER_PRUNE_NUDGE", "off") or "off").lower() == "on":
|
|
1232
|
+
try:
|
|
1233
|
+
with open(path, "rb") as f:
|
|
1234
|
+
ndata = f.read()
|
|
1235
|
+
armed, advisory, new_rec = nudge_decision(
|
|
1236
|
+
rec, _idle_seconds(ndata, time.time()), e)
|
|
1237
|
+
if new_rec != rec:
|
|
1238
|
+
state[session] = new_rec
|
|
1239
|
+
save_state(state)
|
|
1240
|
+
if armed:
|
|
1241
|
+
sys.stderr.write(advisory + "\n")
|
|
1242
|
+
return 2
|
|
1243
|
+
except Exception: # noqa: BLE001 — fail open; pruner fault must never block
|
|
1244
|
+
pass
|
|
1245
|
+
last_pruned_size = rec.get("last_pruned_size")
|
|
1246
|
+
valid_last_pruned_size = (
|
|
1247
|
+
(type(last_pruned_size) is int and last_pruned_size >= 0)
|
|
1248
|
+
or (type(last_pruned_size) is float
|
|
1249
|
+
and last_pruned_size >= 0 and math.isfinite(last_pruned_size))
|
|
1250
|
+
)
|
|
1251
|
+
if valid_last_pruned_size \
|
|
1252
|
+
and (st.st_size - last_pruned_size) < cfg.min_growth:
|
|
1253
|
+
return 0 # cheap stat short-circuit: not enough new bytes
|
|
1254
|
+
# performance-001: read + parse the transcript ONCE here and thread the
|
|
1255
|
+
# bytes/lines/pre_stat through to run() below, instead of letting run()
|
|
1256
|
+
# re-open and re-parse the identical on-disk content a second time. The
|
|
1257
|
+
# fstat is captured from the SAME open fd as the read (not a separate
|
|
1258
|
+
# os.stat afterwards) so it reflects exactly the bytes just read, matching
|
|
1259
|
+
# what run()'s own self-contained read would have captured.
|
|
1260
|
+
try:
|
|
1261
|
+
with open(path, "rb") as f:
|
|
1262
|
+
data = f.read()
|
|
1263
|
+
pre_stat = os.fstat(f.fileno())
|
|
1264
|
+
except Exception: # noqa: BLE001
|
|
1265
|
+
return 0
|
|
1266
|
+
lines = load_lines(data)
|
|
1267
|
+
if not tail_is_settled(lines):
|
|
1268
|
+
return 0
|
|
1269
|
+
try:
|
|
1270
|
+
res = run(path, cfg, session=session, data=data, lines=lines, pre_stat=pre_stat)
|
|
1271
|
+
except Exception: # noqa: BLE001 — never let pruning break the turn
|
|
1272
|
+
return 0
|
|
1273
|
+
b0, b1 = res["bytes_before"], res["bytes_after"]
|
|
1274
|
+
strategy_savings = {
|
|
1275
|
+
name: row["bytes_before"] - row["bytes_after"]
|
|
1276
|
+
for name, row in res["strategies"].items()
|
|
1277
|
+
}
|
|
1278
|
+
reduction = _policy.reduction_metrics(b0, b1, strategy_savings)
|
|
1279
|
+
if not cfg.execute:
|
|
1280
|
+
# Dry mode: persist the would-be outcome to the shared data-collection
|
|
1281
|
+
# log so confidence accrues across sessions before flipping to on.
|
|
1282
|
+
append_dry_metrics({
|
|
1283
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
1284
|
+
"session": session,
|
|
1285
|
+
"mode": "dry",
|
|
1286
|
+
"tier": cfg.tier,
|
|
1287
|
+
**reduction,
|
|
1288
|
+
"verdict": res["verdict"],
|
|
1289
|
+
"validation_errors": len(res["validation_errors"]),
|
|
1290
|
+
"strategies": {k: v["bytes_before"] - v["bytes_after"]
|
|
1291
|
+
for k, v in res["strategies"].items()},
|
|
1292
|
+
"strategy_scopes": {k: v["metric_scope"]
|
|
1293
|
+
for k, v in res["strategies"].items()},
|
|
1294
|
+
}, env=e)
|
|
1295
|
+
# Carry cold_nudged forward so the once-per-cold-window marker survives a
|
|
1296
|
+
# normal (non-armed) prune run. Re-read from state in case the nudge block
|
|
1297
|
+
# above already flushed an updated rec (e.g. a warm-submit clear).
|
|
1298
|
+
_cold_nudged = state.get(session, {}).get("cold_nudged")
|
|
1299
|
+
new_state = {
|
|
1300
|
+
"path": path,
|
|
1301
|
+
"last_size": b1 if res["executed"] else b0,
|
|
1302
|
+
"last_pruned_size": (b1 if res["executed"] else st.st_size),
|
|
1303
|
+
"last_run_ts": int(time.time()),
|
|
1304
|
+
"pct": reduction["pct"],
|
|
1305
|
+
"freed_bytes": reduction["freed_bytes"],
|
|
1306
|
+
"file_pct": reduction["file_pct"],
|
|
1307
|
+
"file_bytes_freed": reduction["file_bytes_freed"],
|
|
1308
|
+
"file_est_tokens_freed": reduction["file_est_tokens_freed"],
|
|
1309
|
+
"context_bytes_freed": reduction["context_bytes_freed"],
|
|
1310
|
+
"context_est_tokens_freed": reduction["context_est_tokens_freed"],
|
|
1311
|
+
"verdict": res["verdict"],
|
|
1312
|
+
}
|
|
1313
|
+
if _cold_nudged:
|
|
1314
|
+
new_state["cold_nudged"] = _cold_nudged
|
|
1315
|
+
state[session] = new_state
|
|
1316
|
+
save_state(state)
|
|
1317
|
+
return 0
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def append_audit_log(record):
|
|
1321
|
+
d = os.path.join(os.path.expanduser("~"), ".codearbiter")
|
|
1322
|
+
os.makedirs(d, exist_ok=True)
|
|
1323
|
+
try:
|
|
1324
|
+
with open(os.path.join(d, "prune.log"), "a", encoding="utf-8") as f:
|
|
1325
|
+
f.write(_dumps(record) + "\n")
|
|
1326
|
+
except Exception: # noqa: BLE001
|
|
1327
|
+
pass
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
# --------------------------------------------------------------------------- #
|
|
1331
|
+
# Top-level run (dry-run analysis; execute optionally writes)
|
|
1332
|
+
# --------------------------------------------------------------------------- #
|
|
1333
|
+
|
|
1334
|
+
def run(path, cfg, session="session", data=None, lines=None, pre_stat=None):
|
|
1335
|
+
"""Prune `path` per `cfg`. Always computes the report; writes only when
|
|
1336
|
+
cfg.execute and validation passes. Returns a result dict.
|
|
1337
|
+
|
|
1338
|
+
performance-001: `data`/`lines`/`pre_stat` let a caller that has ALREADY
|
|
1339
|
+
read+parsed the transcript (hook_run's tail_is_settled check) hand that
|
|
1340
|
+
single read straight to the pruning pass, instead of run() re-opening and
|
|
1341
|
+
re-parsing the identical on-disk bytes a second time. When `data` is None
|
|
1342
|
+
(the default — direct/standalone callers, e.g. the CLI and tests), run()
|
|
1343
|
+
self-heals and reads the file itself exactly as before, so behavior for
|
|
1344
|
+
those callers is unchanged."""
|
|
1345
|
+
if data is None:
|
|
1346
|
+
if cfg.execute:
|
|
1347
|
+
# A prior prune killed between write and truncate leaves a spliced
|
|
1348
|
+
# file; restore it from backup before analyzing. (Dry-run never
|
|
1349
|
+
# writes, including this.)
|
|
1350
|
+
self_heal(path, session)
|
|
1351
|
+
with open(path, "rb") as f:
|
|
1352
|
+
orig_bytes = f.read()
|
|
1353
|
+
pre_stat = os.fstat(f.fileno())
|
|
1354
|
+
lines = load_lines(orig_bytes)
|
|
1355
|
+
else:
|
|
1356
|
+
# Caller already read (and self-healed, if executing) the file once —
|
|
1357
|
+
# reuse its bytes/parse rather than re-reading path from disk.
|
|
1358
|
+
orig_bytes = data
|
|
1359
|
+
if lines is None:
|
|
1360
|
+
lines = load_lines(orig_bytes)
|
|
1361
|
+
if pre_stat is None:
|
|
1362
|
+
pre_stat = os.stat(path)
|
|
1363
|
+
index = build_index(lines, cfg)
|
|
1364
|
+
report = apply_strategies(lines, index, cfg)
|
|
1365
|
+
new_bytes = serialize(lines)
|
|
1366
|
+
|
|
1367
|
+
errs = validate(orig_bytes, new_bytes, lines, cfg)
|
|
1368
|
+
strategy_savings = {
|
|
1369
|
+
name: row["bytes_before"] - row["bytes_after"]
|
|
1370
|
+
for name, row in report.items()
|
|
1371
|
+
}
|
|
1372
|
+
reduction = _policy.reduction_metrics(
|
|
1373
|
+
len(orig_bytes), len(new_bytes), strategy_savings)
|
|
1374
|
+
result = {
|
|
1375
|
+
"path": path,
|
|
1376
|
+
**reduction,
|
|
1377
|
+
"strategies": report,
|
|
1378
|
+
"validation_errors": errs,
|
|
1379
|
+
"executed": False,
|
|
1380
|
+
"verdict": "dry-run",
|
|
1381
|
+
}
|
|
1382
|
+
if errs:
|
|
1383
|
+
result["verdict"] = "refused: validation failed"
|
|
1384
|
+
return result
|
|
1385
|
+
if cfg.execute:
|
|
1386
|
+
ok, verdict = write_in_place(path, new_bytes, pre_stat, cfg, session=session)
|
|
1387
|
+
result["executed"] = ok
|
|
1388
|
+
result["verdict"] = verdict
|
|
1389
|
+
append_audit_log({
|
|
1390
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
1391
|
+
"session": session, "path": path,
|
|
1392
|
+
**reduction,
|
|
1393
|
+
"strategies": {k: v["bytes_before"] - v["bytes_after"]
|
|
1394
|
+
for k, v in report.items()},
|
|
1395
|
+
"strategy_scopes": {k: v["metric_scope"] for k, v in report.items()},
|
|
1396
|
+
"verdict": verdict,
|
|
1397
|
+
})
|
|
1398
|
+
return result
|