@tekyzinc/gsd-t 3.16.12 → 3.18.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/README.md +13 -3
- package/bin/gsd-t-depgraph-validate.cjs +140 -0
- package/bin/gsd-t-economics.cjs +287 -0
- package/bin/gsd-t-file-disjointness.cjs +227 -0
- package/bin/gsd-t-in-session-usage.cjs +213 -0
- package/bin/gsd-t-orchestrator-config.cjs +100 -3
- package/bin/gsd-t-orchestrator.js +2 -1
- package/bin/gsd-t-parallel.cjs +382 -0
- package/bin/gsd-t-report-tokens.cjs +549 -0
- package/bin/gsd-t-task-graph.cjs +366 -0
- package/bin/gsd-t-token-capture.cjs +29 -14
- package/bin/gsd-t-token-dashboard.cjs +35 -0
- package/bin/gsd-t-tool-attribution.cjs +377 -0
- package/bin/gsd-t-tool-cost.cjs +195 -0
- package/bin/gsd-t-unattended-platform.cjs +7 -1
- package/bin/gsd-t-unattended.cjs +2 -0
- package/bin/gsd-t.js +155 -5
- package/bin/headless-auto-spawn.cjs +69 -49
- package/bin/headless-auto-spawn.js +18 -24
- package/bin/runway-estimator.cjs +212 -0
- package/bin/spawn-plan-derive.cjs +163 -0
- package/bin/spawn-plan-status-updater.cjs +292 -0
- package/bin/spawn-plan-writer.cjs +204 -0
- package/commands/gsd-t-debug.md +26 -7
- package/commands/gsd-t-execute.md +36 -28
- package/commands/gsd-t-help.md +11 -0
- package/commands/gsd-t-integrate.md +27 -7
- package/commands/gsd-t-quick.md +30 -13
- package/commands/gsd-t-scan.md +5 -5
- package/commands/gsd-t-unattended-watch.md +4 -3
- package/commands/gsd-t-unattended.md +9 -3
- package/commands/gsd-t-verify.md +5 -5
- package/commands/gsd-t-wave.md +21 -8
- package/commands/gsd.md +45 -3
- package/docs/GSD-T-README.md +43 -5
- package/docs/architecture.md +423 -3
- package/docs/requirements.md +203 -0
- package/package.json +1 -1
- package/scripts/gsd-t-calibration-hook.js +256 -0
- package/scripts/gsd-t-compact-detector.js +223 -0
- package/scripts/gsd-t-compaction-scanner.js +305 -0
- package/scripts/gsd-t-dashboard-autostart.cjs +172 -0
- package/scripts/gsd-t-dashboard-server.js +179 -0
- package/scripts/gsd-t-dashboard.html +3 -3
- package/scripts/gsd-t-heartbeat.js +50 -2
- package/scripts/gsd-t-post-commit-spawn-plan.sh +86 -0
- package/scripts/gsd-t-transcript.html +546 -43
- package/scripts/hooks/gsd-t-in-session-usage-hook.js +84 -0
- package/scripts/spawn-plan-fmt-tokens.cjs +80 -0
- package/templates/CLAUDE-global.md +8 -3
- package/templates/CLAUDE-project.md +17 -14
- package/templates/hooks/post-commit-spawn-plan.sh +85 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* GSD-T Token-Usage Optimization Report Generator (M44)
|
|
4
|
+
*
|
|
5
|
+
* Emits a markdown report at `.gsd-t/reports/token-usage-{YYYY-MM-DD}.md`,
|
|
6
|
+
* organized per the canonical 5-level hierarchy:
|
|
7
|
+
*
|
|
8
|
+
* Run → Iter → Context Window (CW) → Turn → Tool
|
|
9
|
+
*
|
|
10
|
+
* CW is the primary optimization unit (peer of Iter, not a partition of it).
|
|
11
|
+
* The goal is to answer: "where am I burning tokens, and which CWs am I
|
|
12
|
+
* getting full value out of vs. wasting?"
|
|
13
|
+
*
|
|
14
|
+
* Data sources (all read-only):
|
|
15
|
+
* - `.gsd-t/metrics/token-usage.jsonl` — per-turn token rows (schema v2)
|
|
16
|
+
* - `.gsd-t/metrics/compactions.jsonl` — compaction events (live + backfill)
|
|
17
|
+
* - `.gsd-t/events/YYYY-MM-DD.jsonl` — tool_call events (for Section B)
|
|
18
|
+
*
|
|
19
|
+
* No cost columns (user is on Max subscription; tokens are the budget proxy).
|
|
20
|
+
* V1 markdown only — no HTML, no JSON dump, no dashboard widget.
|
|
21
|
+
*
|
|
22
|
+
* Zero external deps. `.cjs` for ESM/CJS compat.
|
|
23
|
+
*
|
|
24
|
+
* Exports:
|
|
25
|
+
* - generateReport({ projectDir, outPath?, date? }) → { path, summary }
|
|
26
|
+
* - groupIntoCWs({ turnRows, compactionRows, sessionIds? }) → Array<CW>
|
|
27
|
+
* - rollupCW(cw) → CW rollup
|
|
28
|
+
* - topNExpensiveTurns(turnRows, n = 20) → Array<Turn>
|
|
29
|
+
* - groupCompactionEvents(compactionRows, turnRows) → Array<CompactionRow>
|
|
30
|
+
* - renderMarkdown({ cws, toolRollup, topTurns, compactions, meta }) → string
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
|
|
36
|
+
const attribution = require('./gsd-t-tool-attribution.cjs');
|
|
37
|
+
|
|
38
|
+
// ── JSONL helpers ────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function _safeParse(line) {
|
|
41
|
+
const s = (line || '').trim();
|
|
42
|
+
if (!s || s[0] !== '{') return null;
|
|
43
|
+
try { return JSON.parse(s); } catch (_) { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _readJsonl(p) {
|
|
47
|
+
if (!p || !fs.existsSync(p)) return [];
|
|
48
|
+
const text = fs.readFileSync(p, 'utf8');
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const line of text.split('\n')) {
|
|
51
|
+
const j = _safeParse(line);
|
|
52
|
+
if (j) out.push(j);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function _parseMs(s) {
|
|
58
|
+
if (!s) return NaN;
|
|
59
|
+
if (/\dT\d/.test(s)) return Date.parse(s);
|
|
60
|
+
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(s)) return Date.parse(s.replace(' ', 'T') + ':00');
|
|
61
|
+
const p = Date.parse(s);
|
|
62
|
+
return Number.isFinite(p) ? p : NaN;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _shortSid(sid) {
|
|
66
|
+
return String(sid || '').slice(0, 8);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _fmtNum(n) {
|
|
70
|
+
if (n == null || !Number.isFinite(Number(n))) return '—';
|
|
71
|
+
const v = Math.round(Number(n));
|
|
72
|
+
return v.toLocaleString('en-US');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _fmtFloat(n, digits = 1) {
|
|
76
|
+
if (n == null || !Number.isFinite(Number(n))) return '—';
|
|
77
|
+
return Number(n).toFixed(digits);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── CW grouping ──────────────────────────────────────────────────────
|
|
81
|
+
//
|
|
82
|
+
// Rule (V1):
|
|
83
|
+
// - Each distinct session_id with ≥1 turn row = 1 CW.
|
|
84
|
+
// - A compaction row `{session_id: Y, prior_session_id: X}` means CW X
|
|
85
|
+
// ended (got compacted) and CW Y began. If X has turn rows, we mark
|
|
86
|
+
// X's endedBy as 'compaction (auto|manual)'. Otherwise ignored.
|
|
87
|
+
// - A CW with no matching compaction on `prior_session_id = CW.sid`
|
|
88
|
+
// ended by 'iter end' (if a later CW exists by sort order) or
|
|
89
|
+
// 'run end' (if it's the last CW in the data).
|
|
90
|
+
// - Dedup compactions by (ts, session_id, prior_session_id) to collapse
|
|
91
|
+
// any overlap between live `source=compact` and historical `compact-backfill`.
|
|
92
|
+
|
|
93
|
+
function groupIntoCWs({ turnRows, compactionRows, sessionIds }) {
|
|
94
|
+
const turns = Array.isArray(turnRows) ? turnRows : [];
|
|
95
|
+
const comps = Array.isArray(compactionRows) ? compactionRows : [];
|
|
96
|
+
|
|
97
|
+
// Bucket turns by session_id. Skip rows with no session_id.
|
|
98
|
+
const bySid = new Map();
|
|
99
|
+
for (const r of turns) {
|
|
100
|
+
const sid = r.session_id;
|
|
101
|
+
if (!sid) continue;
|
|
102
|
+
if (!bySid.has(sid)) bySid.set(sid, []);
|
|
103
|
+
bySid.get(sid).push({ ...r, _ms: _parseMs(r.ts || r.startedAt) });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Optional filter: narrow to a specified sessionIds set.
|
|
107
|
+
let sids = [...bySid.keys()];
|
|
108
|
+
if (Array.isArray(sessionIds) && sessionIds.length) {
|
|
109
|
+
const allow = new Set(sessionIds);
|
|
110
|
+
sids = sids.filter((s) => allow.has(s));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Dedup compactions by (ts|session_id|prior_session_id). Live wins over
|
|
114
|
+
// backfill if both present for the same tuple.
|
|
115
|
+
const compMap = new Map();
|
|
116
|
+
for (const c of comps) {
|
|
117
|
+
const key = `${c.ts || ''}|${c.session_id || ''}|${c.prior_session_id || ''}`;
|
|
118
|
+
const prev = compMap.get(key);
|
|
119
|
+
if (!prev) { compMap.set(key, c); continue; }
|
|
120
|
+
// Prefer live (source=compact) over backfill.
|
|
121
|
+
if (prev.source === 'compact-backfill' && c.source === 'compact') compMap.set(key, c);
|
|
122
|
+
}
|
|
123
|
+
const compsDedup = [...compMap.values()];
|
|
124
|
+
|
|
125
|
+
// Index compactions by prior_session_id — that's "the CW this compaction ended."
|
|
126
|
+
const compByPrior = new Map();
|
|
127
|
+
for (const c of compsDedup) {
|
|
128
|
+
const pid = c.prior_session_id;
|
|
129
|
+
if (!pid) continue;
|
|
130
|
+
if (!compByPrior.has(pid)) compByPrior.set(pid, []);
|
|
131
|
+
compByPrior.get(pid).push(c);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Sort each session's turns chronologically, then order CWs by their
|
|
135
|
+
// earliest-turn timestamp.
|
|
136
|
+
const cws = [];
|
|
137
|
+
for (const sid of sids) {
|
|
138
|
+
const trns = bySid.get(sid) || [];
|
|
139
|
+
if (!trns.length) continue;
|
|
140
|
+
trns.sort((a, b) => (a._ms || 0) - (b._ms || 0));
|
|
141
|
+
const start = trns[0]._ms;
|
|
142
|
+
const end = trns[trns.length - 1]._ms;
|
|
143
|
+
cws.push({ sid, turns: trns, start, end });
|
|
144
|
+
}
|
|
145
|
+
cws.sort((a, b) => (a.start || 0) - (b.start || 0));
|
|
146
|
+
|
|
147
|
+
// Assign endedBy per CW.
|
|
148
|
+
for (let i = 0; i < cws.length; i++) {
|
|
149
|
+
const cw = cws[i];
|
|
150
|
+
const matches = compByPrior.get(cw.sid) || [];
|
|
151
|
+
if (matches.length) {
|
|
152
|
+
// Pick the earliest compaction following this CW's last turn.
|
|
153
|
+
const afterLast = matches
|
|
154
|
+
.map((c) => ({ ...c, _ms: _parseMs(c.ts) }))
|
|
155
|
+
.filter((c) => !Number.isFinite(cw.end) || !Number.isFinite(c._ms) || c._ms >= cw.end)
|
|
156
|
+
.sort((a, b) => (a._ms || 0) - (b._ms || 0));
|
|
157
|
+
const chosen = afterLast[0] || matches[0];
|
|
158
|
+
const trig = chosen.trigger || 'auto';
|
|
159
|
+
cw.endedBy = trig === 'manual' ? 'compaction (manual)' : 'compaction (auto)';
|
|
160
|
+
cw.endedByCompaction = chosen;
|
|
161
|
+
cw.missingTrigger = !chosen.trigger;
|
|
162
|
+
} else if (i < cws.length - 1) {
|
|
163
|
+
cw.endedBy = 'iter end';
|
|
164
|
+
} else {
|
|
165
|
+
cw.endedBy = 'run end';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return cws;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Per-CW rollup ────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
function rollupCW(cw) {
|
|
175
|
+
if (!cw) return null;
|
|
176
|
+
const trns = Array.isArray(cw.turns) ? cw.turns : [];
|
|
177
|
+
let input = 0, output = 0, cr = 0, cc = 0, peakCtxPct = null;
|
|
178
|
+
for (const t of trns) {
|
|
179
|
+
input += Number(t.inputTokens || 0);
|
|
180
|
+
output += Number(t.outputTokens || 0);
|
|
181
|
+
cr += Number(t.cacheReadInputTokens || 0);
|
|
182
|
+
cc += Number(t.cacheCreationInputTokens || 0);
|
|
183
|
+
const px = (typeof t.ctxPct === 'number' && Number.isFinite(t.ctxPct)) ? t.ctxPct : null;
|
|
184
|
+
if (px != null) {
|
|
185
|
+
if (peakCtxPct == null || px > peakCtxPct) peakCtxPct = px;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const avgOutPerTurn = trns.length ? (output / trns.length) : 0;
|
|
189
|
+
return {
|
|
190
|
+
cwId: null, // caller assigns monotonic CW# in report-time numbering
|
|
191
|
+
sid: cw.sid,
|
|
192
|
+
iter: cw.sid, // in V1 each session_id is its own iter
|
|
193
|
+
start: cw.start,
|
|
194
|
+
end: cw.end,
|
|
195
|
+
turns: trns.length,
|
|
196
|
+
input, output, cacheRead: cr, cacheCreation: cc,
|
|
197
|
+
avgOutPerTurn,
|
|
198
|
+
peakCtxPct,
|
|
199
|
+
endedBy: cw.endedBy || 'run end',
|
|
200
|
+
endedByCompaction: cw.endedByCompaction || null,
|
|
201
|
+
missingTrigger: !!cw.missingTrigger,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Top-N expensive turns ─────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
function topNExpensiveTurns(turnRows, n = 20) {
|
|
208
|
+
const rows = Array.isArray(turnRows) ? turnRows : [];
|
|
209
|
+
const ranked = rows.map((t) => {
|
|
210
|
+
const i = Number(t.inputTokens || 0);
|
|
211
|
+
const o = Number(t.outputTokens || 0);
|
|
212
|
+
return {
|
|
213
|
+
ts: t.ts || t.startedAt || null,
|
|
214
|
+
command: t.command || '—',
|
|
215
|
+
step: t.step || '—',
|
|
216
|
+
domain: t.domain || '—',
|
|
217
|
+
task: t.task || '—',
|
|
218
|
+
input: i,
|
|
219
|
+
output: o,
|
|
220
|
+
cacheRead: Number(t.cacheReadInputTokens || 0),
|
|
221
|
+
cacheCreation: Number(t.cacheCreationInputTokens || 0),
|
|
222
|
+
total: i + o,
|
|
223
|
+
ctxPct: (typeof t.ctxPct === 'number' && Number.isFinite(t.ctxPct)) ? t.ctxPct : null,
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
ranked.sort((a, b) => b.total - a.total);
|
|
227
|
+
return ranked.slice(0, Math.max(0, n | 0));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Compaction event enrichment ──────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
function groupCompactionEvents(compactionRows, turnRows) {
|
|
233
|
+
const comps = Array.isArray(compactionRows) ? compactionRows : [];
|
|
234
|
+
const turns = Array.isArray(turnRows) ? turnRows : [];
|
|
235
|
+
|
|
236
|
+
// Precompute turns sorted by ts (ascending) — used for "what was active"
|
|
237
|
+
// lookup (the last turn's command/domain/task before the compaction ts).
|
|
238
|
+
const sortedTurns = turns
|
|
239
|
+
.map((t) => ({ ...t, _ms: _parseMs(t.ts || t.startedAt) }))
|
|
240
|
+
.filter((t) => Number.isFinite(t._ms))
|
|
241
|
+
.sort((a, b) => a._ms - b._ms);
|
|
242
|
+
|
|
243
|
+
// Dedup (same key as groupIntoCWs) — report Section D shows each event once.
|
|
244
|
+
const compMap = new Map();
|
|
245
|
+
for (const c of comps) {
|
|
246
|
+
const key = `${c.ts || ''}|${c.session_id || ''}|${c.prior_session_id || ''}`;
|
|
247
|
+
const prev = compMap.get(key);
|
|
248
|
+
if (!prev) { compMap.set(key, c); continue; }
|
|
249
|
+
if (prev.source === 'compact-backfill' && c.source === 'compact') compMap.set(key, c);
|
|
250
|
+
}
|
|
251
|
+
const dedup = [...compMap.values()];
|
|
252
|
+
|
|
253
|
+
const out = dedup.map((c) => {
|
|
254
|
+
const cms = _parseMs(c.ts);
|
|
255
|
+
// Find the last turn with _ms <= cms. Because sortedTurns is ordered,
|
|
256
|
+
// a small linear scan is fine (V1 dataset is tiny).
|
|
257
|
+
let active = null;
|
|
258
|
+
if (Number.isFinite(cms)) {
|
|
259
|
+
for (let i = sortedTurns.length - 1; i >= 0; i--) {
|
|
260
|
+
if (sortedTurns[i]._ms <= cms) { active = sortedTurns[i]; break; }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
ts: c.ts || null,
|
|
265
|
+
source: c.source || null,
|
|
266
|
+
iter: _shortSid(c.prior_session_id || c.session_id),
|
|
267
|
+
priorSid: c.prior_session_id || null,
|
|
268
|
+
sid: c.session_id || null,
|
|
269
|
+
trigger: c.trigger || null,
|
|
270
|
+
preTokens: (typeof c.preTokens === 'number') ? c.preTokens : null,
|
|
271
|
+
postTokens: (typeof c.postTokens === 'number') ? c.postTokens : null,
|
|
272
|
+
durationMs: (typeof c.durationMs === 'number') ? c.durationMs : null,
|
|
273
|
+
activeCommand: active ? (active.command || '—') : '—',
|
|
274
|
+
activeDomain: active ? (active.domain || '—') : '—',
|
|
275
|
+
activeTask: active ? (active.task || '—') : '—',
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
out.sort((a, b) => String(a.ts || '').localeCompare(String(b.ts || '')));
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Markdown rendering ────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
function _section(title) { return `\n## ${title}\n\n`; }
|
|
285
|
+
|
|
286
|
+
function _fmtDate(ms) {
|
|
287
|
+
if (!Number.isFinite(ms)) return '—';
|
|
288
|
+
const d = new Date(ms);
|
|
289
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
290
|
+
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function renderMarkdown({ cws, toolRollup, topTurns, compactions, meta }) {
|
|
294
|
+
const m = meta || {};
|
|
295
|
+
const lines = [];
|
|
296
|
+
|
|
297
|
+
// ── Header ────────────────────────────────────────────────────────
|
|
298
|
+
lines.push(`# Token Usage Optimization Report — ${m.date || 'unknown'}`);
|
|
299
|
+
lines.push('');
|
|
300
|
+
lines.push('Run → Iter → **CW** → Turn → Tool (CW is the primary optimization unit)');
|
|
301
|
+
lines.push('');
|
|
302
|
+
lines.push(`Generated: ${m.generatedAt || new Date().toISOString()}`);
|
|
303
|
+
lines.push(`Source: ${m.turnCount || 0} turn rows, ${m.compactionCount || 0} compaction events, ${m.toolEventCount || 0} tool-call events`);
|
|
304
|
+
lines.push(`Sessions covered: ${m.sessionCount || 0}`);
|
|
305
|
+
lines.push('');
|
|
306
|
+
|
|
307
|
+
// ── Section A — Per-CW Rollup ─────────────────────────────────────
|
|
308
|
+
lines.push('## A — Per-CW Rollup (PRIMARY)');
|
|
309
|
+
lines.push('');
|
|
310
|
+
if (!cws || !cws.length) {
|
|
311
|
+
lines.push('_No context windows found — `.gsd-t/metrics/token-usage.jsonl` has no rows with a `session_id`._');
|
|
312
|
+
} else {
|
|
313
|
+
lines.push('| CW# | Iter | Start | Turns | In | Out | CacheR | CacheC | Avg Out/turn | Peak Ctx% | Ended-by |');
|
|
314
|
+
lines.push('|---|---|---|---:|---:|---:|---:|---:|---:|---:|---|');
|
|
315
|
+
let hasMissingTrig = false;
|
|
316
|
+
let totalIn = 0, totalOut = 0, totalCr = 0, totalCc = 0, totalTurns = 0;
|
|
317
|
+
let autoCount = 0;
|
|
318
|
+
cws.forEach((raw, i) => {
|
|
319
|
+
const r = rollupCW(raw);
|
|
320
|
+
if (!r) return;
|
|
321
|
+
const num = i + 1;
|
|
322
|
+
const ended = r.endedBy + (r.missingTrigger && r.endedBy.startsWith('compaction') ? ' †' : '');
|
|
323
|
+
if (r.missingTrigger && r.endedBy.startsWith('compaction')) hasMissingTrig = true;
|
|
324
|
+
if (r.endedBy === 'compaction (auto)') autoCount += 1;
|
|
325
|
+
lines.push(`| CW-${num} | ${_shortSid(r.iter)} | ${_fmtDate(r.start)} | ${r.turns} | ${_fmtNum(r.input)} | ${_fmtNum(r.output)} | ${_fmtNum(r.cacheRead)} | ${_fmtNum(r.cacheCreation)} | ${_fmtFloat(r.avgOutPerTurn, 0)} | ${r.peakCtxPct == null ? '—' : _fmtFloat(r.peakCtxPct, 1)} | ${ended} |`);
|
|
326
|
+
totalIn += r.input; totalOut += r.output; totalCr += r.cacheRead; totalCc += r.cacheCreation; totalTurns += r.turns;
|
|
327
|
+
});
|
|
328
|
+
lines.push('');
|
|
329
|
+
lines.push(`**Total across ${cws.length} CW${cws.length === 1 ? '' : 's'}**: in=${_fmtNum(totalIn)} out=${_fmtNum(totalOut)} cacheR=${_fmtNum(totalCr)} cacheC=${_fmtNum(totalCc)} turns=${totalTurns}`);
|
|
330
|
+
lines.push(`**Average turns per CW**: ${_fmtFloat(totalTurns / cws.length, 1)}`);
|
|
331
|
+
lines.push(`**Compaction rate**: ${autoCount}/${cws.length} CW${cws.length === 1 ? '' : 's'} ended by auto-compaction`);
|
|
332
|
+
if (hasMissingTrig) {
|
|
333
|
+
lines.push('');
|
|
334
|
+
lines.push('† Compaction had no `trigger` field in the source row — mapped to `compaction (auto)` as the safe default.');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
lines.push('');
|
|
338
|
+
|
|
339
|
+
// ── Section B — Tool-Tokens Rollup ────────────────────────────────
|
|
340
|
+
lines.push('## B — Tool-Tokens Rollup');
|
|
341
|
+
lines.push('');
|
|
342
|
+
if (!toolRollup || toolRollup.unavailable) {
|
|
343
|
+
lines.push(`_Tool attribution unavailable: ${toolRollup && toolRollup.reason ? toolRollup.reason : 'no joined rows'}._`);
|
|
344
|
+
} else {
|
|
345
|
+
// B.1 — By tool
|
|
346
|
+
lines.push('### B.1 — By tool');
|
|
347
|
+
lines.push('');
|
|
348
|
+
lines.push('| Tool | Calls | In | Out | CacheR | CacheC | Avg tokens/call |');
|
|
349
|
+
lines.push('|---|---:|---:|---:|---:|---:|---:|');
|
|
350
|
+
for (const row of toolRollup.byTool) {
|
|
351
|
+
const total = row.total_input + row.total_output + row.total_cache_read + row.total_cache_creation;
|
|
352
|
+
const avg = row.turn_count ? total / row.turn_count : 0;
|
|
353
|
+
lines.push(`| ${row.key} | ${row.turn_count} | ${_fmtNum(row.total_input)} | ${_fmtNum(row.total_output)} | ${_fmtNum(row.total_cache_read)} | ${_fmtNum(row.total_cache_creation)} | ${_fmtNum(avg)} |`);
|
|
354
|
+
}
|
|
355
|
+
lines.push('');
|
|
356
|
+
|
|
357
|
+
// B.2 — Tool × Command cross-tab
|
|
358
|
+
lines.push('### B.2 — Tool × Command (top 10 tools × top 5 commands, total tokens)');
|
|
359
|
+
lines.push('');
|
|
360
|
+
const grid = toolRollup.toolByCommand || {};
|
|
361
|
+
const topTools = (toolRollup.topTools || []).slice(0, 10);
|
|
362
|
+
const topCmds = (toolRollup.topCommands || []).slice(0, 5);
|
|
363
|
+
if (!topTools.length || !topCmds.length) {
|
|
364
|
+
lines.push('_Not enough data for a cross-tab (need ≥1 tool and ≥1 command)._');
|
|
365
|
+
} else {
|
|
366
|
+
lines.push(`| Tool ╲ Command | ${topCmds.join(' | ')} |`);
|
|
367
|
+
lines.push(`|---|${topCmds.map(() => '---:').join('|')}|`);
|
|
368
|
+
for (const tool of topTools) {
|
|
369
|
+
const cells = topCmds.map((cmd) => {
|
|
370
|
+
const v = grid[tool] && grid[tool][cmd];
|
|
371
|
+
return v ? _fmtNum(v) : '—';
|
|
372
|
+
});
|
|
373
|
+
lines.push(`| ${tool} | ${cells.join(' | ')} |`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
lines.push('');
|
|
378
|
+
|
|
379
|
+
// ── Section C — Top 20 Expensive Turns ────────────────────────────
|
|
380
|
+
lines.push('## C — Top 20 Expensive Turns');
|
|
381
|
+
lines.push('');
|
|
382
|
+
lines.push('Ranked by `input + output` tokens descending. _Input-heavy_ rows signal context bloat; _output-heavy_ rows signal generation cost. Both columns visible so the reader can re-sort mentally.');
|
|
383
|
+
lines.push('');
|
|
384
|
+
if (!topTurns || !topTurns.length) {
|
|
385
|
+
lines.push('_No turn rows to rank._');
|
|
386
|
+
} else {
|
|
387
|
+
lines.push('| # | ts | Command | Step | Domain | Task | In | Out | CacheR | CacheC | Total | Ctx% |');
|
|
388
|
+
lines.push('|---:|---|---|---|---|---|---:|---:|---:|---:|---:|---:|');
|
|
389
|
+
topTurns.forEach((t, i) => {
|
|
390
|
+
lines.push(`| ${i+1} | ${t.ts || '—'} | ${t.command} | ${t.step} | ${t.domain} | ${t.task} | ${_fmtNum(t.input)} | ${_fmtNum(t.output)} | ${_fmtNum(t.cacheRead)} | ${_fmtNum(t.cacheCreation)} | ${_fmtNum(t.total)} | ${t.ctxPct == null ? '—' : _fmtFloat(t.ctxPct, 1)} |`);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
lines.push('');
|
|
394
|
+
|
|
395
|
+
// ── Section D — Compaction Events ─────────────────────────────────
|
|
396
|
+
lines.push('## D — Compaction Events');
|
|
397
|
+
lines.push('');
|
|
398
|
+
if (!compactions || !compactions.length) {
|
|
399
|
+
lines.push('_No compaction events recorded (hook not yet capturing, or no compactions during window)._');
|
|
400
|
+
} else {
|
|
401
|
+
lines.push('| ts | Source | Iter (prior) | Trigger | Pre-tokens | Post-tokens | Duration (ms) | Active Command | Active Domain/Task |');
|
|
402
|
+
lines.push('|---|---|---|---|---:|---:|---:|---|---|');
|
|
403
|
+
let autoC = 0, manC = 0, bfC = 0;
|
|
404
|
+
for (const c of compactions) {
|
|
405
|
+
if (c.source === 'compact-backfill') bfC += 1;
|
|
406
|
+
if (c.trigger === 'manual') manC += 1; else autoC += 1;
|
|
407
|
+
const dt = `${c.activeDomain}/${c.activeTask}`;
|
|
408
|
+
lines.push(`| ${c.ts || '—'} | ${c.source || '—'} | ${c.iter || '—'} | ${c.trigger || '—'} | ${_fmtNum(c.preTokens)} | ${_fmtNum(c.postTokens)} | ${_fmtNum(c.durationMs)} | ${c.activeCommand} | ${dt} |`);
|
|
409
|
+
}
|
|
410
|
+
lines.push('');
|
|
411
|
+
lines.push(`**Summary**: ${autoC} auto, ${manC} manual, ${bfC} backfilled.`);
|
|
412
|
+
}
|
|
413
|
+
lines.push('');
|
|
414
|
+
|
|
415
|
+
return lines.join('\n');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── Tool rollup preparation ───────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
function _prepareToolRollup({ projectDir }) {
|
|
421
|
+
const turnsPath = path.join(projectDir, '.gsd-t', 'metrics', 'token-usage.jsonl');
|
|
422
|
+
const eventsDir = path.join(projectDir, '.gsd-t', 'events');
|
|
423
|
+
if (!fs.existsSync(turnsPath)) {
|
|
424
|
+
return { unavailable: true, reason: 'token-usage.jsonl not found' };
|
|
425
|
+
}
|
|
426
|
+
let joined;
|
|
427
|
+
try {
|
|
428
|
+
joined = attribution.joinTurnsAndEvents({ turnsPath, eventsGlob: eventsDir });
|
|
429
|
+
} catch (e) {
|
|
430
|
+
return { unavailable: true, reason: e.message || String(e) };
|
|
431
|
+
}
|
|
432
|
+
if (!joined || !joined.length) {
|
|
433
|
+
return { unavailable: true, reason: 'no joined turn/event rows' };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const byTool = attribution.aggregateByTool(joined);
|
|
437
|
+
if (!byTool.length) return { unavailable: true, reason: 'aggregateByTool returned 0 rows' };
|
|
438
|
+
|
|
439
|
+
// Build tool × command cross-tab.
|
|
440
|
+
const toolByCommand = {};
|
|
441
|
+
const toolTotals = new Map();
|
|
442
|
+
const cmdTotals = new Map();
|
|
443
|
+
for (const turn of joined) {
|
|
444
|
+
const attr = attribution.attributeTurn(turn);
|
|
445
|
+
const cmd = (turn.command || 'unknown');
|
|
446
|
+
for (const a of attr.attributions) {
|
|
447
|
+
const tool = a.tool_name || 'unknown';
|
|
448
|
+
const total = Number(a.input_tokens_share || 0)
|
|
449
|
+
+ Number(a.output_tokens_share || 0)
|
|
450
|
+
+ Number(a.cache_read_share || 0)
|
|
451
|
+
+ Number(a.cache_creation_share || 0);
|
|
452
|
+
if (!toolByCommand[tool]) toolByCommand[tool] = {};
|
|
453
|
+
toolByCommand[tool][cmd] = (toolByCommand[tool][cmd] || 0) + total;
|
|
454
|
+
toolTotals.set(tool, (toolTotals.get(tool) || 0) + total);
|
|
455
|
+
cmdTotals.set(cmd, (cmdTotals.get(cmd) || 0) + total);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const topTools = [...toolTotals.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k);
|
|
459
|
+
const topCommands = [...cmdTotals.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k);
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
unavailable: false,
|
|
463
|
+
byTool,
|
|
464
|
+
toolByCommand,
|
|
465
|
+
topTools,
|
|
466
|
+
topCommands,
|
|
467
|
+
joinedCount: joined.length,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── Main entry ────────────────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
function generateReport({ projectDir, outPath, date }) {
|
|
474
|
+
if (!projectDir) projectDir = process.cwd();
|
|
475
|
+
const today = date || new Date().toISOString().slice(0, 10);
|
|
476
|
+
|
|
477
|
+
const turnsPath = path.join(projectDir, '.gsd-t', 'metrics', 'token-usage.jsonl');
|
|
478
|
+
const compPath = path.join(projectDir, '.gsd-t', 'metrics', 'compactions.jsonl');
|
|
479
|
+
const eventsDir = path.join(projectDir, '.gsd-t', 'events');
|
|
480
|
+
|
|
481
|
+
const turnRows = _readJsonl(turnsPath);
|
|
482
|
+
const compRows = _readJsonl(compPath);
|
|
483
|
+
|
|
484
|
+
// Count tool-call events across all per-day files (approximation — informational).
|
|
485
|
+
let toolEventCount = 0;
|
|
486
|
+
if (fs.existsSync(eventsDir) && fs.statSync(eventsDir).isDirectory()) {
|
|
487
|
+
for (const f of fs.readdirSync(eventsDir)) {
|
|
488
|
+
if (!/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)) continue;
|
|
489
|
+
try {
|
|
490
|
+
const txt = fs.readFileSync(path.join(eventsDir, f), 'utf8');
|
|
491
|
+
for (const line of txt.split('\n')) {
|
|
492
|
+
const j = _safeParse(line);
|
|
493
|
+
if (j && j.event_type === 'tool_call') toolEventCount += 1;
|
|
494
|
+
}
|
|
495
|
+
} catch (_) { /* best effort */ }
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const cws = groupIntoCWs({ turnRows, compactionRows: compRows });
|
|
500
|
+
const topTurns = topNExpensiveTurns(turnRows, 20);
|
|
501
|
+
const compactions = groupCompactionEvents(compRows, turnRows);
|
|
502
|
+
const toolRollup = _prepareToolRollup({ projectDir });
|
|
503
|
+
|
|
504
|
+
const sessionIds = new Set();
|
|
505
|
+
for (const r of turnRows) if (r.session_id) sessionIds.add(r.session_id);
|
|
506
|
+
|
|
507
|
+
const md = renderMarkdown({
|
|
508
|
+
cws, toolRollup, topTurns, compactions,
|
|
509
|
+
meta: {
|
|
510
|
+
date: today,
|
|
511
|
+
generatedAt: new Date().toISOString(),
|
|
512
|
+
turnCount: turnRows.length,
|
|
513
|
+
compactionCount: compRows.length,
|
|
514
|
+
toolEventCount,
|
|
515
|
+
sessionCount: sessionIds.size,
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const reportsDir = path.join(projectDir, '.gsd-t', 'reports');
|
|
520
|
+
if (!fs.existsSync(reportsDir)) fs.mkdirSync(reportsDir, { recursive: true });
|
|
521
|
+
const finalPath = outPath || path.join(reportsDir, `token-usage-${today}.md`);
|
|
522
|
+
fs.writeFileSync(finalPath, md, 'utf8');
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
path: finalPath,
|
|
526
|
+
summary: {
|
|
527
|
+
date: today,
|
|
528
|
+
turns: turnRows.length,
|
|
529
|
+
compactions: compRows.length,
|
|
530
|
+
toolEvents: toolEventCount,
|
|
531
|
+
sessions: sessionIds.size,
|
|
532
|
+
cws: cws.length,
|
|
533
|
+
compactionEndedCWs: cws.filter((c) => (c.endedBy || '').startsWith('compaction')).length,
|
|
534
|
+
topTool: (toolRollup && !toolRollup.unavailable && toolRollup.byTool[0]) ? toolRollup.byTool[0].key : null,
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
module.exports = {
|
|
540
|
+
generateReport,
|
|
541
|
+
groupIntoCWs,
|
|
542
|
+
rollupCW,
|
|
543
|
+
topNExpensiveTurns,
|
|
544
|
+
groupCompactionEvents,
|
|
545
|
+
renderMarkdown,
|
|
546
|
+
// exposed for tests
|
|
547
|
+
_readJsonl,
|
|
548
|
+
_parseMs,
|
|
549
|
+
};
|