@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/AGENTS.md +3 -3
- package/CHANGELOG.md +40 -0
- package/README.md +7 -8
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +47 -0
- package/app/agents/seo-specialist.md +86 -3
- package/app/hooks/ai-toolkit-statusline.sh +200 -0
- package/app/hooks.json +5 -0
- package/app/skills/brand-voice/SKILL.md +38 -2
- package/app/skills/brand-voice/modes/concise.md +67 -0
- package/app/skills/brand-voice/modes/strict.md +91 -0
- package/app/skills/brand-voice/scripts/measure.py +246 -0
- package/app/skills/briefing/SKILL.md +61 -0
- package/app/skills/swarm/SKILL.md +83 -1
- package/benchmarks/ecosystem-doctor-snapshot.json +17 -13
- package/bin/ai-toolkit.js +2 -0
- package/kb/history/completed/f2-mcp-trim-spike-20260504.md +117 -0
- package/kb/history/completed/output-token-discipline-plan-20260504.md +261 -0
- package/kb/planning/mcp-context-trim-v4-prd.md +158 -0
- package/kb/procedures/release-verification-sop.md +8 -5
- package/kb/reference/architecture-overview.md +2 -2
- package/kb/reference/skills-catalog.md +3 -3
- package/llms-full.txt +569 -12
- package/llms.txt +3 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/install_steps/hooks.py +33 -0
- package/scripts/merge-hooks.py +17 -0
- package/scripts/pack_codebase.py +362 -0
- package/scripts/session_token_stats.py +264 -0
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softspark/ai-toolkit",
|
|
3
|
-
"version": "3.
|
|
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)
|
package/scripts/merge-hooks.py
CHANGED
|
@@ -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:]))
|