@softspark/ai-toolkit 1.6.0 → 1.7.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/CHANGELOG.md +28 -0
- package/README.md +20 -7
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +3 -0
- package/bin/ai-toolkit.js +3 -1
- package/kb/{planning/offline-slm-profile-plan.md → history/completed/offline-slm-profile-plan-20260411.md} +7 -5
- package/kb/reference/architecture-overview.md +3 -2
- package/kb/reference/skills-catalog.md +15 -0
- package/llms-full.txt +2792 -2774
- package/llms.txt +1 -1
- package/manifest.json +5 -4
- package/package.json +1 -1
- package/scripts/add_rule.py +5 -4
- package/scripts/compile_slm.py +1043 -0
- package/scripts/generate_aider_conf.py +2 -1
- package/scripts/generate_augment_rules.py +4 -4
- package/scripts/generate_cline_rules.py +11 -6
- package/scripts/install_steps/ai_tools.py +6 -7
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Compile ai-toolkit into a minimal system prompt for Small Language Models.
|
|
3
|
+
|
|
4
|
+
Reads all toolkit components (constitution, agents, skills, rules, personas),
|
|
5
|
+
scores them by safety criticality and relevance, compresses to fit a token
|
|
6
|
+
budget, and emits a single compiled markdown file.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 scripts/compile_slm.py [options]
|
|
10
|
+
ai-toolkit compile-slm [options]
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--budget N Token budget (default: auto from model size)
|
|
14
|
+
--model-size SIZE Model size: 7b, 8b, 14b, 32b, 70b (default: auto-detect)
|
|
15
|
+
--persona NAME Persona preset to prioritize (e.g., backend-lead)
|
|
16
|
+
--lang LANGS Comma-separated languages to include (e.g., python,typescript)
|
|
17
|
+
--output PATH Output file path (default: ~/.ai-toolkit/compiled/slm-system-prompt.md)
|
|
18
|
+
--format FORMAT Output format: raw, ollama, json-string, aider (default: raw)
|
|
19
|
+
--dry-run Show what would be included without writing output
|
|
20
|
+
--level LEVEL Compression level: ultra-light, light, standard, extended (default: auto)
|
|
21
|
+
|
|
22
|
+
Exit codes:
|
|
23
|
+
0 compilation succeeded
|
|
24
|
+
1 compilation failed (budget exceeded, constitution too large, etc.)
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
import urllib.request
|
|
33
|
+
import urllib.error
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
38
|
+
from _common import (
|
|
39
|
+
toolkit_dir,
|
|
40
|
+
app_dir,
|
|
41
|
+
agents_dir,
|
|
42
|
+
skills_dir,
|
|
43
|
+
frontmatter_field,
|
|
44
|
+
frontmatter_block,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Constants
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
MODEL_BUDGETS: dict[str, dict[str, object]] = {
|
|
52
|
+
"7b": {"budget": 2048, "level": "ultra-light"},
|
|
53
|
+
"8b": {"budget": 2048, "level": "ultra-light"},
|
|
54
|
+
"14b": {"budget": 4096, "level": "light"},
|
|
55
|
+
"32b": {"budget": 8192, "level": "standard"},
|
|
56
|
+
"70b": {"budget": 16384, "level": "extended"},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
DEFAULT_MODEL_SIZE = "14b"
|
|
60
|
+
BUDGET_SAFETY_MARGIN = 0.95
|
|
61
|
+
|
|
62
|
+
COMPRESSION_LEVELS: dict[str, dict[str, object]] = {
|
|
63
|
+
"ultra-light": {
|
|
64
|
+
"strip_examples": True,
|
|
65
|
+
"strip_rationalizations": True,
|
|
66
|
+
"strip_related_skills": True,
|
|
67
|
+
"strip_verification": True,
|
|
68
|
+
"strip_agent_commands": True,
|
|
69
|
+
"strip_multi_agent": True,
|
|
70
|
+
"max_skills": 5,
|
|
71
|
+
"max_agents": 0,
|
|
72
|
+
"include_rules": False,
|
|
73
|
+
},
|
|
74
|
+
"light": {
|
|
75
|
+
"strip_examples": True,
|
|
76
|
+
"strip_rationalizations": True,
|
|
77
|
+
"strip_related_skills": True,
|
|
78
|
+
"strip_verification": "summary",
|
|
79
|
+
"strip_agent_commands": True,
|
|
80
|
+
"strip_multi_agent": True,
|
|
81
|
+
"max_skills": 10,
|
|
82
|
+
"max_agents": 1,
|
|
83
|
+
"include_rules": True,
|
|
84
|
+
},
|
|
85
|
+
"standard": {
|
|
86
|
+
"strip_examples": "first-only",
|
|
87
|
+
"strip_rationalizations": True,
|
|
88
|
+
"strip_related_skills": True,
|
|
89
|
+
"strip_verification": "summary",
|
|
90
|
+
"strip_agent_commands": True,
|
|
91
|
+
"strip_multi_agent": True,
|
|
92
|
+
"max_skills": 20,
|
|
93
|
+
"max_agents": 3,
|
|
94
|
+
"include_rules": True,
|
|
95
|
+
},
|
|
96
|
+
"extended": {
|
|
97
|
+
"strip_examples": "first-only",
|
|
98
|
+
"strip_rationalizations": "first-only",
|
|
99
|
+
"strip_related_skills": False,
|
|
100
|
+
"strip_verification": False,
|
|
101
|
+
"strip_agent_commands": False,
|
|
102
|
+
"strip_multi_agent": True,
|
|
103
|
+
"max_skills": 40,
|
|
104
|
+
"max_agents": 5,
|
|
105
|
+
"include_rules": True,
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# Scoring weights
|
|
110
|
+
W_SAFETY = 0.40
|
|
111
|
+
W_USAGE = 0.25
|
|
112
|
+
W_PERSONA = 0.20
|
|
113
|
+
W_LANGUAGE = 0.15
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# 1.1 — Token Counter
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def estimate_tokens(text: str) -> int:
|
|
121
|
+
"""Estimate token count without external dependencies.
|
|
122
|
+
|
|
123
|
+
Uses two heuristics and returns the higher (conservative) estimate:
|
|
124
|
+
1. Word-based: ~0.75 tokens/word for English prose
|
|
125
|
+
2. Char-based: ~1 token per 4 chars (more accurate for code-heavy content)
|
|
126
|
+
|
|
127
|
+
Adds a penalty for code blocks which have higher token density.
|
|
128
|
+
Accuracy target: +/-10% vs tiktoken cl100k_base.
|
|
129
|
+
"""
|
|
130
|
+
if not text:
|
|
131
|
+
return 0
|
|
132
|
+
word_est = int(len(text.split()) * 0.75)
|
|
133
|
+
char_est = len(text) // 4
|
|
134
|
+
code_blocks = text.count("```")
|
|
135
|
+
code_penalty = code_blocks * 15
|
|
136
|
+
return max(word_est, char_est) + code_penalty
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# 1.2 — Component Parser + Scorer
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class Component:
|
|
145
|
+
"""A single toolkit component scored for SLM compilation."""
|
|
146
|
+
|
|
147
|
+
name: str
|
|
148
|
+
type: str # constitution, agent, skill, rule, persona, hook-rule
|
|
149
|
+
source_file: str
|
|
150
|
+
full_text: str
|
|
151
|
+
compressed_text: str = ""
|
|
152
|
+
tokens_full: int = 0
|
|
153
|
+
tokens_compressed: int = 0
|
|
154
|
+
score: float = 0.0
|
|
155
|
+
|
|
156
|
+
# Scoring factors
|
|
157
|
+
safety_criticality: float = 0.0
|
|
158
|
+
usage_frequency: float = 0.0
|
|
159
|
+
persona_relevance: float = 0.0
|
|
160
|
+
language_relevance: float = 0.0
|
|
161
|
+
|
|
162
|
+
def compute_score(self) -> None:
|
|
163
|
+
"""Compute composite score from weighted factors."""
|
|
164
|
+
self.score = (
|
|
165
|
+
self.safety_criticality * W_SAFETY
|
|
166
|
+
+ self.usage_frequency * W_USAGE
|
|
167
|
+
+ self.persona_relevance * W_PERSONA
|
|
168
|
+
+ self.language_relevance * W_LANGUAGE
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _load_usage_stats() -> dict[str, int]:
|
|
173
|
+
"""Load skill invocation counts from stats.json."""
|
|
174
|
+
stats_path = Path.home() / ".ai-toolkit" / "stats.json"
|
|
175
|
+
if not stats_path.is_file():
|
|
176
|
+
return {}
|
|
177
|
+
try:
|
|
178
|
+
data = json.loads(stats_path.read_text(encoding="utf-8"))
|
|
179
|
+
counts: dict[str, int] = {}
|
|
180
|
+
for run in data.get("loop_runs", []):
|
|
181
|
+
cmd = run.get("command", "").lstrip("/")
|
|
182
|
+
if cmd:
|
|
183
|
+
counts[cmd] = counts.get(cmd, 0) + 1
|
|
184
|
+
return counts
|
|
185
|
+
except (json.JSONDecodeError, OSError):
|
|
186
|
+
return {}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _normalize_usage(counts: dict[str, int]) -> dict[str, float]:
|
|
190
|
+
"""Normalize usage counts to 0.0-1.0 range."""
|
|
191
|
+
if not counts:
|
|
192
|
+
return {}
|
|
193
|
+
max_count = max(counts.values())
|
|
194
|
+
if max_count == 0:
|
|
195
|
+
return {}
|
|
196
|
+
return {k: v / max_count for k, v in counts.items()}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _read_file_text(path: Path) -> str:
|
|
200
|
+
"""Read file content, return empty string on failure."""
|
|
201
|
+
try:
|
|
202
|
+
return path.read_text(encoding="utf-8")
|
|
203
|
+
except OSError:
|
|
204
|
+
return ""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _strip_frontmatter(text: str) -> str:
|
|
208
|
+
"""Remove YAML frontmatter (--- delimited) from text."""
|
|
209
|
+
if not text.startswith("---"):
|
|
210
|
+
return text
|
|
211
|
+
end = text.find("---", 3)
|
|
212
|
+
if end == -1:
|
|
213
|
+
return text
|
|
214
|
+
return text[end + 3:].lstrip("\n")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _parse_persona_skills(persona_path: Path) -> list[str]:
|
|
218
|
+
"""Extract preferred skill names from a persona file."""
|
|
219
|
+
text = _read_file_text(persona_path)
|
|
220
|
+
skills: list[str] = []
|
|
221
|
+
for match in re.finditer(r"`/(\w[\w-]*)`", text):
|
|
222
|
+
skill_name = match.group(1)
|
|
223
|
+
# Strip workflow type suffixes
|
|
224
|
+
if " " not in skill_name:
|
|
225
|
+
skills.append(skill_name)
|
|
226
|
+
return skills
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def parse_components(
|
|
230
|
+
persona: str = "",
|
|
231
|
+
languages: list[str] | None = None,
|
|
232
|
+
) -> list[Component]:
|
|
233
|
+
"""Parse all toolkit components into a scored list."""
|
|
234
|
+
components: list[Component] = []
|
|
235
|
+
usage_stats = _normalize_usage(_load_usage_stats())
|
|
236
|
+
languages = languages or []
|
|
237
|
+
|
|
238
|
+
# Persona skills for boosting
|
|
239
|
+
persona_skills: list[str] = []
|
|
240
|
+
persona_path: Path | None = None
|
|
241
|
+
if persona:
|
|
242
|
+
persona_path = app_dir / "personas" / f"{persona}.md"
|
|
243
|
+
if persona_path.is_file():
|
|
244
|
+
persona_skills = _parse_persona_skills(persona_path)
|
|
245
|
+
|
|
246
|
+
# --- Constitution (always score=1.0) ---
|
|
247
|
+
constitution_path = app_dir / "constitution.md"
|
|
248
|
+
if constitution_path.is_file():
|
|
249
|
+
text = _read_file_text(constitution_path)
|
|
250
|
+
body = _strip_frontmatter(text)
|
|
251
|
+
components.append(Component(
|
|
252
|
+
name="Constitution",
|
|
253
|
+
type="constitution",
|
|
254
|
+
source_file=str(constitution_path),
|
|
255
|
+
full_text=body,
|
|
256
|
+
tokens_full=estimate_tokens(body),
|
|
257
|
+
safety_criticality=1.0,
|
|
258
|
+
usage_frequency=1.0,
|
|
259
|
+
persona_relevance=1.0,
|
|
260
|
+
language_relevance=1.0,
|
|
261
|
+
))
|
|
262
|
+
components[-1].compute_score()
|
|
263
|
+
|
|
264
|
+
# --- Guard hooks as text rules (score=0.95) ---
|
|
265
|
+
for hook_name in ("guard-destructive", "guard-path"):
|
|
266
|
+
hook_path = app_dir / "hooks" / f"{hook_name}.sh"
|
|
267
|
+
if hook_path.is_file():
|
|
268
|
+
text = _read_file_text(hook_path)
|
|
269
|
+
# Extract the blocked patterns as a rule summary
|
|
270
|
+
rule_text = _extract_guard_rule(hook_name, text)
|
|
271
|
+
components.append(Component(
|
|
272
|
+
name=f"Guard: {hook_name}",
|
|
273
|
+
type="hook-rule",
|
|
274
|
+
source_file=str(hook_path),
|
|
275
|
+
full_text=rule_text,
|
|
276
|
+
tokens_full=estimate_tokens(rule_text),
|
|
277
|
+
safety_criticality=0.95,
|
|
278
|
+
usage_frequency=0.8,
|
|
279
|
+
persona_relevance=0.5,
|
|
280
|
+
language_relevance=0.5,
|
|
281
|
+
))
|
|
282
|
+
components[-1].compute_score()
|
|
283
|
+
|
|
284
|
+
# --- Persona definition (score=0.90) ---
|
|
285
|
+
if persona_path and persona_path.is_file():
|
|
286
|
+
text = _read_file_text(persona_path)
|
|
287
|
+
body = _strip_frontmatter(text)
|
|
288
|
+
components.append(Component(
|
|
289
|
+
name=f"Persona: {persona}",
|
|
290
|
+
type="persona",
|
|
291
|
+
source_file=str(persona_path),
|
|
292
|
+
full_text=body,
|
|
293
|
+
tokens_full=estimate_tokens(body),
|
|
294
|
+
safety_criticality=0.0,
|
|
295
|
+
usage_frequency=0.8,
|
|
296
|
+
persona_relevance=1.0,
|
|
297
|
+
language_relevance=0.5,
|
|
298
|
+
))
|
|
299
|
+
components[-1].compute_score()
|
|
300
|
+
|
|
301
|
+
# --- Language rules (score=0.85 for matching, 0.1 for non-matching) ---
|
|
302
|
+
rules_dir = app_dir / "rules"
|
|
303
|
+
# Always include common rules
|
|
304
|
+
common_rules_dir = rules_dir / "common"
|
|
305
|
+
if common_rules_dir.is_dir():
|
|
306
|
+
for rule_file in sorted(common_rules_dir.glob("*.md")):
|
|
307
|
+
text = _read_file_text(rule_file)
|
|
308
|
+
body = _strip_frontmatter(text)
|
|
309
|
+
components.append(Component(
|
|
310
|
+
name=f"Rule: common/{rule_file.stem}",
|
|
311
|
+
type="rule",
|
|
312
|
+
source_file=str(rule_file),
|
|
313
|
+
full_text=body,
|
|
314
|
+
tokens_full=estimate_tokens(body),
|
|
315
|
+
safety_criticality=0.5,
|
|
316
|
+
usage_frequency=0.6,
|
|
317
|
+
persona_relevance=0.5,
|
|
318
|
+
language_relevance=0.85,
|
|
319
|
+
))
|
|
320
|
+
components[-1].compute_score()
|
|
321
|
+
|
|
322
|
+
# Language-specific rules
|
|
323
|
+
if languages:
|
|
324
|
+
for lang in languages:
|
|
325
|
+
lang_dir = rules_dir / lang
|
|
326
|
+
if not lang_dir.is_dir():
|
|
327
|
+
continue
|
|
328
|
+
for rule_file in sorted(lang_dir.glob("*.md")):
|
|
329
|
+
text = _read_file_text(rule_file)
|
|
330
|
+
body = _strip_frontmatter(text)
|
|
331
|
+
components.append(Component(
|
|
332
|
+
name=f"Rule: {lang}/{rule_file.stem}",
|
|
333
|
+
type="rule",
|
|
334
|
+
source_file=str(rule_file),
|
|
335
|
+
full_text=body,
|
|
336
|
+
tokens_full=estimate_tokens(body),
|
|
337
|
+
safety_criticality=0.3,
|
|
338
|
+
usage_frequency=0.5,
|
|
339
|
+
persona_relevance=0.4,
|
|
340
|
+
language_relevance=1.0,
|
|
341
|
+
))
|
|
342
|
+
components[-1].compute_score()
|
|
343
|
+
|
|
344
|
+
# --- Skills ---
|
|
345
|
+
if skills_dir.is_dir():
|
|
346
|
+
for skill_dir in sorted(skills_dir.iterdir()):
|
|
347
|
+
skill_file = skill_dir / "SKILL.md"
|
|
348
|
+
if not skill_file.is_file():
|
|
349
|
+
continue
|
|
350
|
+
skill_name = frontmatter_field(skill_file, "name")
|
|
351
|
+
if not skill_name:
|
|
352
|
+
skill_name = skill_dir.name
|
|
353
|
+
description = frontmatter_field(skill_file, "description")
|
|
354
|
+
user_invocable = frontmatter_field(skill_file, "user-invocable")
|
|
355
|
+
|
|
356
|
+
# Skip non-user-invocable knowledge skills for SLM
|
|
357
|
+
if user_invocable == "false":
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
# Build a compact skill summary (name + description)
|
|
361
|
+
text = _read_file_text(skill_file)
|
|
362
|
+
body = _strip_frontmatter(text)
|
|
363
|
+
|
|
364
|
+
# Persona relevance boost
|
|
365
|
+
p_relevance = 0.7 if skill_name in persona_skills else 0.3
|
|
366
|
+
|
|
367
|
+
# Usage frequency from stats
|
|
368
|
+
u_freq = usage_stats.get(skill_name, 0.2)
|
|
369
|
+
|
|
370
|
+
components.append(Component(
|
|
371
|
+
name=f"Skill: /{skill_name}",
|
|
372
|
+
type="skill",
|
|
373
|
+
source_file=str(skill_file),
|
|
374
|
+
full_text=body,
|
|
375
|
+
tokens_full=estimate_tokens(body),
|
|
376
|
+
safety_criticality=0.1,
|
|
377
|
+
usage_frequency=u_freq,
|
|
378
|
+
persona_relevance=p_relevance,
|
|
379
|
+
language_relevance=0.5,
|
|
380
|
+
))
|
|
381
|
+
components[-1].compute_score()
|
|
382
|
+
|
|
383
|
+
# --- Agents ---
|
|
384
|
+
if agents_dir.is_dir():
|
|
385
|
+
for agent_file in sorted(agents_dir.glob("*.md")):
|
|
386
|
+
agent_name = frontmatter_field(agent_file, "name")
|
|
387
|
+
if not agent_name:
|
|
388
|
+
agent_name = agent_file.stem
|
|
389
|
+
description = frontmatter_field(agent_file, "description")
|
|
390
|
+
text = _read_file_text(agent_file)
|
|
391
|
+
body = _strip_frontmatter(text)
|
|
392
|
+
|
|
393
|
+
# Persona relevance: match if agent skills overlap persona skills
|
|
394
|
+
agent_skills_str = frontmatter_field(agent_file, "skills")
|
|
395
|
+
agent_skill_list = [s.strip() for s in agent_skills_str.split(",") if s.strip()]
|
|
396
|
+
overlap = len(set(agent_skill_list) & set(persona_skills))
|
|
397
|
+
p_relevance = min(0.3 + overlap * 0.2, 1.0)
|
|
398
|
+
|
|
399
|
+
components.append(Component(
|
|
400
|
+
name=f"Agent: {agent_name}",
|
|
401
|
+
type="agent",
|
|
402
|
+
source_file=str(agent_file),
|
|
403
|
+
full_text=body,
|
|
404
|
+
tokens_full=estimate_tokens(body),
|
|
405
|
+
safety_criticality=0.1,
|
|
406
|
+
usage_frequency=0.3,
|
|
407
|
+
persona_relevance=p_relevance,
|
|
408
|
+
language_relevance=0.3,
|
|
409
|
+
))
|
|
410
|
+
components[-1].compute_score()
|
|
411
|
+
|
|
412
|
+
return components
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _extract_guard_rule(hook_name: str, script_text: str) -> str:
|
|
416
|
+
"""Convert a guard hook script into a text rule for SLM system prompt."""
|
|
417
|
+
if hook_name == "guard-destructive":
|
|
418
|
+
# Extract blocked patterns from the script
|
|
419
|
+
patterns: list[str] = []
|
|
420
|
+
for match in re.finditer(r'"([^"]+)"', script_text):
|
|
421
|
+
candidate = match.group(1)
|
|
422
|
+
if any(kw in candidate for kw in ("rm ", "drop ", "delete ", "format", "mkfs", "dd ")):
|
|
423
|
+
patterns.append(candidate)
|
|
424
|
+
if not patterns:
|
|
425
|
+
patterns = ["rm -rf", "DROP TABLE", "FORMAT", "mkfs", "dd if="]
|
|
426
|
+
return (
|
|
427
|
+
"## Destructive Command Guard\n"
|
|
428
|
+
"NEVER execute these commands without explicit user confirmation:\n"
|
|
429
|
+
+ "\n".join(f"- `{p}`" for p in patterns)
|
|
430
|
+
)
|
|
431
|
+
if hook_name == "guard-path":
|
|
432
|
+
return (
|
|
433
|
+
"## Path Guard\n"
|
|
434
|
+
"Only read and write files within the current project directory.\n"
|
|
435
|
+
"Never access files outside the working directory without explicit user permission."
|
|
436
|
+
)
|
|
437
|
+
return ""
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
# 1.3 — Compression Engine
|
|
442
|
+
# ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
def compress_component(component: Component, level_config: dict[str, object]) -> None:
|
|
445
|
+
"""Compress a component's text based on compression level settings."""
|
|
446
|
+
text = component.full_text
|
|
447
|
+
|
|
448
|
+
# Constitution and hook-rules are never compressed
|
|
449
|
+
if component.type in ("constitution", "hook-rule"):
|
|
450
|
+
component.compressed_text = text
|
|
451
|
+
component.tokens_compressed = estimate_tokens(text)
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
# Strip frontmatter (already done in parsing, but safety check)
|
|
455
|
+
text = _strip_frontmatter(text)
|
|
456
|
+
|
|
457
|
+
# Strip examples
|
|
458
|
+
strip_examples = level_config.get("strip_examples", True)
|
|
459
|
+
if strip_examples is True:
|
|
460
|
+
text = _strip_sections(text, ["## Example", "### Example", "## Usage Example"])
|
|
461
|
+
text = _strip_code_blocks(text)
|
|
462
|
+
elif strip_examples == "first-only":
|
|
463
|
+
text = _keep_first_code_block(text)
|
|
464
|
+
|
|
465
|
+
# Strip rationalization tables
|
|
466
|
+
if level_config.get("strip_rationalizations", True) is True:
|
|
467
|
+
text = _strip_sections(text, [
|
|
468
|
+
"## Common Rationalizations",
|
|
469
|
+
"### Common Rationalizations",
|
|
470
|
+
"## Rationalization",
|
|
471
|
+
])
|
|
472
|
+
elif level_config.get("strip_rationalizations") == "first-only":
|
|
473
|
+
text = _strip_sections_keep_first(text, [
|
|
474
|
+
"## Common Rationalizations",
|
|
475
|
+
"### Common Rationalizations",
|
|
476
|
+
])
|
|
477
|
+
|
|
478
|
+
# Strip related skills
|
|
479
|
+
if level_config.get("strip_related_skills", True):
|
|
480
|
+
text = _strip_sections(text, [
|
|
481
|
+
"## Related Skills",
|
|
482
|
+
"### Related Skills",
|
|
483
|
+
"## See Also",
|
|
484
|
+
])
|
|
485
|
+
|
|
486
|
+
# Strip or summarize verification
|
|
487
|
+
strip_verification = level_config.get("strip_verification", True)
|
|
488
|
+
if strip_verification is True:
|
|
489
|
+
text = _strip_sections(text, [
|
|
490
|
+
"## Verification Checklist",
|
|
491
|
+
"### Verification",
|
|
492
|
+
"## Verification",
|
|
493
|
+
])
|
|
494
|
+
elif strip_verification == "summary":
|
|
495
|
+
text = _summarize_sections(text, [
|
|
496
|
+
"## Verification Checklist",
|
|
497
|
+
"### Verification",
|
|
498
|
+
"## Verification",
|
|
499
|
+
])
|
|
500
|
+
|
|
501
|
+
# Strip agent CLI commands
|
|
502
|
+
if level_config.get("strip_agent_commands", True):
|
|
503
|
+
text = _strip_sections(text, [
|
|
504
|
+
"## Allowed CLI Commands",
|
|
505
|
+
"### Allowed CLI Commands",
|
|
506
|
+
])
|
|
507
|
+
|
|
508
|
+
# Strip multi-agent coordination
|
|
509
|
+
if level_config.get("strip_multi_agent", True):
|
|
510
|
+
text = _strip_sections(text, [
|
|
511
|
+
"## Multi-Agent",
|
|
512
|
+
"### Coordination",
|
|
513
|
+
"## Agent Orchestration",
|
|
514
|
+
"## Team Coordination",
|
|
515
|
+
])
|
|
516
|
+
|
|
517
|
+
# Collapse excessive blank lines
|
|
518
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
519
|
+
text = text.strip()
|
|
520
|
+
|
|
521
|
+
component.compressed_text = text
|
|
522
|
+
component.tokens_compressed = estimate_tokens(text)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _strip_sections(text: str, headers: list[str]) -> str:
|
|
526
|
+
"""Remove entire sections (header through next same-or-higher-level header)."""
|
|
527
|
+
for header in headers:
|
|
528
|
+
level = len(header) - len(header.lstrip("#"))
|
|
529
|
+
pattern = re.compile(
|
|
530
|
+
rf"^{re.escape(header)}.*?(?=^#{{1,{level}}} |\Z)",
|
|
531
|
+
re.MULTILINE | re.DOTALL,
|
|
532
|
+
)
|
|
533
|
+
text = pattern.sub("", text)
|
|
534
|
+
return text
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _strip_sections_keep_first(text: str, headers: list[str]) -> str:
|
|
538
|
+
"""Remove all but the first occurrence of matching sections."""
|
|
539
|
+
for header in headers:
|
|
540
|
+
level = len(header) - len(header.lstrip("#"))
|
|
541
|
+
pattern = re.compile(
|
|
542
|
+
rf"^{re.escape(header)}.*?(?=^#{{1,{level}}} |\Z)",
|
|
543
|
+
re.MULTILINE | re.DOTALL,
|
|
544
|
+
)
|
|
545
|
+
matches = list(pattern.finditer(text))
|
|
546
|
+
# Remove all except first
|
|
547
|
+
for match in reversed(matches[1:]):
|
|
548
|
+
text = text[:match.start()] + text[match.end():]
|
|
549
|
+
return text
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _summarize_sections(text: str, headers: list[str]) -> str:
|
|
553
|
+
"""Replace verbose verification sections with a one-liner."""
|
|
554
|
+
for header in headers:
|
|
555
|
+
level = len(header) - len(header.lstrip("#"))
|
|
556
|
+
pattern = re.compile(
|
|
557
|
+
rf"^({re.escape(header)}).*?(?=^#{{1,{level}}} |\Z)",
|
|
558
|
+
re.MULTILINE | re.DOTALL,
|
|
559
|
+
)
|
|
560
|
+
text = pattern.sub(f"{header}\nVerify: tests pass, no placeholders, no regressions.\n\n", text)
|
|
561
|
+
return text
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _strip_code_blocks(text: str) -> str:
|
|
565
|
+
"""Remove all fenced code blocks."""
|
|
566
|
+
return re.sub(r"```[\s\S]*?```", "", text)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _keep_first_code_block(text: str) -> str:
|
|
570
|
+
"""Keep only the first fenced code block, remove the rest."""
|
|
571
|
+
blocks = list(re.finditer(r"```[\s\S]*?```", text))
|
|
572
|
+
for block in reversed(blocks[1:]):
|
|
573
|
+
text = text[:block.start()] + text[block.end():]
|
|
574
|
+
return text
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def compress_all(
|
|
578
|
+
components: list[Component], level: str,
|
|
579
|
+
) -> None:
|
|
580
|
+
"""Apply compression to all components based on level."""
|
|
581
|
+
config = COMPRESSION_LEVELS.get(level, COMPRESSION_LEVELS["light"])
|
|
582
|
+
for comp in components:
|
|
583
|
+
compress_component(comp, config)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# ---------------------------------------------------------------------------
|
|
587
|
+
# 1.4 — Budget Packer
|
|
588
|
+
# ---------------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
def pack_components(
|
|
591
|
+
components: list[Component], budget: int, level: str,
|
|
592
|
+
) -> tuple[list[Component], list[Component]]:
|
|
593
|
+
"""Pack highest-value components into token budget.
|
|
594
|
+
|
|
595
|
+
Returns (included, excluded) tuple.
|
|
596
|
+
"""
|
|
597
|
+
effective_budget = int(budget * BUDGET_SAFETY_MARGIN)
|
|
598
|
+
config = COMPRESSION_LEVELS.get(level, COMPRESSION_LEVELS["light"])
|
|
599
|
+
max_skills = int(config.get("max_skills", 10)) # type: ignore[arg-type]
|
|
600
|
+
max_agents = int(config.get("max_agents", 1)) # type: ignore[arg-type]
|
|
601
|
+
include_rules = bool(config.get("include_rules", True))
|
|
602
|
+
|
|
603
|
+
# Fixed components: constitution, hook-rules, persona (score >= 0.85)
|
|
604
|
+
fixed = [c for c in components if c.score >= 0.85]
|
|
605
|
+
fixed_tokens = sum(c.tokens_compressed for c in fixed)
|
|
606
|
+
|
|
607
|
+
# Constitution budget guard
|
|
608
|
+
if fixed_tokens > effective_budget:
|
|
609
|
+
constitution_tokens = sum(
|
|
610
|
+
c.tokens_compressed for c in fixed if c.type == "constitution"
|
|
611
|
+
)
|
|
612
|
+
print(
|
|
613
|
+
f"ERROR: Constitution + safety rules alone require {fixed_tokens} tokens, "
|
|
614
|
+
f"exceeding budget of {effective_budget} (budget={budget} × {BUDGET_SAFETY_MARGIN}).\n"
|
|
615
|
+
f"Minimum safe budget: {int(fixed_tokens / BUDGET_SAFETY_MARGIN) + 1}.\n"
|
|
616
|
+
f"Use --budget {int(fixed_tokens / BUDGET_SAFETY_MARGIN) + 1} or higher.",
|
|
617
|
+
file=sys.stderr,
|
|
618
|
+
)
|
|
619
|
+
sys.exit(1)
|
|
620
|
+
|
|
621
|
+
remaining_budget = effective_budget - fixed_tokens
|
|
622
|
+
|
|
623
|
+
# Dynamic components: sort by value density (score / tokens)
|
|
624
|
+
dynamic = [c for c in components if c.score < 0.85]
|
|
625
|
+
|
|
626
|
+
# Apply type limits
|
|
627
|
+
if not include_rules:
|
|
628
|
+
dynamic = [c for c in dynamic if c.type != "rule"]
|
|
629
|
+
|
|
630
|
+
# Sort by value density
|
|
631
|
+
dynamic.sort(
|
|
632
|
+
key=lambda c: c.score / max(c.tokens_compressed, 1),
|
|
633
|
+
reverse=True,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
packed = list(fixed)
|
|
637
|
+
excluded: list[Component] = []
|
|
638
|
+
skill_count = 0
|
|
639
|
+
agent_count = 0
|
|
640
|
+
|
|
641
|
+
for comp in dynamic:
|
|
642
|
+
# Enforce type limits
|
|
643
|
+
if comp.type == "skill" and skill_count >= max_skills:
|
|
644
|
+
excluded.append(comp)
|
|
645
|
+
continue
|
|
646
|
+
if comp.type == "agent" and agent_count >= max_agents:
|
|
647
|
+
excluded.append(comp)
|
|
648
|
+
continue
|
|
649
|
+
|
|
650
|
+
if comp.tokens_compressed <= remaining_budget:
|
|
651
|
+
packed.append(comp)
|
|
652
|
+
remaining_budget -= comp.tokens_compressed
|
|
653
|
+
if comp.type == "skill":
|
|
654
|
+
skill_count += 1
|
|
655
|
+
elif comp.type == "agent":
|
|
656
|
+
agent_count += 1
|
|
657
|
+
else:
|
|
658
|
+
excluded.append(comp)
|
|
659
|
+
|
|
660
|
+
return packed, excluded
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
# ---------------------------------------------------------------------------
|
|
664
|
+
# 1.5 — Markdown Emitter
|
|
665
|
+
# ---------------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
# Section order for maximum SLM comprehension
|
|
668
|
+
SECTION_ORDER = ["constitution", "hook-rule", "persona", "rule", "skill", "agent"]
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def emit_markdown(components: list[Component]) -> str:
|
|
672
|
+
"""Emit compiled system prompt as structured markdown."""
|
|
673
|
+
sections: dict[str, list[str]] = {t: [] for t in SECTION_ORDER}
|
|
674
|
+
|
|
675
|
+
for comp in components:
|
|
676
|
+
section_type = comp.type if comp.type in SECTION_ORDER else "skill"
|
|
677
|
+
sections[section_type].append(comp.compressed_text)
|
|
678
|
+
|
|
679
|
+
parts: list[str] = ["# AI Coding Assistant — System Instructions\n"]
|
|
680
|
+
|
|
681
|
+
# Safety Rules (constitution + guard hooks)
|
|
682
|
+
safety_parts = sections["constitution"] + sections["hook-rule"]
|
|
683
|
+
if safety_parts:
|
|
684
|
+
parts.append("## Safety Rules (MANDATORY)\n")
|
|
685
|
+
parts.extend(safety_parts)
|
|
686
|
+
parts.append("")
|
|
687
|
+
|
|
688
|
+
# Identity (persona)
|
|
689
|
+
if sections["persona"]:
|
|
690
|
+
parts.append("## Your Identity\n")
|
|
691
|
+
parts.extend(sections["persona"])
|
|
692
|
+
parts.append("")
|
|
693
|
+
|
|
694
|
+
# Coding Standards (rules)
|
|
695
|
+
if sections["rule"]:
|
|
696
|
+
parts.append("## Coding Standards\n")
|
|
697
|
+
parts.extend(sections["rule"])
|
|
698
|
+
parts.append("")
|
|
699
|
+
|
|
700
|
+
# Key Skills
|
|
701
|
+
if sections["skill"]:
|
|
702
|
+
parts.append("## Key Skills\n")
|
|
703
|
+
for skill_text in sections["skill"]:
|
|
704
|
+
# Compact each skill to header + first paragraph
|
|
705
|
+
lines = skill_text.strip().split("\n")
|
|
706
|
+
compact = "\n".join(lines[:20]) if len(lines) > 20 else skill_text
|
|
707
|
+
parts.append(compact)
|
|
708
|
+
parts.append("")
|
|
709
|
+
|
|
710
|
+
# Agents (if any)
|
|
711
|
+
if sections["agent"]:
|
|
712
|
+
parts.append("## Available Agents\n")
|
|
713
|
+
for agent_text in sections["agent"]:
|
|
714
|
+
lines = agent_text.strip().split("\n")
|
|
715
|
+
compact = "\n".join(lines[:15]) if len(lines) > 15 else agent_text
|
|
716
|
+
parts.append(compact)
|
|
717
|
+
parts.append("")
|
|
718
|
+
|
|
719
|
+
output = "\n".join(parts)
|
|
720
|
+
# Clean up excessive whitespace
|
|
721
|
+
output = re.sub(r"\n{3,}", "\n\n", output)
|
|
722
|
+
return output.strip() + "\n"
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def format_output(markdown: str, fmt: str) -> str:
|
|
726
|
+
"""Convert compiled markdown to requested output format."""
|
|
727
|
+
if fmt == "raw":
|
|
728
|
+
return markdown
|
|
729
|
+
if fmt == "ollama":
|
|
730
|
+
# Ollama Modelfile SYSTEM block
|
|
731
|
+
escaped = markdown.replace('"', '\\"')
|
|
732
|
+
return f'FROM {{}}\nSYSTEM """\n{escaped}\n"""\n'
|
|
733
|
+
if fmt == "json-string":
|
|
734
|
+
return json.dumps(markdown)
|
|
735
|
+
if fmt == "aider":
|
|
736
|
+
# Aider --system-prompt-file compatible (just raw markdown)
|
|
737
|
+
return markdown
|
|
738
|
+
return markdown
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# ---------------------------------------------------------------------------
|
|
742
|
+
# 2.3 — Model Size Detection
|
|
743
|
+
# ---------------------------------------------------------------------------
|
|
744
|
+
|
|
745
|
+
def detect_model_size() -> str | None:
|
|
746
|
+
"""Detect running model size from Ollama API."""
|
|
747
|
+
try:
|
|
748
|
+
req = urllib.request.Request(
|
|
749
|
+
"http://localhost:11434/api/tags",
|
|
750
|
+
method="GET",
|
|
751
|
+
)
|
|
752
|
+
resp = urllib.request.urlopen(req, timeout=2)
|
|
753
|
+
data = json.loads(resp.read())
|
|
754
|
+
models = data.get("models", [])
|
|
755
|
+
if models:
|
|
756
|
+
latest = models[0].get("name", "")
|
|
757
|
+
match = re.search(r"(\d+)[bB]", latest)
|
|
758
|
+
if match:
|
|
759
|
+
return match.group(0).lower()
|
|
760
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
|
|
761
|
+
pass
|
|
762
|
+
return None
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# ---------------------------------------------------------------------------
|
|
766
|
+
# 3.2 — Compile Quality Validator
|
|
767
|
+
# ---------------------------------------------------------------------------
|
|
768
|
+
|
|
769
|
+
def validate_output(
|
|
770
|
+
included: list[Component], markdown: str, budget: int,
|
|
771
|
+
) -> list[str]:
|
|
772
|
+
"""Post-compilation quality checks. Returns list of errors (empty = pass)."""
|
|
773
|
+
errors: list[str] = []
|
|
774
|
+
total_tokens = estimate_tokens(markdown)
|
|
775
|
+
|
|
776
|
+
# 1. Constitution must be present
|
|
777
|
+
has_constitution = any(c.type == "constitution" for c in included)
|
|
778
|
+
if not has_constitution:
|
|
779
|
+
errors.append("FAIL: Constitution missing from compiled output")
|
|
780
|
+
|
|
781
|
+
# 2. Budget not exceeded
|
|
782
|
+
if total_tokens > budget:
|
|
783
|
+
errors.append(
|
|
784
|
+
f"FAIL: Output ({total_tokens:,} tokens) exceeds budget ({budget:,} tokens)"
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
# 3. No empty output
|
|
788
|
+
if len(markdown.strip()) < 100:
|
|
789
|
+
errors.append("FAIL: Compiled output is suspiciously short (<100 chars)")
|
|
790
|
+
|
|
791
|
+
# 4. Minimum component count
|
|
792
|
+
if len(included) < 2:
|
|
793
|
+
errors.append(
|
|
794
|
+
f"WARN: Only {len(included)} component(s) included — output may be too minimal"
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
# 5. Safety section present in output
|
|
798
|
+
if "Safety Rules" not in markdown and "Constitution" not in markdown:
|
|
799
|
+
errors.append("FAIL: No safety section found in compiled output")
|
|
800
|
+
|
|
801
|
+
# 6. Guard hooks compiled as text rules
|
|
802
|
+
has_guard = any(c.type == "hook-rule" for c in included)
|
|
803
|
+
if not has_guard:
|
|
804
|
+
errors.append("WARN: No guard hooks compiled into output — destructive commands unguarded")
|
|
805
|
+
|
|
806
|
+
return errors
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
# ---------------------------------------------------------------------------
|
|
810
|
+
# Integration Guides
|
|
811
|
+
# ---------------------------------------------------------------------------
|
|
812
|
+
|
|
813
|
+
INTEGRATION_GUIDES: dict[str, str] = {
|
|
814
|
+
"ollama": """\
|
|
815
|
+
## Ollama Setup
|
|
816
|
+
|
|
817
|
+
1. Compile system prompt:
|
|
818
|
+
ai-toolkit compile-slm --format ollama --model-size {model_size} > Modelfile.ai-toolkit
|
|
819
|
+
|
|
820
|
+
2. Create custom model:
|
|
821
|
+
ollama create my-assistant -f Modelfile.ai-toolkit
|
|
822
|
+
|
|
823
|
+
3. Run:
|
|
824
|
+
ollama run my-assistant
|
|
825
|
+
|
|
826
|
+
Note: Replace the FROM line in the Modelfile with your base model (e.g., FROM llama3.1:8b).""",
|
|
827
|
+
|
|
828
|
+
"lm-studio": """\
|
|
829
|
+
## LM Studio Setup
|
|
830
|
+
|
|
831
|
+
1. Compile system prompt:
|
|
832
|
+
ai-toolkit compile-slm --model-size {model_size} --output system-prompt.md
|
|
833
|
+
|
|
834
|
+
2. In LM Studio:
|
|
835
|
+
- Open Chat Settings (gear icon)
|
|
836
|
+
- Paste contents of system-prompt.md into "System Prompt" field
|
|
837
|
+
- Save as preset for reuse""",
|
|
838
|
+
|
|
839
|
+
"aider": """\
|
|
840
|
+
## Aider Setup
|
|
841
|
+
|
|
842
|
+
1. Compile system prompt:
|
|
843
|
+
ai-toolkit compile-slm --format aider --model-size {model_size} --output .ai-toolkit-system.md
|
|
844
|
+
|
|
845
|
+
2. Run Aider with custom system prompt:
|
|
846
|
+
aider --system-prompt-file .ai-toolkit-system.md
|
|
847
|
+
|
|
848
|
+
3. Or add to .aider.conf.yml:
|
|
849
|
+
system-prompt-file: .ai-toolkit-system.md""",
|
|
850
|
+
|
|
851
|
+
"continue-dev": """\
|
|
852
|
+
## Continue.dev Setup
|
|
853
|
+
|
|
854
|
+
1. Compile system prompt:
|
|
855
|
+
ai-toolkit compile-slm --model-size {model_size} --output system-prompt.md
|
|
856
|
+
|
|
857
|
+
2. Edit ~/.continue/config.json, add to your model config:
|
|
858
|
+
{{
|
|
859
|
+
"models": [{{
|
|
860
|
+
"title": "Local Assistant",
|
|
861
|
+
"provider": "ollama",
|
|
862
|
+
"model": "llama3.1:8b",
|
|
863
|
+
"systemMessage": "<paste contents of system-prompt.md here>"
|
|
864
|
+
}}]
|
|
865
|
+
}}""",
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def print_integration_guide(model_size: str, output_path: str) -> None:
|
|
870
|
+
"""Print platform-specific integration instructions after compilation."""
|
|
871
|
+
print("\n--- Integration Guides ---", file=sys.stderr)
|
|
872
|
+
for platform, template in INTEGRATION_GUIDES.items():
|
|
873
|
+
print(f"\n{template.format(model_size=model_size)}", file=sys.stderr)
|
|
874
|
+
print(
|
|
875
|
+
f"\nCompiled prompt saved to: {output_path}",
|
|
876
|
+
file=sys.stderr,
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
# ---------------------------------------------------------------------------
|
|
881
|
+
# Dry Run Table
|
|
882
|
+
# ---------------------------------------------------------------------------
|
|
883
|
+
|
|
884
|
+
def print_dry_run(
|
|
885
|
+
included: list[Component],
|
|
886
|
+
excluded: list[Component],
|
|
887
|
+
budget: int,
|
|
888
|
+
level: str,
|
|
889
|
+
persona: str,
|
|
890
|
+
) -> None:
|
|
891
|
+
"""Print a table showing what would be included in compilation."""
|
|
892
|
+
effective_budget = int(budget * BUDGET_SAFETY_MARGIN)
|
|
893
|
+
total_tokens = sum(c.tokens_compressed for c in included)
|
|
894
|
+
|
|
895
|
+
print(f"Budget: {budget} tokens (effective: {effective_budget}) | "
|
|
896
|
+
f"Level: {level} | Persona: {persona or '(none)'}")
|
|
897
|
+
print()
|
|
898
|
+
|
|
899
|
+
# Table header
|
|
900
|
+
header = f"{'Component':<40} {'Score':>6} {'Tokens':>7} {'Included':>10}"
|
|
901
|
+
print(header)
|
|
902
|
+
print("-" * len(header))
|
|
903
|
+
|
|
904
|
+
# Included components
|
|
905
|
+
for comp in sorted(included, key=lambda c: c.score, reverse=True):
|
|
906
|
+
print(f"{comp.name:<40} {comp.score:>6.2f} {comp.tokens_compressed:>7} {'YES':>10}")
|
|
907
|
+
|
|
908
|
+
# Excluded components (top 10)
|
|
909
|
+
for comp in sorted(excluded, key=lambda c: c.score, reverse=True)[:10]:
|
|
910
|
+
reason = "budget" if comp.tokens_compressed > 0 else "limit"
|
|
911
|
+
print(f"{comp.name:<40} {comp.score:>6.2f} {comp.tokens_compressed:>7} {f'NO ({reason})':>10}")
|
|
912
|
+
|
|
913
|
+
if len(excluded) > 10:
|
|
914
|
+
print(f" ... and {len(excluded) - 10} more excluded components")
|
|
915
|
+
|
|
916
|
+
print()
|
|
917
|
+
utilization = (total_tokens / budget * 100) if budget > 0 else 0
|
|
918
|
+
print(f"Total: {total_tokens:,} / {budget:,} tokens ({utilization:.1f}% utilization)")
|
|
919
|
+
print(f"Components: {len(included)} included, {len(excluded)} excluded")
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
# ---------------------------------------------------------------------------
|
|
923
|
+
# Main
|
|
924
|
+
# ---------------------------------------------------------------------------
|
|
925
|
+
|
|
926
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
927
|
+
"""Build argument parser for compile-slm command."""
|
|
928
|
+
parser = argparse.ArgumentParser(
|
|
929
|
+
prog="compile-slm",
|
|
930
|
+
description="Compile ai-toolkit into a minimal SLM system prompt.",
|
|
931
|
+
)
|
|
932
|
+
parser.add_argument(
|
|
933
|
+
"--budget", type=int, default=0,
|
|
934
|
+
help="Token budget (default: auto from model size)",
|
|
935
|
+
)
|
|
936
|
+
parser.add_argument(
|
|
937
|
+
"--model-size",
|
|
938
|
+
choices=list(MODEL_BUDGETS.keys()),
|
|
939
|
+
default="",
|
|
940
|
+
help="Model size (default: auto-detect from Ollama)",
|
|
941
|
+
)
|
|
942
|
+
parser.add_argument(
|
|
943
|
+
"--persona", default="",
|
|
944
|
+
help="Persona preset to prioritize (e.g., backend-lead)",
|
|
945
|
+
)
|
|
946
|
+
parser.add_argument(
|
|
947
|
+
"--lang", default="",
|
|
948
|
+
help="Comma-separated languages to include (e.g., python,typescript)",
|
|
949
|
+
)
|
|
950
|
+
parser.add_argument(
|
|
951
|
+
"--output", default="",
|
|
952
|
+
help="Output file path (default: ~/.ai-toolkit/compiled/slm-system-prompt.md)",
|
|
953
|
+
)
|
|
954
|
+
parser.add_argument(
|
|
955
|
+
"--format",
|
|
956
|
+
choices=["raw", "ollama", "json-string", "aider"],
|
|
957
|
+
default="raw",
|
|
958
|
+
help="Output format (default: raw)",
|
|
959
|
+
)
|
|
960
|
+
parser.add_argument(
|
|
961
|
+
"--level",
|
|
962
|
+
choices=list(COMPRESSION_LEVELS.keys()),
|
|
963
|
+
default="",
|
|
964
|
+
help="Compression level (default: auto from model size)",
|
|
965
|
+
)
|
|
966
|
+
parser.add_argument(
|
|
967
|
+
"--dry-run", action="store_true",
|
|
968
|
+
help="Show what would be included without writing output",
|
|
969
|
+
)
|
|
970
|
+
return parser
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def main(argv: list[str] | None = None) -> int:
|
|
974
|
+
"""Main entry point for compile-slm."""
|
|
975
|
+
args = build_parser().parse_args(argv)
|
|
976
|
+
|
|
977
|
+
# Resolve model size
|
|
978
|
+
model_size = args.model_size
|
|
979
|
+
if not model_size:
|
|
980
|
+
detected = detect_model_size()
|
|
981
|
+
model_size = detected or DEFAULT_MODEL_SIZE
|
|
982
|
+
if detected:
|
|
983
|
+
print(f"Auto-detected model size: {model_size}", file=sys.stderr)
|
|
984
|
+
else:
|
|
985
|
+
print(f"No model detected, using default: {model_size}", file=sys.stderr)
|
|
986
|
+
|
|
987
|
+
# Resolve budget and level
|
|
988
|
+
model_config = MODEL_BUDGETS.get(model_size, MODEL_BUDGETS[DEFAULT_MODEL_SIZE])
|
|
989
|
+
budget = args.budget or int(model_config["budget"]) # type: ignore[arg-type]
|
|
990
|
+
level = args.level or str(model_config["level"])
|
|
991
|
+
|
|
992
|
+
# Parse languages
|
|
993
|
+
languages = [l.strip() for l in args.lang.split(",") if l.strip()] if args.lang else []
|
|
994
|
+
|
|
995
|
+
# Parse components
|
|
996
|
+
components = parse_components(persona=args.persona, languages=languages)
|
|
997
|
+
|
|
998
|
+
# Compress
|
|
999
|
+
compress_all(components, level)
|
|
1000
|
+
|
|
1001
|
+
# Pack
|
|
1002
|
+
included, excluded = pack_components(components, budget, level)
|
|
1003
|
+
|
|
1004
|
+
# Dry run
|
|
1005
|
+
if args.dry_run:
|
|
1006
|
+
print_dry_run(included, excluded, budget, level, args.persona)
|
|
1007
|
+
return 0
|
|
1008
|
+
|
|
1009
|
+
# Emit
|
|
1010
|
+
markdown = emit_markdown(included)
|
|
1011
|
+
output = format_output(markdown, args.format)
|
|
1012
|
+
total_tokens = estimate_tokens(markdown)
|
|
1013
|
+
|
|
1014
|
+
# Validate
|
|
1015
|
+
errors = validate_output(included, markdown, budget)
|
|
1016
|
+
for err in errors:
|
|
1017
|
+
print(err, file=sys.stderr)
|
|
1018
|
+
if any(e.startswith("FAIL") for e in errors):
|
|
1019
|
+
return 1
|
|
1020
|
+
|
|
1021
|
+
# Write output
|
|
1022
|
+
output_path = args.output
|
|
1023
|
+
if not output_path:
|
|
1024
|
+
compiled_dir = Path.home() / ".ai-toolkit" / "compiled"
|
|
1025
|
+
compiled_dir.mkdir(parents=True, exist_ok=True)
|
|
1026
|
+
output_path = str(compiled_dir / "slm-system-prompt.md")
|
|
1027
|
+
|
|
1028
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
1029
|
+
Path(output_path).write_text(output, encoding="utf-8")
|
|
1030
|
+
|
|
1031
|
+
print(f"Compiled {len(included)} components into {total_tokens:,} tokens", file=sys.stderr)
|
|
1032
|
+
print(f"Level: {level} | Budget: {budget:,} | Model: {model_size}", file=sys.stderr)
|
|
1033
|
+
print(f"Output: {output_path}", file=sys.stderr)
|
|
1034
|
+
|
|
1035
|
+
# Integration guides
|
|
1036
|
+
if not args.dry_run:
|
|
1037
|
+
print_integration_guide(model_size, output_path)
|
|
1038
|
+
|
|
1039
|
+
return 0
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
if __name__ == "__main__":
|
|
1043
|
+
sys.exit(main())
|