@brandry/claude-jsonl-compressor 1.0.0-rc.1

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.
@@ -0,0 +1,605 @@
1
+ #!/usr/bin/env python3
2
+ """Byte-preserving, explicit compatibility repairs for one Claude JSONL."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import collections
7
+ import copy
8
+ import json
9
+ import pathlib
10
+ import sys
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
13
+
14
+ SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
15
+ if str(SCRIPT_DIR) not in sys.path:
16
+ sys.path.insert(0, str(SCRIPT_DIR))
17
+
18
+ import compress_claude_jsonl as ccj
19
+
20
+
21
+ JsonObj = Dict[str, Any]
22
+ RULE_NAME = "remove-unsupported-read-pages"
23
+
24
+
25
+ @dataclass
26
+ class Member:
27
+ key: str
28
+ start: int
29
+ end: int
30
+ value: "Node"
31
+
32
+
33
+ @dataclass
34
+ class Node:
35
+ kind: str
36
+ start: int
37
+ end: int
38
+ members: List[Member] = field(default_factory=list)
39
+ items: List["Node"] = field(default_factory=list)
40
+
41
+
42
+ class SpanJsonParser:
43
+ def __init__(self, data: bytes):
44
+ self.data = data
45
+ self.pos = 0
46
+
47
+ def parse(self) -> Node:
48
+ self._ws()
49
+ node = self._value()
50
+ self._ws()
51
+ if self.pos != len(self.data):
52
+ raise ValueError(f"unexpected trailing JSON bytes at offset {self.pos}")
53
+ return node
54
+
55
+ def _ws(self) -> None:
56
+ while self.pos < len(self.data) and self.data[self.pos] in b" \t\r\n":
57
+ self.pos += 1
58
+
59
+ def _string(self) -> Tuple[str, int, int]:
60
+ start = self.pos
61
+ if self.pos >= len(self.data) or self.data[self.pos] != 0x22:
62
+ raise ValueError(f"expected JSON string at offset {self.pos}")
63
+ self.pos += 1
64
+ while self.pos < len(self.data):
65
+ byte = self.data[self.pos]
66
+ if byte == 0x5C:
67
+ self.pos += 2
68
+ continue
69
+ self.pos += 1
70
+ if byte == 0x22:
71
+ raw = self.data[start:self.pos].decode("utf-8", errors="strict")
72
+ value = json.loads(raw)
73
+ if not isinstance(value, str):
74
+ raise ValueError("parsed JSON key is not a string")
75
+ return value, start, self.pos
76
+ raise ValueError(f"unterminated JSON string at offset {start}")
77
+
78
+ def _value(self) -> Node:
79
+ self._ws()
80
+ if self.pos >= len(self.data):
81
+ raise ValueError("unexpected end of JSON")
82
+ byte = self.data[self.pos]
83
+ if byte == 0x7B:
84
+ return self._object()
85
+ if byte == 0x5B:
86
+ return self._array()
87
+ if byte == 0x22:
88
+ _value, start, end = self._string()
89
+ return Node("string", start, end)
90
+ start = self.pos
91
+ while self.pos < len(self.data) and self.data[self.pos] not in b",]} \t\r\n":
92
+ self.pos += 1
93
+ if self.pos == start:
94
+ raise ValueError(f"invalid JSON value at offset {start}")
95
+ json.loads(self.data[start:self.pos].decode("ascii", errors="strict"))
96
+ return Node("scalar", start, self.pos)
97
+
98
+ def _object(self) -> Node:
99
+ start = self.pos
100
+ self.pos += 1
101
+ members: List[Member] = []
102
+ self._ws()
103
+ if self.pos < len(self.data) and self.data[self.pos] == 0x7D:
104
+ self.pos += 1
105
+ return Node("object", start, self.pos, members=members)
106
+ while True:
107
+ self._ws()
108
+ key, key_start, _key_end = self._string()
109
+ self._ws()
110
+ if self.pos >= len(self.data) or self.data[self.pos] != 0x3A:
111
+ raise ValueError(f"expected ':' after object key at offset {self.pos}")
112
+ self.pos += 1
113
+ value = self._value()
114
+ members.append(Member(key, key_start, value.end, value))
115
+ self._ws()
116
+ if self.pos >= len(self.data):
117
+ raise ValueError("unterminated JSON object")
118
+ if self.data[self.pos] == 0x7D:
119
+ self.pos += 1
120
+ return Node("object", start, self.pos, members=members)
121
+ if self.data[self.pos] != 0x2C:
122
+ raise ValueError(f"expected ',' in object at offset {self.pos}")
123
+ self.pos += 1
124
+
125
+ def _array(self) -> Node:
126
+ start = self.pos
127
+ self.pos += 1
128
+ items: List[Node] = []
129
+ self._ws()
130
+ if self.pos < len(self.data) and self.data[self.pos] == 0x5D:
131
+ self.pos += 1
132
+ return Node("array", start, self.pos, items=items)
133
+ while True:
134
+ items.append(self._value())
135
+ self._ws()
136
+ if self.pos >= len(self.data):
137
+ raise ValueError("unterminated JSON array")
138
+ if self.data[self.pos] == 0x5D:
139
+ self.pos += 1
140
+ return Node("array", start, self.pos, items=items)
141
+ if self.data[self.pos] != 0x2C:
142
+ raise ValueError(f"expected ',' in array at offset {self.pos}")
143
+ self.pos += 1
144
+
145
+
146
+ def unique_member(node: Node, key: str) -> Optional[Member]:
147
+ matches = [member for member in node.members if member.key == key]
148
+ if len(matches) > 1:
149
+ raise ValueError(f"duplicate JSON key in repair control path: {key}")
150
+ return matches[0] if matches else None
151
+
152
+
153
+ def assert_no_duplicate_keys(node: Node) -> None:
154
+ if node.kind == "object":
155
+ counts = collections.Counter(member.key for member in node.members)
156
+ duplicates = sorted(key for key, count in counts.items() if count > 1)
157
+ if duplicates:
158
+ raise ValueError(f"duplicate JSON keys make byte repair ambiguous: {duplicates[:20]}")
159
+ for member in node.members:
160
+ assert_no_duplicate_keys(member.value)
161
+ elif node.kind == "array":
162
+ for item in node.items:
163
+ assert_no_duplicate_keys(item)
164
+
165
+
166
+ def member_deletion_span(object_node: Node, target: Member) -> Tuple[int, int]:
167
+ index = object_node.members.index(target)
168
+ if len(object_node.members) == 1:
169
+ return target.start, target.end
170
+ if index < len(object_node.members) - 1:
171
+ return target.start, object_node.members[index + 1].start
172
+ return object_node.members[index - 1].end, target.end
173
+
174
+
175
+ def jsonl_record_spans(data: bytes) -> List[Tuple[int, int, bytes]]:
176
+ spans: List[Tuple[int, int, bytes]] = []
177
+ offset = 0
178
+ while offset < len(data):
179
+ lf_index = data.find(b"\n", offset)
180
+ physical_end = len(data) if lf_index < 0 else lf_index
181
+ content_end = physical_end - 1 if physical_end > offset and data[physical_end - 1] == 0x0D else physical_end
182
+ start = offset
183
+ if start == 0 and data.startswith(b"\xef\xbb\xbf"):
184
+ start = 3
185
+ content = data[start:content_end]
186
+ if content.strip():
187
+ spans.append((start, content_end, content))
188
+ if lf_index < 0:
189
+ break
190
+ offset = lf_index + 1
191
+ return spans
192
+
193
+
194
+ def _node_at_tool_input(root: Node, block_index: int) -> Tuple[Node, Member]:
195
+ message = unique_member(root, "message")
196
+ if message is None or message.value.kind != "object":
197
+ raise ValueError("matched assistant record has no object message node")
198
+ content = unique_member(message.value, "content")
199
+ if content is None or content.value.kind != "array" or block_index >= len(content.value.items):
200
+ raise ValueError("matched tool_use block has no content-array node")
201
+ block = content.value.items[block_index]
202
+ if block.kind != "object":
203
+ raise ValueError("matched tool_use block is not an object node")
204
+ input_member = unique_member(block, "input")
205
+ if input_member is None or input_member.value.kind != "object":
206
+ raise ValueError("matched Read tool_use has no object input node")
207
+ pages = unique_member(input_member.value, "pages")
208
+ if pages is None:
209
+ raise ValueError("matched Read tool_use pages key has no byte span")
210
+ return input_member.value, pages
211
+
212
+
213
+ def plan_read_pages_repairs(
214
+ source_bytes: bytes,
215
+ scope: str = "active-chain",
216
+ resume_leaf_override: Optional[str] = None,
217
+ ) -> Dict[str, Any]:
218
+ records, _raw_lines = ccj.parse_jsonl_bytes(source_bytes, source_label="SOURCE_JSONL")
219
+ line_spans = jsonl_record_spans(source_bytes)
220
+ if len(line_spans) != len(records):
221
+ raise ValueError("record/span count mismatch while planning byte repair")
222
+ parsed_roots: List[Node] = []
223
+ for _line_start, _line_end, line_bytes in line_spans:
224
+ root = SpanJsonParser(line_bytes).parse()
225
+ assert_no_duplicate_keys(root)
226
+ parsed_roots.append(root)
227
+ topology: Optional[Dict[str, Any]] = None
228
+ if scope == "active-chain":
229
+ topology = ccj.require_resume_leaf_info(records, resume_leaf_override=resume_leaf_override)
230
+ scope_indexes = set(topology.get("activeChainIndexes") or [])
231
+ elif scope == "all":
232
+ scope_indexes = set(range(len(records)))
233
+ else:
234
+ raise ValueError(f"unknown repair scope: {scope}")
235
+
236
+ tool_use_occurrences: Dict[str, List[int]] = collections.defaultdict(list)
237
+ tool_result_occurrences: Dict[str, List[int]] = collections.defaultdict(list)
238
+ for record_index in sorted(scope_indexes):
239
+ for tool_id in ccj.tool_use_ids(records[record_index]):
240
+ tool_use_occurrences[tool_id].append(record_index)
241
+ for tool_id in ccj.tool_result_ids(records[record_index]):
242
+ tool_result_occurrences[tool_id].append(record_index)
243
+ patches: List[Dict[str, Any]] = []
244
+ matches: List[Dict[str, Any]] = []
245
+ seen_target_ids: set = set()
246
+ for record_index in sorted(scope_indexes):
247
+ obj = records[record_index]
248
+ if obj.get("type") != "assistant" or ccj.api_role(obj) != "assistant":
249
+ continue
250
+ message = obj.get("message")
251
+ content = message.get("content") if isinstance(message, dict) else None
252
+ if not isinstance(content, list):
253
+ continue
254
+ line_start, _line_end, line_bytes = line_spans[record_index]
255
+ root_node = parsed_roots[record_index]
256
+ for block_index, block in enumerate(content):
257
+ if not isinstance(block, dict) or block.get("type") != "tool_use" or block.get("name") != "Read":
258
+ continue
259
+ tool_input = block.get("input")
260
+ if not isinstance(tool_input, dict) or "pages" not in tool_input:
261
+ continue
262
+ tool_id = block.get("id")
263
+ file_path = tool_input.get("file_path")
264
+ if isinstance(tool_id, str) and len(tool_use_occurrences.get(tool_id, [])) > 1:
265
+ raise ValueError(f"duplicate target tool_use id makes repair ambiguous: {tool_id}")
266
+ if isinstance(tool_id, str) and len(tool_result_occurrences.get(tool_id, [])) > 1:
267
+ raise ValueError(f"duplicate tool_result id makes repair ambiguous: {tool_id}")
268
+ later_results = [
269
+ result_index
270
+ for result_index in tool_result_occurrences.get(tool_id, [])
271
+ if result_index > record_index
272
+ ] if isinstance(tool_id, str) else []
273
+ result_index = later_results[0] if len(later_results) == 1 else None
274
+ result_record = records[result_index] if isinstance(result_index, int) else None
275
+ use_session = obj.get("sessionId")
276
+ result_session = result_record.get("sessionId") if isinstance(result_record, dict) else None
277
+ same_session = (
278
+ isinstance(use_session, str)
279
+ and bool(use_session)
280
+ and isinstance(result_session, str)
281
+ and bool(result_session)
282
+ and use_session == result_session
283
+ )
284
+ assistant_uuid = obj.get("uuid")
285
+ source_uuid = ccj.source_tool_assistant_uuid(result_record) if isinstance(result_record, dict) else None
286
+ source_matches = (
287
+ isinstance(assistant_uuid, str)
288
+ and bool(assistant_uuid)
289
+ and source_uuid == assistant_uuid
290
+ )
291
+ paired = len(later_results) == 1 and same_session and source_matches
292
+ eligible = paired and isinstance(file_path, str) and bool(file_path)
293
+ if eligible:
294
+ reason = "eligible"
295
+ elif len(later_results) != 1:
296
+ reason = "pending-tool-result"
297
+ elif not same_session:
298
+ reason = "cross-session-tool-result"
299
+ elif not source_matches:
300
+ reason = "source-assistant-mismatch"
301
+ else:
302
+ reason = "missing-file-path"
303
+ match = {
304
+ "recordLine": record_index + 1,
305
+ "blockIndex": block_index,
306
+ "toolUseId": tool_id,
307
+ "pairedToolResult": paired,
308
+ "sameSession": same_session,
309
+ "sourceAssistantMatches": source_matches,
310
+ "eligible": eligible,
311
+ "reason": reason,
312
+ }
313
+ matches.append(match)
314
+ if not eligible:
315
+ continue
316
+ if tool_id in seen_target_ids:
317
+ raise ValueError(f"duplicate target tool_use id makes repair ambiguous: {tool_id}")
318
+ seen_target_ids.add(tool_id)
319
+ input_node, pages_member = _node_at_tool_input(root_node, block_index)
320
+ rel_start, rel_end = member_deletion_span(input_node, pages_member)
321
+ patches.append(
322
+ {
323
+ "start": line_start + rel_start,
324
+ "end": line_start + rel_end,
325
+ "recordLine": record_index + 1,
326
+ "blockIndex": block_index,
327
+ "toolUseId": tool_id,
328
+ }
329
+ )
330
+ ordered = sorted(patches, key=lambda item: (item["start"], item["end"]))
331
+ for left, right in zip(ordered, ordered[1:]):
332
+ if left["end"] > right["start"]:
333
+ raise ValueError("planned repair byte spans overlap")
334
+ return {
335
+ "rule": RULE_NAME,
336
+ "scope": scope,
337
+ "sourceSha256": ccj.sha256_hex(source_bytes),
338
+ "sourceBytes": len(source_bytes),
339
+ "recordCount": len(records),
340
+ "targetMatchCount": len(matches),
341
+ "patchableMatchCount": len(patches),
342
+ "pendingMatchCount": sum(1 for item in matches if item["reason"] == "pending-tool-result"),
343
+ "ineligibleMatchCount": sum(1 for item in matches if not item["eligible"]),
344
+ "matches": matches,
345
+ "patches": ordered,
346
+ "resumeTopology": ccj.public_resume_leaf_info(topology),
347
+ }
348
+
349
+
350
+ def apply_patch_plan(source_bytes: bytes, plan: Dict[str, Any]) -> bytes:
351
+ output = source_bytes
352
+ for item in reversed(plan.get("patches") or []):
353
+ start = int(item["start"])
354
+ end = int(item["end"])
355
+ if not (0 <= start < end <= len(output)):
356
+ raise ValueError(f"invalid repair span: {start}:{end}")
357
+ output = output[:start] + output[end:]
358
+ return output
359
+
360
+
361
+ def _uuid_parent_signature(records: Sequence[JsonObj]) -> List[Tuple[Any, Any, Any]]:
362
+ return [(obj.get("type"), obj.get("uuid"), obj.get("parentUuid")) for obj in records]
363
+
364
+
365
+ def validate_repair(source_bytes: bytes, output_bytes: bytes, plan: Dict[str, Any]) -> Dict[str, Any]:
366
+ errors: List[str] = []
367
+ expected = apply_patch_plan(source_bytes, plan)
368
+ if output_bytes != expected:
369
+ errors.append("output bytes differ outside the planned deletion spans")
370
+ source_records, _ = ccj.parse_jsonl_bytes(source_bytes, source_label="SOURCE_JSONL")
371
+ output_records, _ = ccj.parse_jsonl_bytes(output_bytes, source_label="REPAIRED_JSONL")
372
+ if len(source_records) != len(output_records):
373
+ errors.append("record count changed")
374
+ if _uuid_parent_signature(source_records) != _uuid_parent_signature(output_records):
375
+ errors.append("type/uuid/parentUuid signature changed")
376
+ source_tool_ids = [
377
+ (ccj.tool_use_ids(obj), ccj.tool_result_ids(obj)) for obj in source_records
378
+ ]
379
+ output_tool_ids = [
380
+ (ccj.tool_use_ids(obj), ccj.tool_result_ids(obj)) for obj in output_records
381
+ ]
382
+ if source_tool_ids != output_tool_ids:
383
+ errors.append("tool_use/tool_result identifier sequence changed")
384
+ second_plan = plan_read_pages_repairs(
385
+ output_bytes,
386
+ scope=str(plan.get("scope") or "active-chain"),
387
+ resume_leaf_override=(plan.get("resumeTopology") or {}).get("selectedLeafUuid")
388
+ if (plan.get("resumeTopology") or {}).get("manualOverride") else None,
389
+ )
390
+ if second_plan.get("patchableMatchCount") != 0:
391
+ errors.append("repair is not idempotent; a second pass still finds patchable matches")
392
+ return {
393
+ "ok": not errors,
394
+ "errors": errors,
395
+ "sourceSha256": ccj.sha256_hex(source_bytes),
396
+ "outputSha256": ccj.sha256_hex(output_bytes),
397
+ "sourceBytes": len(source_bytes),
398
+ "outputBytes": len(output_bytes),
399
+ "removedBytes": len(source_bytes) - len(output_bytes),
400
+ "recordCount": len(output_records),
401
+ "secondPassPatchableMatchCount": second_plan.get("patchableMatchCount"),
402
+ }
403
+
404
+
405
+ def public_plan(plan: Dict[str, Any]) -> Dict[str, Any]:
406
+ result = copy.deepcopy(plan)
407
+ result.pop("patches", None)
408
+ return result
409
+
410
+
411
+ def publish_repair_candidate(
412
+ path: pathlib.Path,
413
+ source_bytes: bytes,
414
+ output_bytes: bytes,
415
+ plan: Dict[str, Any],
416
+ ) -> Dict[str, Any]:
417
+ if ccj.is_under_claude_root(path):
418
+ raise ValueError("repair candidates and reports must be outside the entire .claude directory")
419
+ expected_sha256 = ccj.sha256_hex(output_bytes)
420
+ previous_bytes = path.read_bytes() if path.exists() else None
421
+ try:
422
+ ccj.atomic_write_bytes(path, output_bytes)
423
+ published_bytes = path.read_bytes()
424
+ if published_bytes != output_bytes or ccj.sha256_hex(published_bytes) != expected_sha256:
425
+ raise RuntimeError("published repair candidate bytes differ from the validated repair snapshot")
426
+ validation = validate_repair(source_bytes, published_bytes, plan)
427
+ if not validation.get("ok"):
428
+ raise ValueError(f"published repair candidate validation failed: {validation.get('errors')}")
429
+ full_validation = ccj.validate_jsonl_bytes(published_bytes, source_label=path.name)
430
+ validation["fullTranscriptValidation"] = full_validation
431
+ if not full_validation.get("ok"):
432
+ raise ValueError(
433
+ "published repair candidate full transcript validation failed: "
434
+ f"{full_validation.get('errors')}"
435
+ )
436
+ return validation
437
+ except Exception:
438
+ if previous_bytes is None:
439
+ try:
440
+ path.unlink()
441
+ ccj.fsync_parent_directory(path)
442
+ except FileNotFoundError:
443
+ pass
444
+ else:
445
+ ccj.atomic_write_bytes(path, previous_bytes)
446
+ raise
447
+
448
+
449
+ def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
450
+ parser = argparse.ArgumentParser(
451
+ prog="claude-jsonl-repair-read-pages",
452
+ description="Scan or byte-preservingly remove historical Read.input.pages members from one Claude Code JSONL.",
453
+ )
454
+ parser.add_argument("--version", action="store_true", help="Print package, engine, and report versions")
455
+ parser.add_argument("--input", type=pathlib.Path, help="One source Claude Code session JSONL")
456
+ parser.add_argument("--output", type=pathlib.Path, help="Distinct repaired candidate path")
457
+ parser.add_argument("--scan-only", action="store_true", help="Report matches without writing files")
458
+ parser.add_argument("--replace-original", action="store_true", help="Transactionally replace one closed live .claude/projects session")
459
+ parser.add_argument(
460
+ "--confirm-session-closed",
461
+ action="store_true",
462
+ help="Required caller acknowledgement for --replace-original; this does not detect processes or locks.",
463
+ )
464
+ parser.add_argument("--work-dir", type=pathlib.Path, help="Required external candidate/report directory for --replace-original")
465
+ parser.add_argument("--backup-dir", type=pathlib.Path, help="Optional external numbered-backup directory for --replace-original")
466
+ parser.add_argument("--rule", choices=(RULE_NAME,), default=RULE_NAME, help=f"Repair rule; currently only {RULE_NAME}")
467
+ parser.add_argument("--scope", choices=("active-chain", "all"), default="active-chain", help="Strict active chain by default; all scans every physical branch")
468
+ parser.add_argument("--expect-matches", type=int, help="Require exactly N patchable matches before writing")
469
+ parser.add_argument("--resume-leaf", help="Explicit active-chain recovery leaf override")
470
+ return parser.parse_args(argv)
471
+
472
+
473
+ def main(argv: Optional[Sequence[str]] = None) -> int:
474
+ args = parse_args(argv)
475
+ try:
476
+ if args.version:
477
+ print(
478
+ json.dumps(
479
+ {
480
+ "packageVersion": ccj.PACKAGE_VERSION,
481
+ "engineVersion": ccj.CODEX_OFFLINE_COMPRESSION_VERSION,
482
+ "reportSchemaVersion": ccj.REPORT_SCHEMA_VERSION,
483
+ },
484
+ indent=2,
485
+ )
486
+ )
487
+ return 0
488
+ if not args.input:
489
+ raise ValueError("--input is required")
490
+ mode_count = int(args.scan_only) + int(bool(args.output)) + int(args.replace_original)
491
+ if mode_count != 1:
492
+ raise ValueError("choose exactly one mode: --scan-only, --output, or --replace-original")
493
+ if args.replace_original and not args.work_dir:
494
+ raise ValueError("--replace-original requires --work-dir")
495
+ if args.replace_original and not args.confirm_session_closed:
496
+ raise ValueError("--replace-original requires --confirm-session-closed before any live-session writes")
497
+ if args.replace_original:
498
+ if not ccj.is_under_claude_projects(args.input):
499
+ raise ValueError("--replace-original is only for one .claude/projects session JSONL")
500
+ ccj.require_live_session_jsonl(args.input)
501
+ if args.confirm_session_closed and not args.replace_original:
502
+ raise ValueError("--confirm-session-closed is only meaningful with --replace-original")
503
+ if args.backup_dir and not args.replace_original:
504
+ raise ValueError("--backup-dir requires --replace-original")
505
+ if args.expect_matches is not None and args.expect_matches < 0:
506
+ raise ValueError("--expect-matches must be non-negative")
507
+ if args.output and args.output.resolve() == args.input.resolve():
508
+ raise ValueError("--input and --output must be different files")
509
+ for label, process_path in (
510
+ ("--output", args.output),
511
+ ("--work-dir", args.work_dir),
512
+ ("--backup-dir", args.backup_dir),
513
+ ):
514
+ if process_path is not None and ccj.is_under_claude_root(process_path):
515
+ raise ValueError(f"{label} process files must be outside the entire .claude directory")
516
+ source_bytes = args.input.read_bytes()
517
+ plan = plan_read_pages_repairs(source_bytes, scope=args.scope, resume_leaf_override=args.resume_leaf)
518
+ if args.expect_matches is not None and plan["patchableMatchCount"] != args.expect_matches:
519
+ raise ValueError(
520
+ f"--expect-matches expected {args.expect_matches}, found {plan['patchableMatchCount']} patchable matches"
521
+ )
522
+ if args.scan_only:
523
+ print(json.dumps(public_plan(plan), ensure_ascii=False, indent=2))
524
+ return 0
525
+ output_bytes = apply_patch_plan(source_bytes, plan)
526
+ validation = validate_repair(source_bytes, output_bytes, plan)
527
+ if not validation.get("ok"):
528
+ raise ValueError(f"repair validation failed: {validation.get('errors')}")
529
+ full_validation = ccj.validate_jsonl_bytes(output_bytes, source_label=args.input.name)
530
+ validation["fullTranscriptValidation"] = full_validation
531
+ if not full_validation.get("ok"):
532
+ raise ValueError(
533
+ "repair candidate full transcript validation failed: "
534
+ f"{full_validation.get('errors')}"
535
+ )
536
+ replacing = False
537
+ if args.replace_original:
538
+ replacing = True
539
+ claude_root = ccj.claude_root_ancestor(args.input)
540
+ if claude_root and ccj.is_same_or_inside(args.work_dir, claude_root):
541
+ raise ValueError("--work-dir must be outside the .claude directory")
542
+ if claude_root and args.backup_dir and ccj.is_same_or_inside(args.backup_dir, claude_root):
543
+ raise ValueError("--backup-dir must be outside the .claude directory")
544
+ args.work_dir.mkdir(parents=True, exist_ok=True)
545
+ output_path = args.work_dir / f"{args.input.stem}.read-pages-repaired.jsonl"
546
+ else:
547
+ output_path = args.output
548
+ validation = publish_repair_candidate(output_path, source_bytes, output_bytes, plan)
549
+ report = {
550
+ "packageVersion": ccj.PACKAGE_VERSION,
551
+ "engineVersion": ccj.CODEX_OFFLINE_COMPRESSION_VERSION,
552
+ "reportSchemaVersion": ccj.REPORT_SCHEMA_VERSION,
553
+ "input": ccj.public_path_label(args.input),
554
+ "output": ccj.public_path_label(output_path),
555
+ "plan": public_plan(plan),
556
+ "validation": validation,
557
+ "replaceOriginal": replacing,
558
+ }
559
+ if replacing:
560
+ replacement = ccj._replace_file_after_validation(
561
+ output_path,
562
+ args.input,
563
+ backup_dir=args.backup_dir,
564
+ expected_source_sha256=plan["sourceSha256"],
565
+ expected_candidate_sha256=validation["outputSha256"],
566
+ )
567
+ backup = replacement["backup_path"]
568
+ report["replacementTarget"] = ccj.public_path_label(args.input)
569
+ report["replacementBackup"] = ccj.public_path_label(backup)
570
+ report["replacementValidation"] = replacement["validation"]
571
+ report["replacementCandidateSha256"] = replacement["candidate_sha256"]
572
+ report["replacementPublishedSha256"] = replacement["published_sha256"]
573
+ report["replacementParentDirectoryFsync"] = replacement["parent_directory_fsync"]
574
+ report_path = output_path.with_suffix(output_path.suffix + ".repair.json")
575
+ try:
576
+ ccj.atomic_write_text(report_path, json.dumps(report, ensure_ascii=False, indent=2) + "\n")
577
+ except Exception as report_exc:
578
+ if replacing:
579
+ receipt = {
580
+ "operationState": "committed-report-failed",
581
+ "replacementTarget": ccj.public_path_label(args.input),
582
+ "replacementBackup": report.get("replacementBackup"),
583
+ "replacementCandidate": ccj.public_path_label(output_path),
584
+ "sourceSha256": plan.get("sourceSha256"),
585
+ "candidateSha256": report.get("replacementCandidateSha256"),
586
+ "publishedSha256": report.get("replacementPublishedSha256"),
587
+ "replacementValidationOk": bool((report.get("replacementValidation") or {}).get("ok")),
588
+ "reportError": f"{type(report_exc).__name__}: {report_exc}",
589
+ }
590
+ print(json.dumps(receipt, ensure_ascii=False, indent=2))
591
+ ccj.eprint(
592
+ "ERROR: live repair committed, but final report publication failed: "
593
+ f"{report_exc}"
594
+ )
595
+ return 3
596
+ raise
597
+ print(json.dumps(report, ensure_ascii=False, indent=2))
598
+ return 0
599
+ except Exception as exc:
600
+ ccj.eprint(f"ERROR: {exc}")
601
+ return 1
602
+
603
+
604
+ if __name__ == "__main__":
605
+ raise SystemExit(main())
@@ -0,0 +1,78 @@
1
+ # Codex Offline Compression Summary
2
+
3
+ This is a candidate Claude Code JSONL compact summary generated outside Claude. It summarizes only older records from the selected active chain. Rewound, inactive, and unattributed records are excluded.
4
+
5
+ ## 1. Scope
6
+
7
+ - Source file label: {input_path}
8
+ - Original record count: {total_records}
9
+ - Active-chain records selected for summary: {omitted_record_count} records before the preserved active-chain window
10
+ - Recent raw active-chain records preserved: {recent_record_count} records
11
+ - Physical line window of preserved records: {recent_start} to {recent_end}
12
+ - Time span summarized: {first_ts} to {last_omitted_ts}
13
+ - Time span preserved: {first_kept_ts} to {last_kept_ts}
14
+ - Session distribution: {session_counts}
15
+ - Common working directories: {cwd_counts}
16
+ - Claude Code versions: {version_counts}
17
+
18
+ ## 2. Structural Overview
19
+
20
+ - Record types: {type_counts}
21
+ - System subtypes: {subtype_counts}
22
+ - Tool invocation overview: {tool_counts}
23
+ - Attachment / hook overview: {attachment_counts}
24
+ - File history snapshots: {file_history_count}
25
+ - Existing compact summaries summarized into this layer: {existing_compact_count}
26
+ - Human user prompts: {human_user_count}
27
+ - Tool-result user records: {tool_result_user_count}
28
+
29
+ ## 3. Long-Term Memory Ledger
30
+
31
+ {long_term_memory}
32
+
33
+ ## 4. Early / Middle Summary
34
+
35
+ ### 4.1 Early
36
+ {early_summary}
37
+
38
+ ### 4.2 Middle
39
+ {middle_summary}
40
+
41
+ ## 5. Assistant Behavior and Evidence
42
+
43
+ ### 5.1 Assistant research decisions and rationales
44
+ {assistant_decision_items}
45
+
46
+ ### 5.2 Key assistant outputs
47
+ {assistant_items}
48
+
49
+ ### 5.3 Key paths and filenames
50
+ {path_counts}
51
+
52
+ ### 5.4 Errors and anomalies
53
+ {error_section}
54
+
55
+ ## 6. Existing Compact Records
56
+
57
+ ### 6.1 compact_boundary layer
58
+ {compact_boundary_items}
59
+
60
+ ### 6.2 isCompactSummary layer
61
+ {compact_items}
62
+
63
+ ### 6.3 Repeated compression policy
64
+ - Treat previous compact layers as prior memory, not as live stacked context.
65
+ - Fold still-relevant facts into the current compact summary.
66
+ - Keep provenance, hashes, file labels, and line ranges in metadata or sidecars.
67
+ - If prior layers themselves are too large, switch to a long-term memory ledger.
68
+
69
+ ## 7. Recent Raw Preservation
70
+
71
+ {recent_preservation_notes}
72
+
73
+ ## 8. Important Reminders
74
+
75
+ - Exact wording lives in the source JSONL or external archives.
76
+ - This file is a candidate transcript rewrite, not a proof of Claude runtime behavior.
77
+ - For humanities, law, art, strategy, planning, history, feasibility, and document-research sessions, preserve user goals, reasons, rejected alternatives, version changes, provenance, unresolved questions, and risk judgments.
78
+ - Project-specific facts must come from the JSONL or an explicit handoff summary, not from hardcoded skill memory.