main
py 174 lines 5.66 KB
Raw
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())