@kulapard/pi-caveman 0.1.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.
@@ -0,0 +1,111 @@
1
+ ---
2
+ name: caveman-compress
3
+ description: >
4
+ Compress natural language memory files (AGENTS.md, CLAUDE.md, todos, preferences) into caveman
5
+ format to save input tokens. Preserves all technical substance, code, URLs, and structure.
6
+ Compressed version overwrites the original file. Human-readable backup saved as FILE.original.md.
7
+ Trigger: /caveman-compress FILEPATH or "compress memory file"
8
+ ---
9
+
10
+ # Caveman Compress
11
+
12
+ ## Purpose
13
+
14
+ Compress natural language files (`AGENTS.md`, `CLAUDE.md`, todos, preferences) into caveman-speak to reduce input tokens. Compressed version overwrites original. Human-readable backup saved as `<filename>.original.md`.
15
+
16
+ ## Trigger
17
+
18
+ `/caveman-compress <filepath>` or when user asks to compress a memory file.
19
+
20
+ ## Process
21
+
22
+ 1. The compression scripts live in `scripts/` (adjacent to this SKILL.md). If the path is not immediately available, search for `scripts/__main__.py` next to this SKILL.md.
23
+
24
+ 2. From the directory containing this SKILL.md, run:
25
+
26
+ python3 -m scripts <absolute_filepath>
27
+
28
+ 3. The CLI will:
29
+ - detect file type (no tokens)
30
+ - call a model to compress (Anthropic SDK if `ANTHROPIC_API_KEY` is set, else the `claude --print` CLI)
31
+ - validate output (no tokens)
32
+ - if errors: cherry-pick fix via the same model call (targeted fixes only, no recompression)
33
+ - retry up to 2 times
34
+ - if still failing after 2 retries: report error to user, leave original file untouched
35
+
36
+ 4. Return result to user
37
+
38
+ ## Compression Rules
39
+
40
+ ### Remove
41
+ - Articles: a, an, the
42
+ - Filler: just, really, basically, actually, simply, essentially, generally
43
+ - Pleasantries: "sure", "certainly", "of course", "happy to", "I'd recommend"
44
+ - Hedging: "it might be worth", "you could consider", "it would be good to"
45
+ - Redundant phrasing: "in order to" → "to", "make sure to" → "ensure", "the reason is because" → "because"
46
+ - Connective fluff: "however", "furthermore", "additionally", "in addition"
47
+
48
+ ### Preserve EXACTLY (never modify)
49
+ - Code blocks (fenced ``` and indented)
50
+ - Inline code (`backtick content`)
51
+ - URLs and links (full URLs, markdown links)
52
+ - File paths (`/src/components/...`, `./config.yaml`)
53
+ - Commands (`npm install`, `git commit`, `docker build`)
54
+ - Technical terms (library names, API names, protocols, algorithms)
55
+ - Proper nouns (project names, people, companies)
56
+ - Dates, version numbers, numeric values
57
+ - Environment variables (`$HOME`, `NODE_ENV`)
58
+
59
+ ### Preserve Structure
60
+ - All markdown headings (keep exact heading text, compress body below)
61
+ - Bullet point hierarchy (keep nesting level)
62
+ - Numbered lists (keep numbering)
63
+ - Tables (compress cell text, keep structure)
64
+ - Frontmatter/YAML headers in markdown files
65
+
66
+ ### Compress
67
+ - Use short synonyms: "big" not "extensive", "fix" not "implement a solution for", "use" not "utilize"
68
+ - Fragments OK: "Run tests before commit" not "You should always run tests before committing"
69
+ - Drop "you should", "make sure to", "remember to" — just state the action
70
+ - Merge redundant bullets that say the same thing differently
71
+ - Keep one example where multiple examples show the same pattern
72
+
73
+ CRITICAL RULE:
74
+ Anything inside ``` ... ``` must be copied EXACTLY.
75
+ Do not:
76
+ - remove comments
77
+ - remove spacing
78
+ - reorder lines
79
+ - shorten commands
80
+ - simplify anything
81
+
82
+ Inline code (`...`) must be preserved EXACTLY.
83
+ Do not modify anything inside backticks.
84
+
85
+ If file contains code blocks:
86
+ - Treat code blocks as read-only regions
87
+ - Only compress text outside them
88
+ - Do not merge sections around code
89
+
90
+ ## Pattern
91
+
92
+ Original:
93
+ > You should always make sure to run the test suite before pushing any changes to the main branch. This is important because it helps catch bugs early and prevents broken builds from being deployed to production.
94
+
95
+ Compressed:
96
+ > Run tests before push to main. Catch bugs early, prevent broken prod deploys.
97
+
98
+ Original:
99
+ > The application uses a microservices architecture with the following components. The API gateway handles all incoming requests and routes them to the appropriate service. The authentication service is responsible for managing user sessions and JWT tokens.
100
+
101
+ Compressed:
102
+ > Microservices architecture. API gateway route all requests to services. Auth service manage user sessions + JWT tokens.
103
+
104
+ ## Boundaries
105
+
106
+ - ONLY compress natural language files (.md, .txt, .typ, .typst, .tex, extensionless)
107
+ - NEVER modify: .py, .js, .ts, .json, .yaml, .yml, .toml, .env, .lock, .css, .html, .xml, .sql, .sh
108
+ - If file has mixed content (prose + code), compress ONLY the prose sections
109
+ - If unsure whether something is code or prose, leave it unchanged
110
+ - Original file is backed up as FILE.original.md before overwriting
111
+ - Never compress FILE.original.md (skip it)
@@ -0,0 +1,9 @@
1
+ """Caveman compress scripts.
2
+
3
+ This package provides tools to compress natural language markdown files
4
+ into caveman format to save input tokens.
5
+ """
6
+
7
+ __all__ = ["cli", "compress", "detect", "validate"]
8
+
9
+ __version__ = "1.0.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env python3
2
+ from pathlib import Path
3
+ import sys
4
+
5
+ # Support both direct execution and module import
6
+ try:
7
+ from .validate import validate
8
+ except ImportError:
9
+ sys.path.insert(0, str(Path(__file__).parent))
10
+ from validate import validate
11
+
12
+ try:
13
+ import tiktoken
14
+ _enc = tiktoken.get_encoding("o200k_base")
15
+ except ImportError:
16
+ _enc = None
17
+
18
+
19
+ def count_tokens(text):
20
+ if _enc is None:
21
+ return len(text.split()) # fallback: word count
22
+ return len(_enc.encode(text))
23
+
24
+
25
+ def benchmark_pair(orig_path: Path, comp_path: Path):
26
+ orig_text = orig_path.read_text()
27
+ comp_text = comp_path.read_text()
28
+
29
+ orig_tokens = count_tokens(orig_text)
30
+ comp_tokens = count_tokens(comp_text)
31
+ saved = 100 * (orig_tokens - comp_tokens) / orig_tokens if orig_tokens > 0 else 0.0
32
+ result = validate(orig_path, comp_path)
33
+
34
+ return (comp_path.name, orig_tokens, comp_tokens, saved, result.is_valid)
35
+
36
+
37
+ def print_table(rows):
38
+ print("\n| File | Original | Compressed | Saved % | Valid |")
39
+ print("|------|----------|------------|---------|-------|")
40
+ for r in rows:
41
+ print(f"| {r[0]} | {r[1]} | {r[2]} | {r[3]:.1f}% | {'✅' if r[4] else '❌'} |")
42
+
43
+
44
+ def main():
45
+ # Direct file pair: python3 benchmark.py original.md compressed.md
46
+ if len(sys.argv) == 3:
47
+ orig = Path(sys.argv[1]).resolve()
48
+ comp = Path(sys.argv[2]).resolve()
49
+ if not orig.exists():
50
+ print(f"❌ Not found: {orig}")
51
+ sys.exit(1)
52
+ if not comp.exists():
53
+ print(f"❌ Not found: {comp}")
54
+ sys.exit(1)
55
+ print_table([benchmark_pair(orig, comp)])
56
+ return
57
+
58
+ # Glob mode: repo_root/tests/caveman-compress/
59
+ # __file__ lives at <repo_root>/skills/caveman-compress/scripts/benchmark.py
60
+ # Walk up four dirs: scripts → caveman-compress → skills → repo_root.
61
+ tests_dir = Path(__file__).resolve().parents[3] / "tests" / "caveman-compress"
62
+ if not tests_dir.exists():
63
+ print(f"❌ Tests dir not found: {tests_dir}")
64
+ sys.exit(1)
65
+
66
+ rows = []
67
+ for orig in sorted(tests_dir.glob("*.original.md")):
68
+ comp = orig.with_name(orig.stem.removesuffix(".original") + ".md")
69
+ if comp.exists():
70
+ rows.append(benchmark_pair(orig, comp))
71
+
72
+ if not rows:
73
+ print("No compressed file pairs found.")
74
+ return
75
+
76
+ print_table(rows)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ main()
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Caveman Compress CLI
4
+
5
+ Usage:
6
+ caveman <filepath>
7
+ """
8
+
9
+ import sys
10
+
11
+ # Force UTF-8 on stdout/stderr before any code can print. Windows consoles
12
+ # default to cp1252 and crash on the ❌ glyphs in error/validation branches,
13
+ # masking the real error and leaving the user with a half-compressed file.
14
+ for _stream in (sys.stdout, sys.stderr):
15
+ reconfigure = getattr(_stream, "reconfigure", None)
16
+ if callable(reconfigure):
17
+ try:
18
+ reconfigure(encoding="utf-8", errors="replace")
19
+ except Exception:
20
+ pass
21
+
22
+ from pathlib import Path
23
+
24
+ from .compress import backup_dir_for, compress_file
25
+ from .detect import detect_file_type, should_compress
26
+
27
+
28
+ def print_usage():
29
+ print("Usage: caveman <filepath>")
30
+
31
+
32
+ def main():
33
+ if len(sys.argv) != 2:
34
+ print_usage()
35
+ sys.exit(1)
36
+
37
+ filepath = Path(sys.argv[1])
38
+
39
+ # Check file exists
40
+ if not filepath.exists():
41
+ print(f"❌ File not found: {filepath}")
42
+ sys.exit(1)
43
+
44
+ if not filepath.is_file():
45
+ print(f"❌ Not a file: {filepath}")
46
+ sys.exit(1)
47
+
48
+ filepath = filepath.resolve()
49
+
50
+ # Detect file type
51
+ file_type = detect_file_type(filepath)
52
+
53
+ print(f"Detected: {file_type}")
54
+
55
+ # Check if compressible
56
+ if not should_compress(filepath):
57
+ print("Skipping: file is not natural language (code/config)")
58
+ sys.exit(0)
59
+
60
+ print("Starting caveman compression...\n")
61
+
62
+ try:
63
+ success = compress_file(filepath)
64
+
65
+ if success:
66
+ print("\nCompression completed successfully")
67
+ backup_path = backup_dir_for(filepath) / (filepath.stem + ".original.md")
68
+ print(f"Compressed: {filepath}")
69
+ print(f"Original: {backup_path}")
70
+ sys.exit(0)
71
+ else:
72
+ print("\n❌ Compression failed after retries")
73
+ sys.exit(2)
74
+
75
+ except KeyboardInterrupt:
76
+ print("\nInterrupted by user")
77
+ sys.exit(130)
78
+
79
+ except Exception as e:
80
+ print(f"\n❌ Error: {e}")
81
+ sys.exit(1)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Caveman Memory Compression Orchestrator
4
+
5
+ Usage:
6
+ python scripts/compress.py <filepath>
7
+ """
8
+
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ OUTER_FENCE_REGEX = re.compile(
17
+ r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL
18
+ )
19
+
20
+ # YAML frontmatter: starts at file start with --- on its own line, ends with --- on its own line.
21
+ # Captures the entire block (including delimiters and trailing newline) and the body after.
22
+ FRONTMATTER_REGEX = re.compile(
23
+ r"\A(---\r?\n.*?\r?\n---\r?\n)(.*)", re.DOTALL
24
+ )
25
+
26
+
27
+ def split_frontmatter(text: str):
28
+ """Split YAML frontmatter from body. Returns (frontmatter, body).
29
+
30
+ Memory files (and many other markdown docs) start with a YAML frontmatter
31
+ block delimited by `---` lines. The compression LLM has a habit of stripping
32
+ or rewriting these despite preserve-structure rules in the prompt — so we
33
+ surgically remove the frontmatter before compression and prepend it back
34
+ verbatim to the output. Files without frontmatter pass through unchanged.
35
+ """
36
+ m = FRONTMATTER_REGEX.match(text)
37
+ if m:
38
+ return m.group(1), m.group(2)
39
+ return "", text
40
+
41
+ # Filenames and paths that almost certainly hold secrets or PII. Compressing
42
+ # them ships raw bytes to the Anthropic API — a third-party data boundary that
43
+ # developers on sensitive codebases cannot cross. detect.py already skips .env
44
+ # by extension, but credentials.md / secrets.txt / ~/.aws/credentials would
45
+ # slip through the natural-language filter. This is a hard refuse before read.
46
+ SENSITIVE_BASENAME_REGEX = re.compile(
47
+ r"(?ix)^("
48
+ r"\.env(\..+)?"
49
+ r"|\.netrc"
50
+ r"|credentials(\..+)?"
51
+ r"|secrets?(\..+)?"
52
+ r"|passwords?(\..+)?"
53
+ r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?"
54
+ r"|authorized_keys"
55
+ r"|known_hosts"
56
+ r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)"
57
+ r")$"
58
+ )
59
+
60
+ SENSITIVE_PATH_COMPONENTS = frozenset({".ssh", ".aws", ".gnupg", ".kube", ".docker"})
61
+
62
+ SENSITIVE_NAME_TOKENS = (
63
+ "secret", "credential", "password", "passwd",
64
+ "apikey", "accesskey", "token", "privatekey",
65
+ )
66
+
67
+
68
+ def backup_dir_for(filepath: Path) -> Path:
69
+ """Resolve the out-of-tree backup directory for a given source file.
70
+
71
+ Backups must live OUTSIDE the source directory so skill auto-loaders
72
+ (Claude Code rules/, opencode instructions/, etc.) stop re-ingesting the
73
+ `.original.md` copies as live files. Base dir is platform-aware:
74
+ - Windows: %LOCALAPPDATA%\\caveman-compress\\backups
75
+ - else: $XDG_DATA_HOME/caveman-compress/backups if set,
76
+ else ~/.local/share/caveman-compress/backups
77
+
78
+ The source file's parent-dir name is mirrored under the base to reduce
79
+ cross-project collisions (e.g. two `task.md` files in different repos).
80
+ """
81
+ if os.name == "nt" or sys.platform == "win32":
82
+ local_appdata = os.environ.get("LOCALAPPDATA")
83
+ base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local"
84
+ base = base / "caveman-compress" / "backups"
85
+ else:
86
+ xdg = os.environ.get("XDG_DATA_HOME")
87
+ base = Path(xdg) if xdg else Path.home() / ".local" / "share"
88
+ base = base / "caveman-compress" / "backups"
89
+ return base / filepath.parent.name
90
+
91
+
92
+ def is_sensitive_path(filepath: Path) -> bool:
93
+ """Heuristic denylist for files that must never be shipped to a third-party API."""
94
+ name = filepath.name
95
+ if SENSITIVE_BASENAME_REGEX.match(name):
96
+ return True
97
+ lowered_parts = {p.lower() for p in filepath.parts}
98
+ if lowered_parts & SENSITIVE_PATH_COMPONENTS:
99
+ return True
100
+ # Normalize separators so "api-key" and "api_key" both match "apikey".
101
+ lower = re.sub(r"[_\-\s.]", "", name.lower())
102
+ return any(tok in lower for tok in SENSITIVE_NAME_TOKENS)
103
+
104
+
105
+ def strip_llm_wrapper(text: str) -> str:
106
+ """Strip outer ```markdown ... ``` fence when it wraps the entire output."""
107
+ m = OUTER_FENCE_REGEX.match(text)
108
+ if m:
109
+ return m.group(2)
110
+ return text
111
+
112
+ from .detect import should_compress
113
+ from .validate import validate
114
+
115
+ MAX_RETRIES = 2
116
+
117
+
118
+ # ---------- Claude Calls ----------
119
+
120
+
121
+ def call_claude(prompt: str) -> str:
122
+ """Send a prompt to Claude.
123
+
124
+ Prefers the Anthropic SDK when ANTHROPIC_API_KEY is set; otherwise falls
125
+ back to the ``claude --print`` CLI (which handles desktop auth).
126
+
127
+ On Windows the CLI subprocess decoding defaults to the system codepage
128
+ (cp1251 / cp1252) and crashes on UTF-8 output — see issue #152. Pinning
129
+ ``encoding="utf-8"`` with ``errors="replace"`` matches the CLI's actual
130
+ native I/O and prevents the UnicodeDecodeError before validation can
131
+ report. Windows users with non-ASCII content can also set
132
+ ``ANTHROPIC_API_KEY`` to route through the SDK and skip the subprocess.
133
+ """
134
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
135
+ if api_key:
136
+ try:
137
+ import anthropic
138
+
139
+ client = anthropic.Anthropic(api_key=api_key)
140
+ msg = client.messages.create(
141
+ model=os.environ.get("CAVEMAN_MODEL", "claude-sonnet-4-5"),
142
+ max_tokens=8192,
143
+ messages=[{"role": "user", "content": prompt}],
144
+ )
145
+ return strip_llm_wrapper(msg.content[0].text.strip())
146
+ except ImportError:
147
+ pass # anthropic not installed, fall back to CLI
148
+ # Fallback: use claude CLI (handles desktop auth).
149
+ # Resolve binary via shutil.which so Windows .cmd/.bat shims (e.g.
150
+ # %APPDATA%\npm\claude.CMD) work without shell=True. On POSIX,
151
+ # shutil.which returns the same absolute path as the implicit lookup,
152
+ # so this is a no-op there. Falls back to bare "claude" if not found
153
+ # on PATH so subprocess raises a clear FileNotFoundError.
154
+ claude_bin = shutil.which("claude") or "claude"
155
+ try:
156
+ result = subprocess.run(
157
+ [claude_bin, "--print"],
158
+ input=prompt,
159
+ text=True,
160
+ capture_output=True,
161
+ check=True,
162
+ encoding="utf-8",
163
+ errors="replace",
164
+ )
165
+ return strip_llm_wrapper(result.stdout.strip())
166
+ except subprocess.CalledProcessError as e:
167
+ raise RuntimeError(f"Claude call failed:\n{e.stderr}")
168
+
169
+
170
+ def build_compress_prompt(original: str) -> str:
171
+ return f"""
172
+ Compress this markdown into caveman format.
173
+
174
+ STRICT RULES:
175
+ - Do NOT modify anything inside ``` code blocks
176
+ - Do NOT modify anything inside inline backticks
177
+ - Preserve ALL URLs exactly
178
+ - Preserve ALL headings exactly
179
+ - Preserve file paths and commands
180
+ - Return ONLY the compressed markdown body — do NOT wrap the entire output in a ```markdown fence or any other fence. Inner code blocks from the original stay as-is; do not add a new outer fence around the whole file.
181
+
182
+ Only compress natural language.
183
+
184
+ TEXT:
185
+ {original}
186
+ """
187
+
188
+
189
+ def build_fix_prompt(original: str, compressed: str, errors: list[str]) -> str:
190
+ errors_str = "\n".join(f"- {e}" for e in errors)
191
+ return f"""You are fixing a caveman-compressed markdown file. Specific validation errors were found.
192
+
193
+ CRITICAL RULES:
194
+ - DO NOT recompress or rephrase the file
195
+ - ONLY fix the listed errors — leave everything else exactly as-is
196
+ - The ORIGINAL is provided as reference only (to restore missing content)
197
+ - Preserve caveman style in all untouched sections
198
+
199
+ ERRORS TO FIX:
200
+ {errors_str}
201
+
202
+ HOW TO FIX:
203
+ - Missing URL: find it in ORIGINAL, restore it exactly where it belongs in COMPRESSED
204
+ - Code block mismatch: find the exact code block in ORIGINAL, restore it in COMPRESSED
205
+ - Heading mismatch: restore the exact heading text from ORIGINAL into COMPRESSED
206
+ - Do not touch any section not mentioned in the errors
207
+
208
+ ORIGINAL (reference only):
209
+ {original}
210
+
211
+ COMPRESSED (fix this):
212
+ {compressed}
213
+
214
+ Return ONLY the fixed compressed file. No explanation.
215
+ """
216
+
217
+
218
+ # ---------- Core Logic ----------
219
+
220
+
221
+ def compress_file(filepath: Path) -> bool:
222
+ # Resolve and validate path
223
+ filepath = filepath.resolve()
224
+ MAX_FILE_SIZE = 500_000 # 500KB
225
+ if not filepath.exists():
226
+ raise FileNotFoundError(f"File not found: {filepath}")
227
+ if filepath.stat().st_size > MAX_FILE_SIZE:
228
+ raise ValueError(f"File too large to compress safely (max 500KB): {filepath}")
229
+
230
+ # Refuse files that look like they contain secrets or PII. Compressing ships
231
+ # the raw bytes to the Anthropic API — a third-party boundary — so we fail
232
+ # loudly rather than silently exfiltrate credentials or keys. Override is
233
+ # intentional: the user must rename the file if the heuristic is wrong.
234
+ if is_sensitive_path(filepath):
235
+ raise ValueError(
236
+ f"Refusing to compress {filepath}: filename looks sensitive "
237
+ "(credentials, keys, secrets, or known private paths). "
238
+ "Compression sends file contents to the Anthropic API. "
239
+ "Rename the file if this is a false positive."
240
+ )
241
+
242
+ print(f"Processing: {filepath}")
243
+
244
+ if not should_compress(filepath):
245
+ print("Skipping (not natural language)")
246
+ return False
247
+
248
+ original_text = filepath.read_text(errors="ignore")
249
+ # Store backup outside the source directory so skill auto-loaders don't
250
+ # re-ingest the `.original.md` copy as a live file. Mirror the source's
251
+ # parent-dir name + stem under a platform-aware base to reduce collisions.
252
+ backup_dir = backup_dir_for(filepath)
253
+ backup_dir.mkdir(parents=True, exist_ok=True)
254
+ backup_path = backup_dir / (filepath.stem + ".original.md")
255
+
256
+ if not original_text.strip():
257
+ print("❌ Refusing to compress: file is empty or whitespace-only.")
258
+ return False
259
+
260
+ # Check if backup already exists to prevent accidental overwriting
261
+ if backup_path.exists():
262
+ print(f"⚠️ Backup file already exists: {backup_path}")
263
+ print("The original backup may contain important content.")
264
+ print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.")
265
+ return False
266
+
267
+ # Split YAML frontmatter off before compression. Claude tends to strip or
268
+ # rewrite frontmatter despite preserve-structure rules; we keep it verbatim
269
+ # by removing it from the input and re-prepending it to the output.
270
+ frontmatter, body = split_frontmatter(original_text)
271
+ if frontmatter:
272
+ print(f"Detected YAML frontmatter ({len(frontmatter)} chars) — preserving verbatim")
273
+
274
+ if not body.strip():
275
+ print("❌ Refusing to compress: body is empty after frontmatter removal.")
276
+ return False
277
+
278
+ # Step 1: Compress (body only, frontmatter excluded)
279
+ print("Compressing with Claude...")
280
+ compressed_body = call_claude(build_compress_prompt(body))
281
+
282
+ if compressed_body is None or not compressed_body.strip():
283
+ print("❌ Compression aborted: Claude returned an empty response.")
284
+ print(" Original file is untouched (no backup created).")
285
+ return False
286
+
287
+ # Compare the BODY (not the whole file) — frontmatter is preserved verbatim
288
+ # and would never change, so identity must be judged on the compressible part.
289
+ if compressed_body.strip() == body.strip():
290
+ print("❌ Compression aborted: output is identical to input.")
291
+ print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is")
292
+ print(" already in caveman form. Original file is untouched (no backup created).")
293
+ return False
294
+
295
+ # Reassemble: frontmatter (verbatim) + compressed body
296
+ compressed = frontmatter + compressed_body
297
+
298
+ # Save original as backup, then verify the backup readback before
299
+ # touching the input file. If the filesystem dropped bytes (encoding,
300
+ # antivirus, disk full), unlink the bad backup and abort instead of
301
+ # leaving the user with a corrupt backup + compressed primary.
302
+ backup_path.write_text(original_text)
303
+ backup_readback = backup_path.read_text(errors="ignore")
304
+ if backup_readback != original_text:
305
+ print(f"❌ Backup write verification failed: {backup_path}")
306
+ print(" In-memory original differs from on-disk backup. Aborting before touching the input file.")
307
+ try:
308
+ backup_path.unlink()
309
+ except OSError:
310
+ pass
311
+ return False
312
+ filepath.write_text(compressed)
313
+
314
+ # Step 2: Validate + Retry
315
+ for attempt in range(MAX_RETRIES):
316
+ print(f"\nValidation attempt {attempt + 1}")
317
+
318
+ result = validate(backup_path, filepath)
319
+
320
+ if result.is_valid:
321
+ print("Validation passed")
322
+ break
323
+
324
+ print("❌ Validation failed:")
325
+ for err in result.errors:
326
+ print(f" - {err}")
327
+
328
+ if attempt == MAX_RETRIES - 1:
329
+ # Restore original on failure
330
+ filepath.write_text(original_text)
331
+ backup_path.unlink(missing_ok=True)
332
+ print("❌ Failed after retries — original restored")
333
+ return False
334
+
335
+ print("Fixing with Claude...")
336
+ compressed = call_claude(
337
+ build_fix_prompt(original_text, compressed, result.errors)
338
+ )
339
+ filepath.write_text(compressed)
340
+
341
+ return True