@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.
@@ -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())