| 1 | #!/usr/bin/env python3 |
| 2 | """Wisdom.md size cap and retirement management. |
| 3 | |
| 4 | Checks wisdom.md file size against a soft limit and retires oldest |
| 5 | heuristics to an archive file when the limit is exceeded. |
| 6 | |
| 7 | CLI: |
| 8 | python scripts/wisdom_cap.py --topic ai-ml [--limit 5120] [--dry-run] |
| 9 | """ |
| 10 | |
| 11 | from __future__ import annotations |
| 12 | |
| 13 | import argparse |
| 14 | import sys |
| 15 | from datetime import datetime, timezone |
| 16 | from pathlib import Path |
| 17 | |
| 18 | SQUAD_DIR = Path(".squad/topics") |
| 19 | DEFAULT_LIMIT = 5120 # 5KB soft limit |
| 20 | |
| 21 | |
| 22 | def get_wisdom_path(topic: str) -> Path: |
| 23 | return SQUAD_DIR / topic / "wisdom.md" |
| 24 | |
| 25 | |
| 26 | def get_archive_path(topic: str) -> Path: |
| 27 | return SQUAD_DIR / topic / "wisdom-archive.md" |
| 28 | |
| 29 | |
| 30 | def parse_heuristics(content: str) -> list[dict]: |
| 31 | """Parse wisdom.md into sections with their heuristic bullet points. |
| 32 | |
| 33 | Returns a list of dicts with 'section', 'line', and 'text' keys. |
| 34 | """ |
| 35 | heuristics = [] |
| 36 | current_section = "" |
| 37 | for i, line in enumerate(content.splitlines()): |
| 38 | if line.startswith("## "): |
| 39 | current_section = line.strip("# ").strip() |
| 40 | elif line.startswith("- "): |
| 41 | heuristics.append( |
| 42 | { |
| 43 | "section": current_section, |
| 44 | "line": i, |
| 45 | "text": line, |
| 46 | } |
| 47 | ) |
| 48 | return heuristics |
| 49 | |
| 50 | |
| 51 | def select_for_retirement(heuristics: list[dict], bytes_to_free: int) -> list[dict]: |
| 52 | """Select heuristics from the end of the list (oldest/least-referenced first). |
| 53 | |
| 54 | Strategy: retire from the bottom of each section first, working backwards. |
| 55 | """ |
| 56 | # Retire from the end of the file upward until we've freed enough bytes |
| 57 | retired = [] |
| 58 | freed = 0 |
| 59 | for h in reversed(heuristics): |
| 60 | if freed >= bytes_to_free: |
| 61 | break |
| 62 | retired.append(h) |
| 63 | freed += len(h["text"].encode("utf-8")) + 1 # +1 for newline |
| 64 | return retired |
| 65 | |
| 66 | |
| 67 | def retire_heuristics( |
| 68 | wisdom_path: Path, archive_path: Path, limit: int, dry_run: bool = False |
| 69 | ) -> dict: |
| 70 | """Check wisdom size and retire heuristics if over limit. |
| 71 | |
| 72 | Returns a summary dict with action details. |
| 73 | """ |
| 74 | if not wisdom_path.exists(): |
| 75 | return {"status": "skip", "reason": "wisdom.md not found", "path": str(wisdom_path)} |
| 76 | |
| 77 | content = wisdom_path.read_text(encoding="utf-8") |
| 78 | current_size = len(content.encode("utf-8")) |
| 79 | |
| 80 | if current_size <= limit: |
| 81 | return { |
| 82 | "status": "ok", |
| 83 | "size": current_size, |
| 84 | "limit": limit, |
| 85 | "message": f"Under limit ({current_size}/{limit} bytes)", |
| 86 | } |
| 87 | |
| 88 | bytes_over = current_size - limit |
| 89 | heuristics = parse_heuristics(content) |
| 90 | |
| 91 | if not heuristics: |
| 92 | return { |
| 93 | "status": "warn", |
| 94 | "size": current_size, |
| 95 | "message": "Over limit but no parseable heuristics to retire", |
| 96 | } |
| 97 | |
| 98 | to_retire = select_for_retirement(heuristics, bytes_over) |
| 99 | |
| 100 | if dry_run: |
| 101 | return { |
| 102 | "status": "dry_run", |
| 103 | "size": current_size, |
| 104 | "limit": limit, |
| 105 | "would_retire": len(to_retire), |
| 106 | "items": [h["text"] for h in to_retire], |
| 107 | } |
| 108 | |
| 109 | # Build archive entry |
| 110 | timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") |
| 111 | archive_entry = ( |
| 112 | f"\n## Retired {timestamp}\n\nReason: wisdom.md exceeded {limit} byte soft limit\n\n" |
| 113 | ) |
| 114 | archive_entry += "\n".join(h["text"] for h in to_retire) + "\n" |
| 115 | |
| 116 | # Write archive |
| 117 | archive_path.parent.mkdir(parents=True, exist_ok=True) |
| 118 | if archive_path.exists(): |
| 119 | existing = archive_path.read_text(encoding="utf-8") |
| 120 | archive_path.write_text(existing + archive_entry, encoding="utf-8") |
| 121 | else: |
| 122 | header = "# Wisdom Archive\n\nRetired heuristics from wisdom.md.\n" |
| 123 | archive_path.write_text(header + archive_entry, encoding="utf-8") |
| 124 | |
| 125 | # Remove retired lines from wisdom content |
| 126 | lines = content.splitlines() |
| 127 | retired_lines = {h["line"] for h in to_retire} |
| 128 | new_lines = [ln for i, ln in enumerate(lines) if i not in retired_lines] |
| 129 | # Clean up any trailing empty lines in sections |
| 130 | new_content = "\n".join(new_lines).rstrip() + "\n" |
| 131 | wisdom_path.write_text(new_content, encoding="utf-8") |
| 132 | |
| 133 | new_size = len(new_content.encode("utf-8")) |
| 134 | return { |
| 135 | "status": "retired", |
| 136 | "original_size": current_size, |
| 137 | "new_size": new_size, |
| 138 | "retired_count": len(to_retire), |
| 139 | "archive_path": str(archive_path), |
| 140 | } |
| 141 | |
| 142 | |
| 143 | def main(argv: list[str] | None = None) -> int: |
| 144 | parser = argparse.ArgumentParser(description="Wisdom.md size cap management") |
| 145 | parser.add_argument("--topic", required=True, help="Topic ID (e.g., ai-ml)") |
| 146 | parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help="Size limit in bytes") |
| 147 | parser.add_argument("--dry-run", action="store_true", help="Show what would be retired") |
| 148 | args = parser.parse_args(argv) |
| 149 | |
| 150 | wisdom_path = get_wisdom_path(args.topic) |
| 151 | archive_path = get_archive_path(args.topic) |
| 152 | |
| 153 | result = retire_heuristics(wisdom_path, archive_path, args.limit, dry_run=args.dry_run) |
| 154 | |
| 155 | if result["status"] == "skip": |
| 156 | print(f"Skipped: {result['reason']}") |
| 157 | elif result["status"] == "ok": |
| 158 | print(result["message"]) |
| 159 | elif result["status"] == "dry_run": |
| 160 | print(f"DRY RUN: Would retire {result['would_retire']} heuristics") |
| 161 | for item in result.get("items", []): |
| 162 | print(f" {item}") |
| 163 | elif result["status"] == "retired": |
| 164 | print(f"Retired {result['retired_count']} heuristics") |
| 165 | print(f" Size: {result['original_size']} -> {result['new_size']} bytes") |
| 166 | print(f" Archive: {result['archive_path']}") |
| 167 | else: |
| 168 | print(f"Warning: {result.get('message', 'unknown status')}") |
| 169 | |
| 170 | return 0 |
| 171 | |
| 172 | |
| 173 | if __name__ == "__main__": |
| 174 | sys.exit(main()) |