@softspark/ai-toolkit 3.1.1 → 3.2.1

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/llms.txt CHANGED
@@ -16,9 +16,12 @@
16
16
  - [Plan: Deep Coverage v3.0 — 100% Native Surface Utilization](kb/history/completed/deep-coverage-v3-20260423.md)
17
17
  - [Plan: Ecosystem Deep Sweep — All 12 Supported Tools](kb/history/completed/ecosystem-deep-sweep-20260423.md)
18
18
  - [Plan: Enterprise Config Inheritance — Multi-Repo Governance with `extends`](kb/history/completed/enterprise-config-inheritance-plan-20260412.md)
19
+ - [Spike: F2 MCP Context Trim — Hook Feasibility & Path Decision](kb/history/completed/f2-mcp-trim-spike-20260504.md)
19
20
  - [Plan: Offline-First SLM Profile — Lightweight Mode for Local Models](kb/history/completed/offline-slm-profile-plan-20260411.md)
21
+ - [Plan: Output & Token Discipline](kb/history/completed/output-token-discipline-plan-20260504.md)
20
22
  - [How-To Guides](kb/howto/README.md)
21
23
  - [Plan: Cloud Security Pack — Multi-Cloud Audit](kb/planning/cloud-security-pack-plan.md)
24
+ - [PRD: MCP Context Trim v4.0](kb/planning/mcp-context-trim-v4-prd.md)
22
25
  - [SOP: Ecosystem Sync](kb/procedures/ecosystem-sync-sop.md)
23
26
  - [SOP: Claude Toolkit Maintenance](kb/procedures/maintenance-sop.md)
24
27
  - [SOP: Release Preparation](kb/procedures/release-preparation-sop.md)
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.1.1",
2
+ "version": "3.2.1",
3
3
  "components": {
4
4
  "agents": {
5
5
  "description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softspark/ai-toolkit",
3
- "version": "3.1.1",
3
+ "version": "3.2.1",
4
4
  "description": "AI coding toolkit: 112 skills, 44 agents, 12-editor write-through (Claude, Cursor, Windsurf, Copilot, Gemini, Cline, Roo, Aider, Augment, Antigravity, Codex, opencode), machine-enforced safety constitution, SARIF audit, signed npm provenance.",
5
5
  "keywords": [
6
6
  "claude",
@@ -29,6 +29,7 @@ def install_hooks(claude_dir: Path, hooks_scripts_dir: Path,
29
29
  return
30
30
 
31
31
  _copy_hook_scripts(claude_dir, hooks_scripts_dir)
32
+ _copy_hook_runtime_scripts(hooks_scripts_dir.parent / "scripts")
32
33
  _run_merge_hooks(
33
34
  "inject",
34
35
  str(hooks_json),
@@ -62,6 +63,38 @@ def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
62
63
  print(" Removed: .claude/hooks (legacy symlink)")
63
64
 
64
65
 
66
+ # Python helpers that hooks invoke at runtime. Kept narrow on purpose — only
67
+ # scripts that a deployed hook actually executes belong here.
68
+ HOOK_RUNTIME_SCRIPTS: tuple[str, ...] = (
69
+ "session_token_stats.py",
70
+ "version_check.py",
71
+ )
72
+
73
+
74
+ def _copy_hook_runtime_scripts(scripts_dst: Path) -> None:
75
+ """Copy the small set of Python helpers that deployed hooks invoke.
76
+
77
+ Without this, hooks that call e.g. session_token_stats.py only work when
78
+ the npm-global @softspark/ai-toolkit package is up to date. Shipping these
79
+ alongside the hooks means ~/.softspark/ai-toolkit/ is self-sufficient.
80
+ """
81
+ scripts_src = toolkit_dir / "scripts"
82
+ if not scripts_src.is_dir():
83
+ return
84
+ scripts_dst.mkdir(parents=True, exist_ok=True)
85
+ copied = 0
86
+ for name in HOOK_RUNTIME_SCRIPTS:
87
+ src = scripts_src / name
88
+ if not src.is_file():
89
+ continue
90
+ dst = scripts_dst / name
91
+ shutil.copy2(src, dst)
92
+ dst.chmod(dst.stat().st_mode | 0o111)
93
+ copied += 1
94
+ if copied:
95
+ print(f" Copied: {copied} hook runtime scripts to ~/.softspark/ai-toolkit/scripts/")
96
+
97
+
65
98
  def _run_merge_hooks(action: str, *args: str) -> None:
66
99
  cmd = ["python3", str(toolkit_dir / "scripts" / "merge-hooks.py"), action, *args]
67
100
  subprocess.run(cmd, check=True, timeout=120)
@@ -111,6 +111,7 @@ def cmd_inject(toolkit_path: str, target_path: str) -> None:
111
111
  sys.exit(2)
112
112
 
113
113
  toolkit_hooks = toolkit_data.get("hooks", {})
114
+ toolkit_statusline = toolkit_data.get("statusLine")
114
115
 
115
116
  # Load existing target settings (preserving all non-hooks keys)
116
117
  target_settings: dict = {}
@@ -128,6 +129,17 @@ def cmd_inject(toolkit_path: str, target_path: str) -> None:
128
129
  result = merge(toolkit_hooks, target_hooks)
129
130
 
130
131
  target_settings["hooks"] = result
132
+
133
+ # statusLine: only set if absent OR previously installed by ai-toolkit.
134
+ # User-customized statusLine (no _source tag) is preserved.
135
+ if toolkit_statusline is not None:
136
+ existing_sl = target_settings.get("statusLine")
137
+ if existing_sl is None or (
138
+ isinstance(existing_sl, dict) and existing_sl.get("_source") == SOURCE_TAG
139
+ ):
140
+ target_settings["statusLine"] = toolkit_statusline
141
+ # else: user has a custom statusLine, leave it alone
142
+
131
143
  save_json(target_path, target_settings)
132
144
 
133
145
 
@@ -160,6 +172,11 @@ def cmd_strip(target_path: str) -> None:
160
172
  else:
161
173
  target_settings.pop("hooks", None)
162
174
 
175
+ # Strip toolkit-installed statusLine (user-customized one is preserved).
176
+ sl = target_settings.get("statusLine")
177
+ if isinstance(sl, dict) and sl.get("_source") == SOURCE_TAG:
178
+ target_settings.pop("statusLine", None)
179
+
163
180
  save_json(target_path, target_settings)
164
181
 
165
182
 
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env python3
2
+ """Pack a codebase into a single AI-friendly markdown file under a token budget.
3
+
4
+ Walks the current working directory (or --root), respects .gitignore, ranks files
5
+ by extension + size, and writes a single markdown file with a table of contents
6
+ and per-file blocks. Files over the per-file size cap are summarized to head + tail.
7
+
8
+ Usage:
9
+ python3 scripts/pack_codebase.py [options]
10
+ ai-toolkit pack-codebase [options]
11
+
12
+ Options:
13
+ --root PATH Root directory to pack (default: CWD)
14
+ --output PATH Output file (default: ./pack-codebase.md)
15
+ --budget N[k|m] Token budget; supports "100k", "1m" (default: 100k)
16
+ --include GLOB[,GLOB] Include only matching paths (relative globs)
17
+ --exclude GLOB[,GLOB] Exclude matching paths (added to gitignore set)
18
+ --max-file-bytes N Per-file byte cap before head/tail truncation (default: 8000)
19
+ --head-lines N Lines kept from head when truncating (default: 60)
20
+ --tail-lines N Lines kept from tail when truncating (default: 20)
21
+ --dry-run List files that would be included; do not write output
22
+ --json Emit a JSON manifest to stdout instead of markdown
23
+
24
+ Exit codes:
25
+ 0 pack succeeded (or dry-run completed)
26
+ 1 budget exceeded with no files left to drop
27
+ 2 no files matched after include/exclude
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import fnmatch
33
+ import json
34
+ import os
35
+ import sys
36
+ from dataclasses import dataclass, field
37
+ from pathlib import Path
38
+
39
+ # Token estimate: 4 chars per token is the standard rough heuristic for English / code.
40
+ CHARS_PER_TOKEN = 4
41
+
42
+ DEFAULT_BUDGET = "100k"
43
+ DEFAULT_MAX_FILE_BYTES = 8000
44
+ DEFAULT_HEAD_LINES = 60
45
+ DEFAULT_TAIL_LINES = 20
46
+
47
+ CODE_EXTS = {
48
+ ".py", ".pyi", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
49
+ ".go", ".rs", ".java", ".kt", ".kts", ".swift", ".rb", ".php",
50
+ ".cs", ".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".hxx",
51
+ ".dart", ".scala", ".clj", ".ex", ".exs", ".erl", ".lua", ".sh",
52
+ ".bash", ".zsh", ".fish", ".ps1",
53
+ }
54
+ CONFIG_EXTS = {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env.example"}
55
+ CONFIG_NAMES = {"Dockerfile", "Makefile", "Procfile", ".dockerignore", ".gitignore"}
56
+ DOC_EXTS = {".md", ".mdx", ".rst", ".txt"}
57
+
58
+ BINARY_EXTS = {
59
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".pdf",
60
+ ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
61
+ ".mp3", ".mp4", ".mov", ".avi", ".webm", ".wav", ".ogg",
62
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
63
+ ".so", ".dylib", ".dll", ".exe", ".class", ".jar",
64
+ ".pyc", ".pyo", ".o", ".a", ".node",
65
+ }
66
+
67
+ ALWAYS_SKIP_DIRS = {
68
+ ".git", "node_modules", ".venv", "venv", "__pycache__", ".pytest_cache",
69
+ ".mypy_cache", ".ruff_cache", "dist", "build", ".next", ".nuxt", ".output",
70
+ "target", "coverage", ".coverage", "htmlcov", ".tox", ".idea", ".vscode",
71
+ ".DS_Store",
72
+ }
73
+
74
+
75
+ @dataclass
76
+ class FileEntry:
77
+ path: Path
78
+ rel: str
79
+ size: int
80
+ category: str # "code" | "config" | "docs" | "other"
81
+ priority: int = 0 # higher = include first
82
+ included: bool = False
83
+ truncated: bool = False
84
+ body: str = ""
85
+
86
+ def estimated_tokens(self) -> int:
87
+ return max(len(self.body) // CHARS_PER_TOKEN, 1) if self.body else 0
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Argument parsing
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ def parse_budget(value: str) -> int:
96
+ v = value.strip().lower()
97
+ if v.endswith("k"):
98
+ return int(float(v[:-1]) * 1_000)
99
+ if v.endswith("m"):
100
+ return int(float(v[:-1]) * 1_000_000)
101
+ return int(v)
102
+
103
+
104
+ def parse_args(argv: list[str]) -> argparse.Namespace:
105
+ p = argparse.ArgumentParser(description="Pack a codebase into a single AI-friendly markdown file.")
106
+ p.add_argument("--root", default=os.getcwd())
107
+ p.add_argument("--output", default="pack-codebase.md")
108
+ p.add_argument("--budget", default=DEFAULT_BUDGET)
109
+ p.add_argument("--include", default="")
110
+ p.add_argument("--exclude", default="")
111
+ p.add_argument("--max-file-bytes", type=int, default=DEFAULT_MAX_FILE_BYTES)
112
+ p.add_argument("--head-lines", type=int, default=DEFAULT_HEAD_LINES)
113
+ p.add_argument("--tail-lines", type=int, default=DEFAULT_TAIL_LINES)
114
+ p.add_argument("--dry-run", action="store_true")
115
+ p.add_argument("--json", action="store_true")
116
+ return p.parse_args(argv)
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # .gitignore handling (minimal — directory + glob patterns, no negations)
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def load_gitignore_patterns(root: Path) -> list[str]:
125
+ gi = root / ".gitignore"
126
+ if not gi.exists():
127
+ return []
128
+ patterns: list[str] = []
129
+ for line in gi.read_text(encoding="utf-8", errors="ignore").splitlines():
130
+ line = line.strip()
131
+ if not line or line.startswith("#") or line.startswith("!"):
132
+ continue
133
+ patterns.append(line)
134
+ return patterns
135
+
136
+
137
+ def matches_pattern(rel_path: str, pattern: str) -> bool:
138
+ if pattern.endswith("/"):
139
+ pattern = pattern + "**"
140
+ if "/" in pattern:
141
+ return fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(rel_path, pattern.lstrip("/"))
142
+ return any(fnmatch.fnmatch(part, pattern) for part in rel_path.split("/"))
143
+
144
+
145
+ def should_skip(rel_path: str, name: str, patterns: list[str]) -> bool:
146
+ parts = rel_path.split("/")
147
+ if any(part in ALWAYS_SKIP_DIRS for part in parts):
148
+ return True
149
+ return any(matches_pattern(rel_path, p) for p in patterns)
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Categorization
154
+ # ---------------------------------------------------------------------------
155
+
156
+
157
+ def categorize(path: Path) -> tuple[str, int]:
158
+ name = path.name
159
+ suffix = path.suffix.lower()
160
+ if suffix in BINARY_EXTS:
161
+ return ("binary", -1)
162
+ if name in CONFIG_NAMES or suffix in CONFIG_EXTS:
163
+ return ("config", 80)
164
+ if suffix in CODE_EXTS:
165
+ return ("code", 100)
166
+ if suffix in DOC_EXTS:
167
+ return ("docs", 60)
168
+ return ("other", 20)
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # File discovery + body extraction
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ def discover_files(
177
+ root: Path,
178
+ include_globs: list[str],
179
+ extra_exclude: list[str],
180
+ ) -> list[FileEntry]:
181
+ patterns = load_gitignore_patterns(root) + extra_exclude
182
+ entries: list[FileEntry] = []
183
+ for dirpath, dirnames, filenames in os.walk(root):
184
+ rel_dir = os.path.relpath(dirpath, root).replace(os.sep, "/")
185
+ rel_dir = "" if rel_dir == "." else rel_dir
186
+ dirnames[:] = [d for d in dirnames if d not in ALWAYS_SKIP_DIRS]
187
+ if rel_dir and should_skip(rel_dir, os.path.basename(dirpath), patterns):
188
+ dirnames[:] = []
189
+ continue
190
+ for fname in filenames:
191
+ rel = f"{rel_dir}/{fname}" if rel_dir else fname
192
+ if should_skip(rel, fname, patterns):
193
+ continue
194
+ if include_globs and not any(matches_pattern(rel, g) for g in include_globs):
195
+ continue
196
+ full = Path(dirpath) / fname
197
+ try:
198
+ size = full.stat().st_size
199
+ except OSError:
200
+ continue
201
+ category, priority = categorize(full)
202
+ if category == "binary":
203
+ continue
204
+ entries.append(FileEntry(path=full, rel=rel, size=size, category=category, priority=priority))
205
+ return entries
206
+
207
+
208
+ def read_body(entry: FileEntry, max_bytes: int, head_lines: int, tail_lines: int) -> None:
209
+ try:
210
+ text = entry.path.read_text(encoding="utf-8", errors="replace")
211
+ except OSError:
212
+ entry.body = ""
213
+ return
214
+ if entry.size <= max_bytes:
215
+ entry.body = text
216
+ return
217
+ lines = text.splitlines()
218
+ head = "\n".join(lines[:head_lines])
219
+ tail = "\n".join(lines[-tail_lines:]) if tail_lines > 0 else ""
220
+ omitted = max(len(lines) - head_lines - tail_lines, 0)
221
+ parts = [head]
222
+ if omitted > 0:
223
+ parts.append(f"\n... [{omitted} lines omitted — file truncated to fit budget] ...\n")
224
+ if tail:
225
+ parts.append(tail)
226
+ entry.body = "\n".join(parts)
227
+ entry.truncated = True
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Selection under budget
232
+ # ---------------------------------------------------------------------------
233
+
234
+
235
+ def select_under_budget(entries: list[FileEntry], budget_tokens: int) -> tuple[list[FileEntry], int]:
236
+ entries.sort(key=lambda e: (-e.priority, e.size, e.rel))
237
+ used = 0
238
+ chosen: list[FileEntry] = []
239
+ for e in entries:
240
+ cost = e.estimated_tokens()
241
+ if cost == 0:
242
+ continue
243
+ if used + cost > budget_tokens:
244
+ continue
245
+ e.included = True
246
+ chosen.append(e)
247
+ used += cost
248
+ return chosen, used
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Output formatting
253
+ # ---------------------------------------------------------------------------
254
+
255
+
256
+ def render_markdown(root: Path, chosen: list[FileEntry], budget_tokens: int, used_tokens: int) -> str:
257
+ lines: list[str] = []
258
+ lines.append(f"# Codebase Pack — {root.name}")
259
+ lines.append("")
260
+ lines.append(f"- Root: `{root}`")
261
+ lines.append(f"- Files included: {len(chosen)}")
262
+ lines.append(f"- Estimated tokens: {used_tokens:,} / {budget_tokens:,} budget")
263
+ lines.append(f"- Truncated files: {sum(1 for e in chosen if e.truncated)}")
264
+ lines.append("")
265
+ lines.append("## Table of Contents")
266
+ lines.append("")
267
+ for e in chosen:
268
+ marker = " (truncated)" if e.truncated else ""
269
+ lines.append(f"- `{e.rel}` ({e.category}, {e.size:,} B){marker}")
270
+ lines.append("")
271
+ lines.append("---")
272
+ lines.append("")
273
+ for e in chosen:
274
+ suffix = e.path.suffix.lstrip(".") or "text"
275
+ fence_lang = {"py": "python", "ts": "typescript", "tsx": "tsx", "js": "javascript",
276
+ "jsx": "jsx", "go": "go", "rs": "rust", "rb": "ruby", "kt": "kotlin",
277
+ "swift": "swift", "java": "java", "cpp": "cpp", "c": "c", "sh": "bash",
278
+ "yml": "yaml", "yaml": "yaml", "json": "json", "toml": "toml",
279
+ "md": "markdown", "php": "php", "cs": "csharp", "dart": "dart"}.get(suffix, suffix)
280
+ lines.append(f"## `{e.rel}`")
281
+ if e.truncated:
282
+ lines.append("")
283
+ lines.append("> _File truncated to fit the token budget._")
284
+ lines.append("")
285
+ lines.append(f"```{fence_lang}")
286
+ lines.append(e.body.rstrip("\n"))
287
+ lines.append("```")
288
+ lines.append("")
289
+ return "\n".join(lines)
290
+
291
+
292
+ def render_manifest(root: Path, chosen: list[FileEntry], dropped: list[FileEntry], budget_tokens: int, used_tokens: int) -> dict:
293
+ return {
294
+ "root": str(root),
295
+ "budget_tokens": budget_tokens,
296
+ "used_tokens": used_tokens,
297
+ "files_included": [
298
+ {"path": e.rel, "category": e.category, "size": e.size, "tokens": e.estimated_tokens(), "truncated": e.truncated}
299
+ for e in chosen
300
+ ],
301
+ "files_dropped": [
302
+ {"path": e.rel, "category": e.category, "size": e.size, "reason": "over_budget"}
303
+ for e in dropped
304
+ ],
305
+ }
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # Main
310
+ # ---------------------------------------------------------------------------
311
+
312
+
313
+ def main(argv: list[str]) -> int:
314
+ args = parse_args(argv)
315
+ root = Path(args.root).resolve()
316
+ if not root.is_dir():
317
+ print(f"Error: root is not a directory: {root}", file=sys.stderr)
318
+ return 1
319
+ budget_tokens = parse_budget(args.budget)
320
+ include_globs = [g.strip() for g in args.include.split(",") if g.strip()]
321
+ extra_exclude = [g.strip() for g in args.exclude.split(",") if g.strip()]
322
+
323
+ entries = discover_files(root, include_globs, extra_exclude)
324
+ if not entries:
325
+ print("No files matched after include/exclude filters.", file=sys.stderr)
326
+ return 2
327
+
328
+ for e in entries:
329
+ read_body(e, args.max_file_bytes, args.head_lines, args.tail_lines)
330
+
331
+ chosen, used_tokens = select_under_budget(entries, budget_tokens)
332
+ dropped = [e for e in entries if not e.included]
333
+
334
+ if args.json:
335
+ manifest = render_manifest(root, chosen, dropped, budget_tokens, used_tokens)
336
+ print(json.dumps(manifest, indent=2))
337
+ return 0
338
+
339
+ if args.dry_run:
340
+ print(f"Would include {len(chosen)} files (~{used_tokens:,} tokens), drop {len(dropped)}.")
341
+ for e in chosen:
342
+ tag = " [trunc]" if e.truncated else ""
343
+ print(f" + {e.rel} ({e.category}, {e.size} B, ~{e.estimated_tokens()} tok){tag}")
344
+ for e in dropped[:20]:
345
+ print(f" - {e.rel} ({e.category}, {e.size} B) — over budget")
346
+ if len(dropped) > 20:
347
+ print(f" ... and {len(dropped) - 20} more dropped.")
348
+ return 0
349
+
350
+ output = Path(args.output)
351
+ if not output.is_absolute():
352
+ output = Path.cwd() / output
353
+ output.parent.mkdir(parents=True, exist_ok=True)
354
+ output.write_text(render_markdown(root, chosen, budget_tokens, used_tokens), encoding="utf-8")
355
+ print(f"Wrote {output} — {len(chosen)} files, ~{used_tokens:,} tokens / {budget_tokens:,} budget.")
356
+ if dropped:
357
+ print(f"Dropped {len(dropped)} file(s) over budget. Re-run with a larger --budget if needed.")
358
+ return 0
359
+
360
+
361
+ if __name__ == "__main__":
362
+ sys.exit(main(sys.argv[1:]))