@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
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read Claude Code session JSONL files and report real token usage.
|
|
3
|
+
|
|
4
|
+
Claude Code writes one JSONL line per message to:
|
|
5
|
+
~/.claude/projects/<sanitized-cwd>/<session-id>.jsonl
|
|
6
|
+
|
|
7
|
+
Each message contains a `usage` block with input_tokens, output_tokens,
|
|
8
|
+
cache_creation_input_tokens, and cache_read_input_tokens. This script
|
|
9
|
+
parses those blocks and emits aggregate or per-session reports.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python3 scripts/session_token_stats.py [options]
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--session PATH Path to a single session JSONL (default: latest)
|
|
16
|
+
--project-dir PATH Look only in this project's session dir
|
|
17
|
+
--claude-dir PATH Override ~/.claude (for testing)
|
|
18
|
+
--since DURATION Filter to messages newer than 7d|24h|30m
|
|
19
|
+
--json Emit JSON instead of human-readable text
|
|
20
|
+
--statusline One-line statusline-friendly output
|
|
21
|
+
--baseline FILE Compare current session vs a baseline JSON file
|
|
22
|
+
|
|
23
|
+
Exit codes:
|
|
24
|
+
0 success
|
|
25
|
+
1 no sessions found
|
|
26
|
+
2 invalid arguments or filesystem error
|
|
27
|
+
|
|
28
|
+
Stdlib-only. Designed to run in the statusline hot path (<50ms target).
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
from datetime import datetime, timedelta, timezone
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Iterator
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
DEFAULT_CLAUDE_DIR = Path.home() / ".claude"
|
|
43
|
+
DURATION_RE = re.compile(r"^(\d+)([smhd])$")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_duration(value: str) -> timedelta:
|
|
47
|
+
"""Parse 7d, 24h, 30m, 90s into a timedelta. Raises ValueError on bad input."""
|
|
48
|
+
match = DURATION_RE.match(value)
|
|
49
|
+
if not match:
|
|
50
|
+
raise ValueError(f"invalid duration: {value!r}; expected e.g. 7d, 24h, 30m")
|
|
51
|
+
n = int(match.group(1))
|
|
52
|
+
unit = match.group(2)
|
|
53
|
+
if unit == "s":
|
|
54
|
+
return timedelta(seconds=n)
|
|
55
|
+
if unit == "m":
|
|
56
|
+
return timedelta(minutes=n)
|
|
57
|
+
if unit == "h":
|
|
58
|
+
return timedelta(hours=n)
|
|
59
|
+
return timedelta(days=n)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def sanitize_cwd(cwd: str) -> str:
|
|
63
|
+
"""Claude Code mangles `/` to `-` and prefixes with `-` for project dir name."""
|
|
64
|
+
return "-" + cwd.replace("/", "-").lstrip("-")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def find_project_dir(claude_dir: Path, cwd: str | None = None) -> Path | None:
|
|
68
|
+
"""Locate the project sessions directory for cwd, or None if missing."""
|
|
69
|
+
projects_root = claude_dir / "projects"
|
|
70
|
+
if not projects_root.is_dir():
|
|
71
|
+
return None
|
|
72
|
+
if cwd:
|
|
73
|
+
candidate = projects_root / sanitize_cwd(cwd)
|
|
74
|
+
return candidate if candidate.is_dir() else None
|
|
75
|
+
return projects_root
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_latest_session(search_dir: Path) -> Path | None:
|
|
79
|
+
"""Return the most-recently-modified .jsonl under search_dir, or None."""
|
|
80
|
+
if not search_dir.is_dir():
|
|
81
|
+
return None
|
|
82
|
+
candidates = list(search_dir.rglob("*.jsonl"))
|
|
83
|
+
if not candidates:
|
|
84
|
+
return None
|
|
85
|
+
return max(candidates, key=lambda p: p.stat().st_mtime)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def iter_messages(session_file: Path) -> Iterator[dict[str, Any]]:
|
|
89
|
+
"""Yield parsed JSON messages from a session file. Skips malformed lines.
|
|
90
|
+
|
|
91
|
+
Lines without a `usage` field are skipped without parsing — most lines in a
|
|
92
|
+
Claude Code session JSONL are user inputs, tool results, etc. that have no
|
|
93
|
+
token cost. Filtering at the substring level avoids json.loads on ~80% of
|
|
94
|
+
the file and cuts parse time roughly in half on long sessions.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
with open(session_file, encoding="utf-8") as f:
|
|
98
|
+
for line in f:
|
|
99
|
+
line = line.strip()
|
|
100
|
+
if not line or '"usage"' not in line:
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
yield json.loads(line)
|
|
104
|
+
except json.JSONDecodeError:
|
|
105
|
+
continue
|
|
106
|
+
except OSError:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def extract_usage(message: dict[str, Any]) -> dict[str, int] | None:
|
|
111
|
+
"""Pull usage block from a message, normalizing field names. Returns None if absent."""
|
|
112
|
+
usage = None
|
|
113
|
+
if isinstance(message.get("message"), dict):
|
|
114
|
+
usage = message["message"].get("usage")
|
|
115
|
+
if usage is None:
|
|
116
|
+
usage = message.get("usage")
|
|
117
|
+
if not isinstance(usage, dict):
|
|
118
|
+
return None
|
|
119
|
+
return {
|
|
120
|
+
"input": int(usage.get("input_tokens") or 0),
|
|
121
|
+
"output": int(usage.get("output_tokens") or 0),
|
|
122
|
+
"cache_create": int(usage.get("cache_creation_input_tokens") or 0),
|
|
123
|
+
"cache_read": int(usage.get("cache_read_input_tokens") or 0),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def message_timestamp(message: dict[str, Any]) -> datetime | None:
|
|
128
|
+
"""Extract message timestamp. Tries common field names."""
|
|
129
|
+
for key in ("timestamp", "created_at", "time"):
|
|
130
|
+
value = message.get(key)
|
|
131
|
+
if isinstance(value, str):
|
|
132
|
+
try:
|
|
133
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
134
|
+
except ValueError:
|
|
135
|
+
continue
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def aggregate(
|
|
140
|
+
session_file: Path,
|
|
141
|
+
since: timedelta | None = None,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""Sum token usage across a session, optionally filtered by recency."""
|
|
144
|
+
cutoff = datetime.now(timezone.utc) - since if since else None
|
|
145
|
+
totals = {"input": 0, "output": 0, "cache_create": 0, "cache_read": 0}
|
|
146
|
+
message_count = 0
|
|
147
|
+
counted = 0
|
|
148
|
+
|
|
149
|
+
for message in iter_messages(session_file):
|
|
150
|
+
message_count += 1
|
|
151
|
+
if cutoff is not None:
|
|
152
|
+
ts = message_timestamp(message)
|
|
153
|
+
if ts is None or ts < cutoff:
|
|
154
|
+
continue
|
|
155
|
+
usage = extract_usage(message)
|
|
156
|
+
if usage is None:
|
|
157
|
+
continue
|
|
158
|
+
for key, value in usage.items():
|
|
159
|
+
totals[key] += value
|
|
160
|
+
counted += 1
|
|
161
|
+
|
|
162
|
+
totals["total"] = totals["input"] + totals["output"]
|
|
163
|
+
totals["total_with_cache"] = (
|
|
164
|
+
totals["total"] + totals["cache_create"] + totals["cache_read"]
|
|
165
|
+
)
|
|
166
|
+
# novel_input = tokens the model genuinely processed afresh (literal input
|
|
167
|
+
# + freshly cached prompt prefix). Excludes cache_read which is recycled.
|
|
168
|
+
# Used by statusline to render "i/o:novel_input/output".
|
|
169
|
+
totals["novel_input"] = totals["input"] + totals["cache_create"]
|
|
170
|
+
totals["displayed_total"] = totals["novel_input"] + totals["output"]
|
|
171
|
+
totals["messages_total"] = message_count
|
|
172
|
+
totals["messages_counted"] = counted
|
|
173
|
+
return totals
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def format_human(totals: dict[str, Any], session_file: Path) -> str:
|
|
177
|
+
lines = [
|
|
178
|
+
f"session: {session_file.name}",
|
|
179
|
+
f" input {totals['input']:>10,}",
|
|
180
|
+
f" output {totals['output']:>10,}",
|
|
181
|
+
f" cache_create {totals['cache_create']:>10,}",
|
|
182
|
+
f" cache_read {totals['cache_read']:>10,}",
|
|
183
|
+
f" total {totals['total']:>10,}",
|
|
184
|
+
f" with cache {totals['total_with_cache']:>10,}",
|
|
185
|
+
f" messages {totals['messages_counted']}/{totals['messages_total']}",
|
|
186
|
+
]
|
|
187
|
+
return "\n".join(lines)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def format_statusline(totals: dict[str, Any], baseline: dict[str, Any] | None) -> str:
|
|
191
|
+
total = totals["total"]
|
|
192
|
+
if total >= 1000:
|
|
193
|
+
rendered = f"{total / 1000:.1f}k"
|
|
194
|
+
else:
|
|
195
|
+
rendered = str(total)
|
|
196
|
+
output = f"[ai-toolkit] session: {rendered}"
|
|
197
|
+
if baseline:
|
|
198
|
+
baseline_total = baseline.get("total")
|
|
199
|
+
if isinstance(baseline_total, int) and baseline_total > 0:
|
|
200
|
+
delta = (total - baseline_total) / baseline_total
|
|
201
|
+
arrow = "↓" if delta < 0 else "↑" if delta > 0 else "·"
|
|
202
|
+
output += f" · trend: {arrow}{abs(delta) * 100:.0f}%"
|
|
203
|
+
return output
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def main() -> int:
|
|
207
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
208
|
+
parser.add_argument("--session", type=Path, help="Path to a session JSONL")
|
|
209
|
+
parser.add_argument("--project-dir", type=Path, help="Look only in this project's session dir")
|
|
210
|
+
parser.add_argument("--claude-dir", type=Path, default=DEFAULT_CLAUDE_DIR)
|
|
211
|
+
parser.add_argument("--cwd", type=str, default=os.getcwd())
|
|
212
|
+
parser.add_argument("--since", type=str)
|
|
213
|
+
parser.add_argument("--json", action="store_true")
|
|
214
|
+
parser.add_argument("--statusline", action="store_true")
|
|
215
|
+
parser.add_argument("--baseline", type=Path, help="JSON baseline for trend comparison")
|
|
216
|
+
args = parser.parse_args()
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
since = parse_duration(args.since) if args.since else None
|
|
220
|
+
except ValueError as exc:
|
|
221
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
222
|
+
return 2
|
|
223
|
+
|
|
224
|
+
if args.session:
|
|
225
|
+
session_file: Path | None = args.session
|
|
226
|
+
elif args.project_dir:
|
|
227
|
+
session_file = find_latest_session(args.project_dir)
|
|
228
|
+
else:
|
|
229
|
+
project_dir = find_project_dir(args.claude_dir, args.cwd)
|
|
230
|
+
if project_dir is None:
|
|
231
|
+
print("error: no Claude Code project directory found", file=sys.stderr)
|
|
232
|
+
return 1
|
|
233
|
+
session_file = find_latest_session(project_dir)
|
|
234
|
+
|
|
235
|
+
if not session_file or not session_file.is_file():
|
|
236
|
+
print("error: no session file found", file=sys.stderr)
|
|
237
|
+
return 1
|
|
238
|
+
|
|
239
|
+
totals = aggregate(session_file, since=since)
|
|
240
|
+
|
|
241
|
+
baseline = None
|
|
242
|
+
if args.baseline and args.baseline.is_file():
|
|
243
|
+
try:
|
|
244
|
+
baseline = json.loads(args.baseline.read_text())
|
|
245
|
+
except (json.JSONDecodeError, OSError):
|
|
246
|
+
baseline = None
|
|
247
|
+
|
|
248
|
+
if args.statusline:
|
|
249
|
+
print(format_statusline(totals, baseline))
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
if args.json:
|
|
253
|
+
payload = {"session": str(session_file), "totals": totals}
|
|
254
|
+
if baseline:
|
|
255
|
+
payload["baseline"] = baseline
|
|
256
|
+
print(json.dumps(payload, indent=2))
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
print(format_human(totals, session_file))
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
sys.exit(main())
|