swarmdo 1.29.0 → 1.30.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.
- package/.claude/helpers/auto-memory-hook.mjs +66 -33
- package/README.md +2 -2
- package/package.json +1 -1
- package/v3/@swarmdo/cli/.claude/helpers/auto-memory-hook.mjs +65 -32
- package/v3/@swarmdo/cli/dist/src/commands/usage.js +174 -2
- package/v3/@swarmdo/cli/dist/src/usage/limits.d.ts +77 -0
- package/v3/@swarmdo/cli/dist/src/usage/limits.js +134 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect-html.d.ts +23 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect-html.js +94 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +105 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect.js +165 -0
- package/v3/@swarmdo/cli/dist/src/usage/spend-forecast.d.ts +28 -0
- package/v3/@swarmdo/cli/dist/src/usage/spend-forecast.js +33 -0
- package/v3/@swarmdo/cli/dist/src/usage/transcript-errors.d.ts +14 -0
- package/v3/@swarmdo/cli/dist/src/usage/transcript-errors.js +10 -0
- package/v3/@swarmdo/cli/dist/src/util/csv.d.ts +14 -0
- package/v3/@swarmdo/cli/dist/src/util/csv.js +20 -0
- package/v3/@swarmdo/cli/package.json +1 -1
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
15
15
|
import { join, dirname } from 'path';
|
|
16
|
-
import { fileURLToPath } from 'url';
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
17
17
|
|
|
18
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
19
19
|
const __dirname = dirname(__filename);
|
|
@@ -52,13 +52,30 @@ async function gracefulExit(signal) {
|
|
|
52
52
|
process.on('SIGTERM', () => { gracefulExit('SIGTERM'); });
|
|
53
53
|
process.on('SIGINT', () => { gracefulExit('SIGINT'); });
|
|
54
54
|
|
|
55
|
-
// Ensure data dir
|
|
56
|
-
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
57
|
-
|
|
58
55
|
// ============================================================================
|
|
59
56
|
// Simple JSON File Backend (implements IMemoryBackend interface)
|
|
60
57
|
// ============================================================================
|
|
61
58
|
|
|
59
|
+
// Collapse entries that are the SAME memory — identical content signature but
|
|
60
|
+
// distinct ids — keeping the first occurrence. The auto-memory bridge
|
|
61
|
+
// historically re-imported every session with a fresh random id, bloating the
|
|
62
|
+
// store ~22x (#53); this heals already-bloated stores and de-dupes defensively.
|
|
63
|
+
// Keyed by namespace + content hash (or raw content) so genuinely distinct
|
|
64
|
+
// memories are never merged. Pure.
|
|
65
|
+
function dedupeByContentSignature(entries) {
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
const ns = (e && e.namespace) || 'default';
|
|
70
|
+
const body = (e && e.metadata && e.metadata.contentHash) || (e && (e.content || e.value)) || '';
|
|
71
|
+
const sig = ns + '\u0001' + body;
|
|
72
|
+
if (seen.has(sig)) continue;
|
|
73
|
+
seen.add(sig);
|
|
74
|
+
out.push(e);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
class JsonFileBackend {
|
|
63
80
|
constructor(filePath) {
|
|
64
81
|
this.filePath = filePath;
|
|
@@ -70,7 +87,12 @@ class JsonFileBackend {
|
|
|
70
87
|
try {
|
|
71
88
|
const data = JSON.parse(readFileSync(this.filePath, 'utf-8'));
|
|
72
89
|
if (Array.isArray(data)) {
|
|
73
|
-
|
|
90
|
+
// Collapse content-duplicates left by the pre-#53 import bug (same
|
|
91
|
+
// memory re-imported each session under a fresh id). Auto-heals an
|
|
92
|
+
// already-bloated store by rewriting it once when dupes are dropped.
|
|
93
|
+
const deduped = dedupeByContentSignature(data);
|
|
94
|
+
for (const entry of deduped) this.entries.set(entry.id, entry);
|
|
95
|
+
if (deduped.length < data.length) this._persist();
|
|
74
96
|
}
|
|
75
97
|
} catch { /* start fresh */ }
|
|
76
98
|
}
|
|
@@ -617,33 +639,44 @@ async function doImportAll() {
|
|
|
617
639
|
// Main
|
|
618
640
|
// ============================================================================
|
|
619
641
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
process.
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
642
|
+
// Only dispatch CLI commands when this file is executed directly
|
|
643
|
+
// (`node auto-memory-hook.mjs …`), NOT when it is imported by a test —
|
|
644
|
+
// importing must not run a command or exit the process (#53 regression test
|
|
645
|
+
// imports JsonFileBackend + dedupeByContentSignature below).
|
|
646
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
|
647
|
+
// Ensure data dir (only when actually running a command, not on import)
|
|
648
|
+
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
649
|
+
const command = process.argv[2] || 'status';
|
|
650
|
+
|
|
651
|
+
// Dynamic import() failures can surface as unhandled rejections on a later
|
|
652
|
+
// microtask even when the awaiting call site already caught them, which would
|
|
653
|
+
// otherwise force a non-zero exit. Swallow to keep hooks exit-0, but surface the
|
|
654
|
+
// reason under SWARMDO_DEBUG/DEBUG so genuine async bugs aren't silently hidden
|
|
655
|
+
// (FIX 2 — the previous `() => {}` discarded every rejection process-wide).
|
|
656
|
+
process.on('unhandledRejection', (reason) => {
|
|
657
|
+
if (DEBUG) {
|
|
658
|
+
const detail = reason && reason.message ? reason.message : String(reason);
|
|
659
|
+
process.stderr.write(`[AutoMemory] unhandledRejection (suppressed): ${detail}\n`);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
switch (command) {
|
|
665
|
+
case 'import': await doImport(); break;
|
|
666
|
+
case 'import-all': await doImportAll(); break;
|
|
667
|
+
case 'sync': await doSync(); break;
|
|
668
|
+
case 'status': await doStatus(); break;
|
|
669
|
+
default:
|
|
670
|
+
console.log('Usage: auto-memory-hook.mjs <import|sync|status>');
|
|
671
|
+
process.exit(1);
|
|
672
|
+
}
|
|
673
|
+
} catch (err) {
|
|
674
|
+
// Hooks must never crash Claude Code - fail silently
|
|
675
|
+
dim(`Error (non-critical): ${err.message}`);
|
|
643
676
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
dim(`Error (non-critical): ${err.message}`);
|
|
677
|
+
// Ensure clean exit for Claude Code hooks (exit 0 = success, no stderr = no error)
|
|
678
|
+
process.exit(0);
|
|
647
679
|
}
|
|
648
|
-
|
|
649
|
-
|
|
680
|
+
|
|
681
|
+
// Exported for unit tests (the run-guard above keeps import side-effect-free).
|
|
682
|
+
export { JsonFileBackend, dedupeByContentSignature };
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://swarmdo.com)
|
|
4
4
|
|
|
5
|
-
[](https://img.shields.io/badge/npx%20swarmdo-v1.30.0-cb3837?style=for-the-badge&logo=npm&logoColor=white)](https://github.com/SwarmDo/swarmdo/releases)
|
|
6
6
|
[](https://github.com/SwarmDo/swarmdo/blob/main/LICENSE)
|
|
7
7
|
[](https://swarmdo.com)
|
|
8
8
|
[](https://github.com/SwarmDo/swarmdo)
|
|
@@ -271,7 +271,7 @@ The recent release train added a full day-to-day operations layer around the swa
|
|
|
271
271
|
|
|
272
272
|
| Command | What it does |
|
|
273
273
|
|---------|-------------|
|
|
274
|
-
| `swarmdo usage` (alias `cost`) | Claude Code **spend analytics** from your local transcripts — `daily`, `monthly
|
|
274
|
+
| `swarmdo usage` (alias `cost`) | Claude Code **spend analytics** from your local transcripts — `daily`, `monthly` (with a month-end spend projection), `models`, `projects`, `sessions`, live 5-hour `blocks` burn, `errors` (tool-failure analytics), `cache` (prompt-cache efficiency + $ saved), `diff` (period-over-period comparison with per-model movers), `reflect` (a wrapped-style retrospective — busiest day, streak, top models/projects, peak hour, delegation ratio — with a shareable `--html` dashboard), and `limits` (an official-quota **exhaustion forecaster** over Claude Code's `rate_limits`); the standard views take `--csv` for spreadsheet export |
|
|
275
275
|
| `swarmdo usage guard` | **Budget policy** — limits for the active 5h block / today / month via flags or `SWARMDO_GUARD_*` env → ok / warn / over; `--strict` exits 1, safe for CI gates and Stop hooks |
|
|
276
276
|
| `swarmdo hud` | **One-screen ops HUD** — 5h block burn, task readiness, daemon workers, memory snapshots (`--watch`, `--json`) |
|
|
277
277
|
| `swarmdo repair` (alias `tdd-repair`) | **Test-Driven Repair** — a bounded, budget-capped headless `claude` loop that fixes source until a failing test passes; dry-run unless `--confirm` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "swarmdo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.0",
|
|
4
4
|
"description": "Swarmdo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
15
15
|
import { join, dirname } from 'path';
|
|
16
|
-
import { fileURLToPath } from 'url';
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
17
17
|
|
|
18
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
19
19
|
const __dirname = dirname(__filename);
|
|
@@ -52,13 +52,30 @@ async function gracefulExit(signal) {
|
|
|
52
52
|
process.on('SIGTERM', () => { gracefulExit('SIGTERM'); });
|
|
53
53
|
process.on('SIGINT', () => { gracefulExit('SIGINT'); });
|
|
54
54
|
|
|
55
|
-
// Ensure data dir
|
|
56
|
-
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
57
|
-
|
|
58
55
|
// ============================================================================
|
|
59
56
|
// Simple JSON File Backend (implements IMemoryBackend interface)
|
|
60
57
|
// ============================================================================
|
|
61
58
|
|
|
59
|
+
// Collapse entries that are the SAME memory — identical content signature but
|
|
60
|
+
// distinct ids — keeping the first occurrence. The auto-memory bridge
|
|
61
|
+
// historically re-imported every session with a fresh random id, bloating the
|
|
62
|
+
// store ~22x (#53); this heals already-bloated stores and de-dupes defensively.
|
|
63
|
+
// Keyed by namespace + content hash (or raw content) so genuinely distinct
|
|
64
|
+
// memories are never merged. Pure.
|
|
65
|
+
function dedupeByContentSignature(entries) {
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
const ns = (e && e.namespace) || 'default';
|
|
70
|
+
const body = (e && e.metadata && e.metadata.contentHash) || (e && (e.content || e.value)) || '';
|
|
71
|
+
const sig = ns + '\u0001' + body;
|
|
72
|
+
if (seen.has(sig)) continue;
|
|
73
|
+
seen.add(sig);
|
|
74
|
+
out.push(e);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
class JsonFileBackend {
|
|
63
80
|
constructor(filePath) {
|
|
64
81
|
this.filePath = filePath;
|
|
@@ -70,7 +87,12 @@ class JsonFileBackend {
|
|
|
70
87
|
try {
|
|
71
88
|
const data = JSON.parse(readFileSync(this.filePath, 'utf-8'));
|
|
72
89
|
if (Array.isArray(data)) {
|
|
73
|
-
|
|
90
|
+
// Collapse content-duplicates left by the pre-#53 import bug (same
|
|
91
|
+
// memory re-imported each session under a fresh id). Auto-heals an
|
|
92
|
+
// already-bloated store by rewriting it once when dupes are dropped.
|
|
93
|
+
const deduped = dedupeByContentSignature(data);
|
|
94
|
+
for (const entry of deduped) this.entries.set(entry.id, entry);
|
|
95
|
+
if (deduped.length < data.length) this._persist();
|
|
74
96
|
}
|
|
75
97
|
} catch { /* start fresh */ }
|
|
76
98
|
}
|
|
@@ -371,32 +393,43 @@ async function doStatus() {
|
|
|
371
393
|
// Main
|
|
372
394
|
// ============================================================================
|
|
373
395
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
process.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
+
// Only dispatch CLI commands when this file is executed directly
|
|
397
|
+
// (`node auto-memory-hook.mjs …`), NOT when it is imported by a test —
|
|
398
|
+
// importing must not run a command or exit the process (#53 regression test
|
|
399
|
+
// imports JsonFileBackend + dedupeByContentSignature below).
|
|
400
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
|
401
|
+
// Ensure data dir (only when actually running a command, not on import)
|
|
402
|
+
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
403
|
+
const command = process.argv[2] || 'status';
|
|
404
|
+
|
|
405
|
+
// Dynamic import() failures can surface as unhandled rejections on a later
|
|
406
|
+
// microtask even when the awaiting call site already caught them, which would
|
|
407
|
+
// otherwise force a non-zero exit. Swallow to keep hooks exit-0, but surface the
|
|
408
|
+
// reason under SWARMDO_DEBUG/DEBUG so genuine async bugs aren't silently hidden
|
|
409
|
+
// (FIX 2 — the previous `() => {}` discarded every rejection process-wide).
|
|
410
|
+
process.on('unhandledRejection', (reason) => {
|
|
411
|
+
if (DEBUG) {
|
|
412
|
+
const detail = reason && reason.message ? reason.message : String(reason);
|
|
413
|
+
process.stderr.write(`[AutoMemory] unhandledRejection (suppressed): ${detail}\n`);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
switch (command) {
|
|
419
|
+
case 'import': await doImport(); break;
|
|
420
|
+
case 'sync': await doSync(); break;
|
|
421
|
+
case 'status': await doStatus(); break;
|
|
422
|
+
default:
|
|
423
|
+
console.log('Usage: auto-memory-hook.mjs <import|sync|status>');
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
} catch (err) {
|
|
427
|
+
// Hooks must never crash Claude Code - fail silently
|
|
428
|
+
try { dim(`Error (non-critical): ${err.message}`); } catch (_) {}
|
|
396
429
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
try { dim(`Error (non-critical): ${err.message}`); } catch (_) {}
|
|
430
|
+
// Force clean exit — process.exitCode alone isn't enough if async errors override it
|
|
431
|
+
process.exit(0);
|
|
400
432
|
}
|
|
401
|
-
|
|
402
|
-
|
|
433
|
+
|
|
434
|
+
// Exported for unit tests (the run-guard above keeps import side-effect-free).
|
|
435
|
+
export { JsonFileBackend, dedupeByContentSignature };
|
|
@@ -14,10 +14,16 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { output } from '../output.js';
|
|
16
16
|
import { aggregateBlocks, aggregateUsage, collectUsage, totalUsage, localDateKey, } from '../usage/transcript-usage.js';
|
|
17
|
-
import { collectToolErrors } from '../usage/transcript-errors.js';
|
|
17
|
+
import { collectToolErrors, delegationFromReport } from '../usage/transcript-errors.js';
|
|
18
18
|
import { computeCacheStats } from '../usage/cache-stats.js';
|
|
19
19
|
import { evaluateGuard } from '../usage/spend-guard.js';
|
|
20
20
|
import { resolvePeriodPair, parseRange, diffPeriods, modelMovers } from '../usage/diff.js';
|
|
21
|
+
import { computeReflection, monthsBefore, hourSparkline } from '../usage/reflect.js';
|
|
22
|
+
import { renderReflectionHtml } from '../usage/reflect-html.js';
|
|
23
|
+
import { forecastWindow, parseRateLimits, worstStatus, formatForecast, formatLimitSegment } from '../usage/limits.js';
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { toCsv } from '../util/csv.js';
|
|
26
|
+
import { projectMonthEnd, daysInMonthOf } from '../usage/spend-forecast.js';
|
|
21
27
|
const VIEWS = {
|
|
22
28
|
daily: { dimension: 'day', label: 'Date' },
|
|
23
29
|
monthly: { dimension: 'month', label: 'Month' },
|
|
@@ -376,6 +382,143 @@ function runGuardView(ctx, collection) {
|
|
|
376
382
|
output.writeln(output.dim('exit 1 (--strict + over budget)'));
|
|
377
383
|
return { success: exitCode === 0, exitCode };
|
|
378
384
|
}
|
|
385
|
+
const PERIOD_MONTHS = { '1m': 1, '3m': 3, '6m': 6, '12m': 12 };
|
|
386
|
+
/** `usage reflect` — a wrapped-style retrospective over the local transcripts (#47). */
|
|
387
|
+
function runReflectView(ctx, collection) {
|
|
388
|
+
const periodFlag = (typeof ctx.flags.period === 'string' ? ctx.flags.period : '3m').toLowerCase();
|
|
389
|
+
const months = PERIOD_MONTHS[periodFlag];
|
|
390
|
+
if (!months) {
|
|
391
|
+
output.printError(`unknown --period "${periodFlag}" (expected ${Object.keys(PERIOD_MONTHS).join('|')})`);
|
|
392
|
+
return { success: false, exitCode: 1 };
|
|
393
|
+
}
|
|
394
|
+
const to = localDateKey(new Date());
|
|
395
|
+
const from = monthsBefore(to, months);
|
|
396
|
+
const dayRows = aggregateUsage(collection.events, 'day').map((r) => ({ key: r.key, totals: r.totals }));
|
|
397
|
+
const md = new Map();
|
|
398
|
+
const pd = new Map(); // per-(project, day) for top projects
|
|
399
|
+
const hourHistogram = new Array(24).fill(0); // local-hour cost, windowed to [from,to]
|
|
400
|
+
for (const e of collection.events) {
|
|
401
|
+
const k = `${e.model}\u0000${e.dateKey}`;
|
|
402
|
+
const row = md.get(k) ?? { key: e.model, day: e.dateKey, totals: { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0 } };
|
|
403
|
+
row.totals.costUsd += e.costUsd;
|
|
404
|
+
row.totals.totalTokens += e.inputTokens + e.outputTokens + e.cacheWriteTokens + e.cacheReadTokens;
|
|
405
|
+
md.set(k, row);
|
|
406
|
+
// per-(project, day) fold (space separator is safe — dateKey is fixed-width)
|
|
407
|
+
const pk = `${e.project} ${e.dateKey}`;
|
|
408
|
+
const prow = pd.get(pk) ?? { key: e.project, day: e.dateKey, totals: { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0 } };
|
|
409
|
+
prow.totals.costUsd += e.costUsd;
|
|
410
|
+
prow.totals.totalTokens += e.inputTokens + e.outputTokens + e.cacheWriteTokens + e.cacheReadTokens;
|
|
411
|
+
pd.set(pk, prow);
|
|
412
|
+
if (e.dateKey >= from && e.dateKey <= to)
|
|
413
|
+
hourHistogram[new Date(e.timestampMs).getHours()] += e.costUsd;
|
|
414
|
+
}
|
|
415
|
+
const reflection = computeReflection(dayRows, [...md.values()], from, to, {}, [...pd.values()], hourHistogram);
|
|
416
|
+
// Delegation ratio (#47): reuse the (windowed) tool-error scan to count Task
|
|
417
|
+
// (subagent) calls vs all tool calls — the parsing is already tested there.
|
|
418
|
+
const delDirFlag = ctx.flags.dir;
|
|
419
|
+
const delDirs = typeof delDirFlag === 'string' ? [delDirFlag] : Array.isArray(delDirFlag) ? delDirFlag.map(String) : undefined;
|
|
420
|
+
const delegation = delegationFromReport(collectToolErrors({ dirs: delDirs, since: from, until: to }));
|
|
421
|
+
if (ctx.flags.json === true) {
|
|
422
|
+
output.printJson({ ...reflection, delegation });
|
|
423
|
+
return { success: true, data: reflection };
|
|
424
|
+
}
|
|
425
|
+
if (ctx.flags.html === true) {
|
|
426
|
+
output.writeln(renderReflectionHtml(reflection, { generatedAt: to, delegation }));
|
|
427
|
+
return { success: true, exitCode: 0 };
|
|
428
|
+
}
|
|
429
|
+
const r = reflection;
|
|
430
|
+
const pctStr = (x) => `${Math.round(x * 100)}%`;
|
|
431
|
+
const arrow = r.trend.direction === 'up' ? '↑' : r.trend.direction === 'down' ? '↓' : '→';
|
|
432
|
+
output.writeln(output.bold(`Usage reflect ${r.period.from}..${r.period.to}`) + output.dim(` (${periodFlag}, ${r.period.spanDays} days)`));
|
|
433
|
+
if (r.totals.activeDays === 0) {
|
|
434
|
+
output.writeln(output.info('no Claude Code activity in this window'));
|
|
435
|
+
return { success: true, exitCode: 0 };
|
|
436
|
+
}
|
|
437
|
+
output.writeln('');
|
|
438
|
+
output.writeln(` Total spend ${output.bold(fmtCost(r.totals.costUsd))} ${output.dim(`across ${r.totals.activeDays} active day${r.totals.activeDays === 1 ? '' : 's'} of ${r.period.spanDays}`)}`);
|
|
439
|
+
output.writeln(` Total tokens ${r.totals.totalTokens.toLocaleString()}`);
|
|
440
|
+
if (r.busiestDay)
|
|
441
|
+
output.writeln(` Busiest day ${r.busiestDay.day} ${output.dim(`(${fmtCost(r.busiestDay.costUsd)}, ${r.busiestDay.totalTokens.toLocaleString()} tok)`)}`);
|
|
442
|
+
output.writeln(` Longest streak ${r.longestStreak} day${r.longestStreak === 1 ? '' : 's'}`);
|
|
443
|
+
output.writeln(` Avg / active day ${fmtCost(r.avgCostPerActiveDay)}`);
|
|
444
|
+
output.writeln(` Cache read share ${pctStr(r.cacheReadPct)}`);
|
|
445
|
+
if (delegation.toolCalls > 0) {
|
|
446
|
+
output.writeln(` Delegation ${pctStr(delegation.ratio)} ${output.dim(`(${delegation.taskCalls} of ${delegation.toolCalls} tool calls to subagents)`)}`);
|
|
447
|
+
}
|
|
448
|
+
output.writeln(` Cost trend ${arrow} ${fmtCost(r.trend.firstHalfCost)} → ${fmtCost(r.trend.secondHalfCost)} ${output.dim(`(${r.trend.direction})`)}`);
|
|
449
|
+
if (r.peakHour)
|
|
450
|
+
output.writeln(` Peak hour ${String(r.peakHour.hour).padStart(2, '0')}:00 ${output.dim(`(${fmtCost(r.peakHour.value)})`)}`);
|
|
451
|
+
if (r.hourHistogram.some((v) => v > 0)) {
|
|
452
|
+
output.writeln(` By hour ${hourSparkline(r.hourHistogram)} ${output.dim('0…23')}`);
|
|
453
|
+
}
|
|
454
|
+
if (r.topModels.length) {
|
|
455
|
+
output.writeln('');
|
|
456
|
+
output.writeln(output.bold(' Top models'));
|
|
457
|
+
r.topModels.forEach((m, i) => {
|
|
458
|
+
output.writeln(` ${i + 1}. ${m.model.padEnd(22)} ${fmtCost(m.costUsd).padStart(9)} ${output.dim(pctStr(m.pct))}`);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
if (r.topProjects.length) {
|
|
462
|
+
output.writeln('');
|
|
463
|
+
output.writeln(output.bold(' Top projects'));
|
|
464
|
+
r.topProjects.forEach((p, i) => {
|
|
465
|
+
const name = p.model.length > 34 ? '…' + p.model.slice(-33) : p.model;
|
|
466
|
+
output.writeln(` ${i + 1}. ${name.padEnd(34)} ${fmtCost(p.costUsd).padStart(9)} ${output.dim(pctStr(p.pct))}`);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
return { success: true, exitCode: 0 };
|
|
470
|
+
}
|
|
471
|
+
/** `usage limits` — forecast the official 5h/7d quota windows from the
|
|
472
|
+
* statusline rate_limits payload (stdin or --payload). #46. */
|
|
473
|
+
function runLimitsView(ctx) {
|
|
474
|
+
const nowMs = Date.now();
|
|
475
|
+
let raw = typeof ctx.flags.payload === 'string' ? ctx.flags.payload : '';
|
|
476
|
+
if (!raw && !process.stdin.isTTY) {
|
|
477
|
+
try {
|
|
478
|
+
raw = readFileSync(0, 'utf8');
|
|
479
|
+
}
|
|
480
|
+
catch { /* no piped input */ }
|
|
481
|
+
}
|
|
482
|
+
if (!raw.trim()) {
|
|
483
|
+
output.writeln(output.info("no rate_limits payload — pipe Claude Code's statusline JSON in, or pass --payload '<json>'"));
|
|
484
|
+
output.writeln(output.dim(' e.g. in a statusline command: … | swarmdo usage limits'));
|
|
485
|
+
return { success: true, exitCode: 0 };
|
|
486
|
+
}
|
|
487
|
+
let payload;
|
|
488
|
+
try {
|
|
489
|
+
payload = JSON.parse(raw);
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
output.printError('payload is not valid JSON');
|
|
493
|
+
return { success: false, exitCode: 1 };
|
|
494
|
+
}
|
|
495
|
+
const windows = parseRateLimits(payload);
|
|
496
|
+
if (windows.length === 0) {
|
|
497
|
+
output.writeln(output.info('no five_hour / seven_day rate_limits found in the payload'));
|
|
498
|
+
return { success: true, exitCode: 0 };
|
|
499
|
+
}
|
|
500
|
+
const forecasts = windows.map((w) => ({ label: w.label, f: forecastWindow(w.state, nowMs) }));
|
|
501
|
+
if (ctx.flags.json === true) {
|
|
502
|
+
output.printJson(forecasts.map((x) => ({ label: x.label, ...x.f })));
|
|
503
|
+
return { success: true, data: forecasts };
|
|
504
|
+
}
|
|
505
|
+
if (ctx.flags.oneline === true) {
|
|
506
|
+
// Compact statusline-ready indicator, e.g. "5h 72%⚠ · 7d 12%".
|
|
507
|
+
output.writeln(formatLimitSegment(forecasts));
|
|
508
|
+
const worstOne = worstStatus(forecasts.map((x) => x.f));
|
|
509
|
+
return { success: true, exitCode: ctx.flags.strict === true && worstOne === 'over' ? 1 : 0 };
|
|
510
|
+
}
|
|
511
|
+
const worst = worstStatus(forecasts.map((x) => x.f));
|
|
512
|
+
output.writeln(output.bold('Usage limits') + output.dim(` (${worst})`));
|
|
513
|
+
for (const { label, f } of forecasts) {
|
|
514
|
+
const line = formatForecast(label, f, nowMs);
|
|
515
|
+
output.writeln(' ' + (f.status === 'over' ? output.error(line) : f.status === 'warn' ? line : output.dim(line)));
|
|
516
|
+
}
|
|
517
|
+
const exitCode = ctx.flags.strict === true && worst === 'over' ? 1 : 0;
|
|
518
|
+
if (exitCode === 1)
|
|
519
|
+
output.writeln(output.dim('exit 1 (--strict + cap reached)'));
|
|
520
|
+
return { success: exitCode === 0, exitCode };
|
|
521
|
+
}
|
|
379
522
|
async function run(ctx) {
|
|
380
523
|
const viewName = (ctx.args[0] || 'daily').toLowerCase();
|
|
381
524
|
if (viewName === 'blocks') {
|
|
@@ -409,9 +552,17 @@ async function run(ctx) {
|
|
|
409
552
|
const dirs = typeof dirFlag === 'string' ? [dirFlag] : Array.isArray(dirFlag) ? dirFlag.map(String) : undefined;
|
|
410
553
|
return runDiffView(ctx, collectUsage({ dirs }));
|
|
411
554
|
}
|
|
555
|
+
if (viewName === 'reflect') {
|
|
556
|
+
const dirFlag = ctx.flags.dir;
|
|
557
|
+
const dirs = typeof dirFlag === 'string' ? [dirFlag] : Array.isArray(dirFlag) ? dirFlag.map(String) : undefined;
|
|
558
|
+
return runReflectView(ctx, collectUsage({ dirs }));
|
|
559
|
+
}
|
|
560
|
+
if (viewName === 'limits') {
|
|
561
|
+
return runLimitsView(ctx); // reads the rate_limits payload from stdin/--payload, not transcripts
|
|
562
|
+
}
|
|
412
563
|
const view = VIEWS[viewName];
|
|
413
564
|
if (!view) {
|
|
414
|
-
output.writeln(output.error(`unknown view: ${viewName} (expected ${Object.keys(VIEWS).join('|')}|blocks|errors|cache|guard|diff)`));
|
|
565
|
+
output.writeln(output.error(`unknown view: ${viewName} (expected ${Object.keys(VIEWS).join('|')}|blocks|errors|cache|guard|diff|reflect|limits)`));
|
|
415
566
|
return { success: false, exitCode: 1 };
|
|
416
567
|
}
|
|
417
568
|
const since = typeof ctx.flags.since === 'string' ? ctx.flags.since : undefined;
|
|
@@ -433,12 +584,31 @@ async function run(ctx) {
|
|
|
433
584
|
output.writeln(JSON.stringify(jsonPayload(viewName, collection, rows, grand, since, until), null, 2));
|
|
434
585
|
return { success: true, exitCode: 0, data: grand };
|
|
435
586
|
}
|
|
587
|
+
if (ctx.flags.csv === true) {
|
|
588
|
+
// Portable export for spreadsheets / expense reports.
|
|
589
|
+
const headers = ['key', 'costUsd', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'totalTokens', 'entries'];
|
|
590
|
+
const csvRows = rows.map((r) => [r.key, r.totals.costUsd, r.totals.inputTokens, r.totals.outputTokens, r.totals.cacheReadTokens, r.totals.cacheWriteTokens, r.totals.totalTokens, r.totals.entries]);
|
|
591
|
+
output.writeln(toCsv(headers, csvRows));
|
|
592
|
+
return { success: true, exitCode: 0, data: grand };
|
|
593
|
+
}
|
|
436
594
|
if (collection.events.length === 0) {
|
|
437
595
|
output.writeln(output.info(`no usage entries found in ${collection.filesScanned} transcript files`));
|
|
438
596
|
return { success: true, exitCode: 0 };
|
|
439
597
|
}
|
|
440
598
|
output.writeln(output.bold(`Claude Code usage — ${viewName}`) + (since || until ? output.dim(` (${since ?? '…'} → ${until ?? '…'})`) : ''));
|
|
441
599
|
renderTable(view.label, rows, grand);
|
|
600
|
+
if (viewName === 'monthly') {
|
|
601
|
+
// Month-end pace projection for the current calendar month.
|
|
602
|
+
const today = localDateKey(new Date());
|
|
603
|
+
const curMonth = today.slice(0, 7);
|
|
604
|
+
const dom = Number(today.slice(8, 10));
|
|
605
|
+
const curRow = rows.find((r) => r.key === curMonth);
|
|
606
|
+
const dim = daysInMonthOf(curMonth);
|
|
607
|
+
if (curRow && dom > 0 && Number.isFinite(dim)) {
|
|
608
|
+
const p = projectMonthEnd(curRow.totals.costUsd, dom, dim);
|
|
609
|
+
output.writeln(output.dim(`${curMonth} on pace for ~${fmtCost(p.projectedUsd)} (${fmtCost(p.monthToDateUsd)} over ${dom}/${p.daysInMonth} days · ~${fmtCost(p.remainingUsd)} to go)`));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
442
612
|
output.writeln(output.dim(`${collection.filesScanned} transcript files · ${grand.entries} billed responses · sources: ${collection.dirsScanned.join(', ')}`));
|
|
443
613
|
if (collection.unpricedModels.length > 0) {
|
|
444
614
|
const { unpricedResponses, unpricedTokens } = unpricedStats(collection.events);
|
|
@@ -456,11 +626,13 @@ export const usageCommand = {
|
|
|
456
626
|
{ name: 'dir', description: 'explicit Claude projects dir (replaces auto-discovery)', type: 'string' },
|
|
457
627
|
{ name: 'limit', description: 'max rows for models/projects/sessions views', type: 'number' },
|
|
458
628
|
{ name: 'json', description: 'machine-readable output', type: 'boolean', default: false },
|
|
629
|
+
{ name: 'csv', description: 'CSV export (daily|monthly|models|projects|sessions views)', type: 'boolean', default: false },
|
|
459
630
|
],
|
|
460
631
|
examples: [
|
|
461
632
|
{ command: 'swarmdo usage', description: 'Daily token/cost table across all local Claude Code sessions' },
|
|
462
633
|
{ command: 'swarmdo usage models --since 2026-07-01', description: 'Spend per model this month' },
|
|
463
634
|
{ command: 'swarmdo usage projects --json', description: 'Per-project totals as JSON' },
|
|
635
|
+
{ command: 'swarmdo usage monthly --csv > usage.csv', description: 'Export monthly spend to CSV' },
|
|
464
636
|
{ command: 'swarmdo cost sessions --limit 10', description: 'Ten most expensive sessions (alias)' },
|
|
465
637
|
],
|
|
466
638
|
action: run,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* limits.ts — forecast when a Claude Code usage window will hit its cap.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code passes an official `rate_limits` payload to statusline scripts:
|
|
5
|
+
* for the rolling 5-hour and 7-day windows it reports `used_percentage` and
|
|
6
|
+
* `resets_at`. This is the projection layer on top — at the current burn rate,
|
|
7
|
+
* when will the window hit 100%, and does that land before the window resets?
|
|
8
|
+
* ("at this pace you hit the weekly cap Thu 14:00, 6h before it resets").
|
|
9
|
+
*
|
|
10
|
+
* Pure + deterministic: it takes a normalized window state + an injected `now`
|
|
11
|
+
* (the statusline shim maps the raw payload to WindowState — see #46), so the
|
|
12
|
+
* projection math is unit-tested with zero clock or wire-format coupling.
|
|
13
|
+
*/
|
|
14
|
+
export type LimitStatus = 'ok' | 'warn' | 'over';
|
|
15
|
+
/** Rolling-window durations Claude Code reports (5-hour + 7-day caps). */
|
|
16
|
+
export declare const FIVE_HOUR_MS: number;
|
|
17
|
+
export declare const SEVEN_DAY_MS: number;
|
|
18
|
+
export interface NamedWindow {
|
|
19
|
+
/** short label, '5h' | '7d' */
|
|
20
|
+
label: string;
|
|
21
|
+
state: WindowState;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Map Claude Code's statusline `rate_limits` payload to normalized windows.
|
|
25
|
+
* Tolerant of shape drift: accepts the payload bare or wrapped in `rate_limits`,
|
|
26
|
+
* snake_case or camelCase keys, and epoch-seconds / epoch-ms / ISO `resets_at`.
|
|
27
|
+
* Windows missing usage or a reset time are dropped. Pure.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseRateLimits(payload: unknown): NamedWindow[];
|
|
30
|
+
export interface WindowState {
|
|
31
|
+
/** percent of the window's quota consumed, 0..100 */
|
|
32
|
+
usedPercentage: number;
|
|
33
|
+
/** window duration in ms (5h = 18_000_000, 7d = 604_800_000) */
|
|
34
|
+
windowMs: number;
|
|
35
|
+
/** epoch ms when the window resets to 0% */
|
|
36
|
+
resetsAtMs: number;
|
|
37
|
+
}
|
|
38
|
+
export interface LimitForecast {
|
|
39
|
+
usedPercentage: number;
|
|
40
|
+
resetsAtMs: number;
|
|
41
|
+
/** ms from now until the window resets (never negative) */
|
|
42
|
+
msToReset: number;
|
|
43
|
+
/** projected epoch ms when usage reaches 100% at the current burn rate, or
|
|
44
|
+
* null when it can't be projected (no burn yet, already at/over 100%, or the
|
|
45
|
+
* window hasn't started per the clock) */
|
|
46
|
+
exhaustionMs: number | null;
|
|
47
|
+
/** true when exhaustion is projected at or before the window resets */
|
|
48
|
+
willExhaust: boolean;
|
|
49
|
+
status: LimitStatus;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Forecast a single window. Burn rate is `usedPercentage / elapsed`, where
|
|
53
|
+
* elapsed = now − windowStart and windowStart = resetsAt − windowMs. Pure.
|
|
54
|
+
*/
|
|
55
|
+
export declare function forecastWindow(w: WindowState, nowMs: number, opts?: {
|
|
56
|
+
warnPct?: number;
|
|
57
|
+
}): LimitForecast;
|
|
58
|
+
/** Worst status across windows (over > warn > ok). Pure. */
|
|
59
|
+
export declare function worstStatus(forecasts: LimitForecast[]): LimitStatus;
|
|
60
|
+
/**
|
|
61
|
+
* The binding window — the one that constrains you first. A window projected to
|
|
62
|
+
* exhaust before reset outranks one that won't; among those that will, the
|
|
63
|
+
* earlier exhaustion wins; otherwise the higher used_percentage. null for [].
|
|
64
|
+
*/
|
|
65
|
+
export declare function bindingWindow(forecasts: LimitForecast[]): LimitForecast | null;
|
|
66
|
+
/** Compact human duration for a ms span, e.g. 9000000 → "2h30m", 90000 → "1m". */
|
|
67
|
+
export declare function humanizeMs(ms: number): string;
|
|
68
|
+
/** Ultra-compact one-line indicator for a statusline, e.g. "5h 72%⚠ · 7d 12%".
|
|
69
|
+
* `!` marks an over-cap window, `⚠` an at-risk one. Pure. */
|
|
70
|
+
export declare function formatLimitSegment(forecasts: Array<{
|
|
71
|
+
label: string;
|
|
72
|
+
f: LimitForecast;
|
|
73
|
+
}>): string;
|
|
74
|
+
/** One-line summary for a window, e.g. "5h window: 42% used, resets in 2h14m —
|
|
75
|
+
* on pace to hit the cap in ~1h20m (before reset)". `label` names the window. */
|
|
76
|
+
export declare function formatForecast(label: string, f: LimitForecast, nowMs: number): string;
|
|
77
|
+
//# sourceMappingURL=limits.d.ts.map
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* limits.ts — forecast when a Claude Code usage window will hit its cap.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code passes an official `rate_limits` payload to statusline scripts:
|
|
5
|
+
* for the rolling 5-hour and 7-day windows it reports `used_percentage` and
|
|
6
|
+
* `resets_at`. This is the projection layer on top — at the current burn rate,
|
|
7
|
+
* when will the window hit 100%, and does that land before the window resets?
|
|
8
|
+
* ("at this pace you hit the weekly cap Thu 14:00, 6h before it resets").
|
|
9
|
+
*
|
|
10
|
+
* Pure + deterministic: it takes a normalized window state + an injected `now`
|
|
11
|
+
* (the statusline shim maps the raw payload to WindowState — see #46), so the
|
|
12
|
+
* projection math is unit-tested with zero clock or wire-format coupling.
|
|
13
|
+
*/
|
|
14
|
+
/** Rolling-window durations Claude Code reports (5-hour + 7-day caps). */
|
|
15
|
+
export const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
|
|
16
|
+
export const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
17
|
+
/** Coerce an epoch-seconds, epoch-ms, or ISO-string timestamp to epoch ms. */
|
|
18
|
+
function toEpochMs(v) {
|
|
19
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
20
|
+
return v < 1e12 ? v * 1000 : v; // secs vs ms
|
|
21
|
+
if (typeof v === 'string') {
|
|
22
|
+
const t = Date.parse(v);
|
|
23
|
+
return Number.isNaN(t) ? null : t;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
function pickWindow(obj, windowMs, label) {
|
|
28
|
+
if (!obj || typeof obj !== 'object')
|
|
29
|
+
return null;
|
|
30
|
+
const o = obj;
|
|
31
|
+
const used = (o.used_percentage ?? o.usedPercentage);
|
|
32
|
+
const resetsAtMs = toEpochMs(o.resets_at ?? o.resetsAt);
|
|
33
|
+
if (typeof used !== 'number' || !Number.isFinite(used) || resetsAtMs === null)
|
|
34
|
+
return null;
|
|
35
|
+
return { label, state: { usedPercentage: Math.max(0, Math.min(100, used)), windowMs, resetsAtMs } };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Map Claude Code's statusline `rate_limits` payload to normalized windows.
|
|
39
|
+
* Tolerant of shape drift: accepts the payload bare or wrapped in `rate_limits`,
|
|
40
|
+
* snake_case or camelCase keys, and epoch-seconds / epoch-ms / ISO `resets_at`.
|
|
41
|
+
* Windows missing usage or a reset time are dropped. Pure.
|
|
42
|
+
*/
|
|
43
|
+
export function parseRateLimits(payload) {
|
|
44
|
+
const p = payload && typeof payload === 'object' ? payload : {};
|
|
45
|
+
const rl = (p.rate_limits ?? p.rateLimits ?? p);
|
|
46
|
+
const out = [];
|
|
47
|
+
const w5 = pickWindow(rl.five_hour ?? rl.fiveHour, FIVE_HOUR_MS, '5h');
|
|
48
|
+
const w7 = pickWindow(rl.seven_day ?? rl.sevenDay, SEVEN_DAY_MS, '7d');
|
|
49
|
+
if (w5)
|
|
50
|
+
out.push(w5);
|
|
51
|
+
if (w7)
|
|
52
|
+
out.push(w7);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Forecast a single window. Burn rate is `usedPercentage / elapsed`, where
|
|
57
|
+
* elapsed = now − windowStart and windowStart = resetsAt − windowMs. Pure.
|
|
58
|
+
*/
|
|
59
|
+
export function forecastWindow(w, nowMs, opts = {}) {
|
|
60
|
+
const warnPct = opts.warnPct ?? 80;
|
|
61
|
+
const used = w.usedPercentage;
|
|
62
|
+
const msToReset = Math.max(0, w.resetsAtMs - nowMs);
|
|
63
|
+
const windowStartMs = w.resetsAtMs - w.windowMs;
|
|
64
|
+
const elapsed = nowMs - windowStartMs;
|
|
65
|
+
let exhaustionMs = null;
|
|
66
|
+
if (used >= 100) {
|
|
67
|
+
// already exhausted — mark exhaustion as "now"
|
|
68
|
+
exhaustionMs = nowMs;
|
|
69
|
+
}
|
|
70
|
+
else if (used > 0 && elapsed > 0) {
|
|
71
|
+
const msTo100 = ((100 - used) * elapsed) / used; // (remaining%) / (used%/elapsed)
|
|
72
|
+
exhaustionMs = nowMs + msTo100;
|
|
73
|
+
}
|
|
74
|
+
const willExhaust = exhaustionMs !== null && exhaustionMs <= w.resetsAtMs;
|
|
75
|
+
const status = used >= 100 ? 'over' : (willExhaust || used >= warnPct) ? 'warn' : 'ok';
|
|
76
|
+
return { usedPercentage: used, resetsAtMs: w.resetsAtMs, msToReset, exhaustionMs, willExhaust, status };
|
|
77
|
+
}
|
|
78
|
+
/** Worst status across windows (over > warn > ok). Pure. */
|
|
79
|
+
export function worstStatus(forecasts) {
|
|
80
|
+
const rank = { ok: 0, warn: 1, over: 2 };
|
|
81
|
+
return forecasts.reduce((acc, f) => (rank[f.status] > rank[acc] ? f.status : acc), 'ok');
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The binding window — the one that constrains you first. A window projected to
|
|
85
|
+
* exhaust before reset outranks one that won't; among those that will, the
|
|
86
|
+
* earlier exhaustion wins; otherwise the higher used_percentage. null for [].
|
|
87
|
+
*/
|
|
88
|
+
export function bindingWindow(forecasts) {
|
|
89
|
+
if (forecasts.length === 0)
|
|
90
|
+
return null;
|
|
91
|
+
return [...forecasts].sort((a, b) => {
|
|
92
|
+
if (a.willExhaust !== b.willExhaust)
|
|
93
|
+
return a.willExhaust ? -1 : 1;
|
|
94
|
+
if (a.willExhaust && b.willExhaust)
|
|
95
|
+
return a.exhaustionMs - b.exhaustionMs;
|
|
96
|
+
return b.usedPercentage - a.usedPercentage;
|
|
97
|
+
})[0];
|
|
98
|
+
}
|
|
99
|
+
/** Compact human duration for a ms span, e.g. 9000000 → "2h30m", 90000 → "1m". */
|
|
100
|
+
export function humanizeMs(ms) {
|
|
101
|
+
if (ms <= 0)
|
|
102
|
+
return '0m';
|
|
103
|
+
const totalMin = Math.floor(ms / 60_000);
|
|
104
|
+
const d = Math.floor(totalMin / 1440);
|
|
105
|
+
const h = Math.floor((totalMin % 1440) / 60);
|
|
106
|
+
const m = totalMin % 60;
|
|
107
|
+
if (d > 0)
|
|
108
|
+
return `${d}d${h > 0 ? `${h}h` : ''}`;
|
|
109
|
+
if (h > 0)
|
|
110
|
+
return `${h}h${m > 0 ? `${m}m` : ''}`;
|
|
111
|
+
return `${m}m`;
|
|
112
|
+
}
|
|
113
|
+
/** Ultra-compact one-line indicator for a statusline, e.g. "5h 72%⚠ · 7d 12%".
|
|
114
|
+
* `!` marks an over-cap window, `⚠` an at-risk one. Pure. */
|
|
115
|
+
export function formatLimitSegment(forecasts) {
|
|
116
|
+
if (forecasts.length === 0)
|
|
117
|
+
return 'limits: n/a';
|
|
118
|
+
const mark = (s) => (s === 'over' ? '!' : s === 'warn' ? '⚠' : '');
|
|
119
|
+
return forecasts.map(({ label, f }) => `${label} ${Math.round(f.usedPercentage)}%${mark(f.status)}`).join(' · ');
|
|
120
|
+
}
|
|
121
|
+
/** One-line summary for a window, e.g. "5h window: 42% used, resets in 2h14m —
|
|
122
|
+
* on pace to hit the cap in ~1h20m (before reset)". `label` names the window. */
|
|
123
|
+
export function formatForecast(label, f, nowMs) {
|
|
124
|
+
const head = `${label}: ${Math.round(f.usedPercentage)}% used, resets in ${humanizeMs(f.msToReset)}`;
|
|
125
|
+
if (f.status === 'over')
|
|
126
|
+
return `${head} — CAP REACHED`;
|
|
127
|
+
if (f.willExhaust && f.exhaustionMs !== null) {
|
|
128
|
+
const inMs = Math.max(0, f.exhaustionMs - nowMs);
|
|
129
|
+
const before = humanizeMs(f.resetsAtMs - f.exhaustionMs);
|
|
130
|
+
return `${head} — on pace to hit the cap in ~${humanizeMs(inMs)} (${before} before reset)`;
|
|
131
|
+
}
|
|
132
|
+
return `${head} — on pace to stay under the cap`;
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=limits.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reflect-html.ts — render a Reflection (see reflect.ts) into a self-contained,
|
|
3
|
+
* shareable HTML dashboard for `swarmdo usage reflect --html`.
|
|
4
|
+
*
|
|
5
|
+
* Pure + deterministic: it takes an already-computed Reflection and returns a
|
|
6
|
+
* complete HTML document string with all CSS inlined (no external requests, no
|
|
7
|
+
* clock — any timestamp is passed in), so it unit-tests without a browser or a
|
|
8
|
+
* network. Every user-controlled string (model ids, project paths) is HTML-
|
|
9
|
+
* escaped. See #47.
|
|
10
|
+
*/
|
|
11
|
+
import type { Reflection } from './reflect.js';
|
|
12
|
+
/** HTML-escape a string for safe interpolation into text or attributes. */
|
|
13
|
+
export declare function escapeHtml(s: string): string;
|
|
14
|
+
/** Render the retrospective to a complete self-contained HTML document. Pure. */
|
|
15
|
+
export declare function renderReflectionHtml(r: Reflection, opts?: {
|
|
16
|
+
generatedAt?: string;
|
|
17
|
+
delegation?: {
|
|
18
|
+
taskCalls: number;
|
|
19
|
+
toolCalls: number;
|
|
20
|
+
ratio: number;
|
|
21
|
+
};
|
|
22
|
+
}): string;
|
|
23
|
+
//# sourceMappingURL=reflect-html.d.ts.map
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reflect-html.ts — render a Reflection (see reflect.ts) into a self-contained,
|
|
3
|
+
* shareable HTML dashboard for `swarmdo usage reflect --html`.
|
|
4
|
+
*
|
|
5
|
+
* Pure + deterministic: it takes an already-computed Reflection and returns a
|
|
6
|
+
* complete HTML document string with all CSS inlined (no external requests, no
|
|
7
|
+
* clock — any timestamp is passed in), so it unit-tests without a browser or a
|
|
8
|
+
* network. Every user-controlled string (model ids, project paths) is HTML-
|
|
9
|
+
* escaped. See #47.
|
|
10
|
+
*/
|
|
11
|
+
/** HTML-escape a string for safe interpolation into text or attributes. */
|
|
12
|
+
export function escapeHtml(s) {
|
|
13
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
14
|
+
}
|
|
15
|
+
const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
16
|
+
const num = (n) => n.toLocaleString('en-US');
|
|
17
|
+
const pct = (x) => `${Math.round(x * 100)}%`;
|
|
18
|
+
/** A labelled proportional bar (width is % of the largest value in its group). */
|
|
19
|
+
function bar(label, valueText, fillPct) {
|
|
20
|
+
const w = Math.max(0, Math.min(100, fillPct));
|
|
21
|
+
return `<div class="bar"><div class="bar-label" title="${escapeHtml(label)}">${escapeHtml(label)}</div>`
|
|
22
|
+
+ `<div class="bar-track"><div class="bar-fill" style="width:${w.toFixed(1)}%"></div></div>`
|
|
23
|
+
+ `<div class="bar-val">${escapeHtml(valueText)}</div></div>`;
|
|
24
|
+
}
|
|
25
|
+
function shareBars(rows) {
|
|
26
|
+
if (!rows.length)
|
|
27
|
+
return '<p class="empty">—</p>';
|
|
28
|
+
const max = Math.max(...rows.map((r) => r.costUsd), 0) || 1;
|
|
29
|
+
return rows.map((r) => bar(r.model, `${usd(r.costUsd)} · ${pct(r.pct)}`, (r.costUsd / max) * 100)).join('');
|
|
30
|
+
}
|
|
31
|
+
function hourBars(hist) {
|
|
32
|
+
if (!hist.some((v) => v > 0))
|
|
33
|
+
return '<p class="empty">—</p>';
|
|
34
|
+
const max = Math.max(...hist) || 1;
|
|
35
|
+
const cells = hist.map((v, h) => {
|
|
36
|
+
const hpct = (v / max) * 100;
|
|
37
|
+
return `<div class="hour" title="${String(h).padStart(2, '0')}:00 — ${usd(v)}">`
|
|
38
|
+
+ `<div class="hour-bar" style="height:${hpct.toFixed(1)}%"></div>`
|
|
39
|
+
+ `<div class="hour-tick">${h % 6 === 0 ? String(h).padStart(2, '0') : ''}</div></div>`;
|
|
40
|
+
}).join('');
|
|
41
|
+
return `<div class="hours">${cells}</div>`;
|
|
42
|
+
}
|
|
43
|
+
function stat(label, value) {
|
|
44
|
+
return `<div class="stat"><div class="stat-val">${escapeHtml(value)}</div><div class="stat-label">${escapeHtml(label)}</div></div>`;
|
|
45
|
+
}
|
|
46
|
+
/** Render the retrospective to a complete self-contained HTML document. Pure. */
|
|
47
|
+
export function renderReflectionHtml(r, opts = {}) {
|
|
48
|
+
const arrow = r.trend.direction === 'up' ? '↑' : r.trend.direction === 'down' ? '↓' : '→';
|
|
49
|
+
const peak = r.peakHour ? `${String(r.peakHour.hour).padStart(2, '0')}:00` : '—';
|
|
50
|
+
const busiest = r.busiestDay ? `${r.busiestDay.day} (${usd(r.busiestDay.costUsd)})` : '—';
|
|
51
|
+
const gen = opts.generatedAt ? `<p class="gen">generated ${escapeHtml(opts.generatedAt)}</p>` : '';
|
|
52
|
+
return `<!doctype html>
|
|
53
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
54
|
+
<title>Claude Code Reflect — ${escapeHtml(r.period.from)}..${escapeHtml(r.period.to)}</title>
|
|
55
|
+
<style>
|
|
56
|
+
:root{--bg:#0f1117;--card:#181b24;--fg:#e6e8ee;--muted:#8b90a0;--accent:#7c9cff;--track:#262a36}
|
|
57
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;padding:32px}
|
|
58
|
+
.wrap{max-width:840px;margin:0 auto}
|
|
59
|
+
h1{font-size:22px;margin:0 0 2px}.sub{color:var(--muted);margin:0 0 24px}.gen{color:var(--muted);font-size:12px;margin:24px 0 0}
|
|
60
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:28px}
|
|
61
|
+
.stat{background:var(--card);border-radius:12px;padding:16px}
|
|
62
|
+
.stat-val{font-size:22px;font-weight:600}.stat-label{color:var(--muted);font-size:12px;margin-top:4px}
|
|
63
|
+
h2{font-size:14px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:28px 0 12px}
|
|
64
|
+
.bar{display:grid;grid-template-columns:minmax(0,1fr) 3fr minmax(0,auto);align-items:center;gap:10px;margin:6px 0}
|
|
65
|
+
.bar-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}
|
|
66
|
+
.bar-track{background:var(--track);border-radius:6px;height:14px;overflow:hidden}
|
|
67
|
+
.bar-fill{background:var(--accent);height:100%;border-radius:6px}
|
|
68
|
+
.bar-val{font-size:12px;color:var(--muted);white-space:nowrap}
|
|
69
|
+
.hours{display:flex;align-items:flex-end;gap:3px;height:90px}
|
|
70
|
+
.hour{flex:1;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;height:100%}
|
|
71
|
+
.hour-bar{width:100%;background:var(--accent);border-radius:3px 3px 0 0;min-height:1px}
|
|
72
|
+
.hour-tick{font-size:9px;color:var(--muted);margin-top:4px;height:12px}
|
|
73
|
+
.empty{color:var(--muted)}
|
|
74
|
+
</style></head><body><div class="wrap">
|
|
75
|
+
<h1>Claude Code Reflect ${escapeHtml(arrow)}</h1>
|
|
76
|
+
<p class="sub">${escapeHtml(r.period.from)} .. ${escapeHtml(r.period.to)} · ${r.period.spanDays} days · ${r.totals.activeDays} active</p>
|
|
77
|
+
<div class="grid">
|
|
78
|
+
${stat('Total spend', usd(r.totals.costUsd))}
|
|
79
|
+
${stat('Total tokens', num(r.totals.totalTokens))}
|
|
80
|
+
${stat('Avg / active day', usd(r.avgCostPerActiveDay))}
|
|
81
|
+
${stat('Longest streak', `${r.longestStreak} day${r.longestStreak === 1 ? '' : 's'}`)}
|
|
82
|
+
${stat('Peak hour', peak)}
|
|
83
|
+
${stat('Cache read', pct(r.cacheReadPct))}
|
|
84
|
+
${stat('Busiest day', busiest)}
|
|
85
|
+
${stat('Trend', `${arrow} ${pct(r.trend.firstHalfCost > 0 ? (r.trend.secondHalfCost - r.trend.firstHalfCost) / r.trend.firstHalfCost : 0)}`)}
|
|
86
|
+
${opts.delegation && opts.delegation.toolCalls > 0 ? stat('Delegation', pct(opts.delegation.ratio)) : ''}
|
|
87
|
+
</div>
|
|
88
|
+
<h2>Cost by hour of day</h2>${hourBars(r.hourHistogram)}
|
|
89
|
+
<h2>Top models</h2>${shareBars(r.topModels)}
|
|
90
|
+
<h2>Top projects</h2>${shareBars(r.topProjects)}
|
|
91
|
+
${gen}
|
|
92
|
+
</div></body></html>`;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=reflect-html.js.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reflect.ts — a "wrapped"-style retrospective over Claude Code usage.
|
|
3
|
+
*
|
|
4
|
+
* Anthropic's Reflect (2026-07-09) summarizes claude.ai *chats*; it doesn't
|
|
5
|
+
* touch Claude Code terminal work. swarmdo already parses the local transcripts
|
|
6
|
+
* for `usage daily/monthly/blocks/errors/cache`, so this is the retrospective
|
|
7
|
+
* layer on top: fold the existing per-day and per-(model,day) aggregation rows
|
|
8
|
+
* into headline stats — totals, the busiest day, the top models, the longest
|
|
9
|
+
* active-day streak, the cost trend, and cache efficiency.
|
|
10
|
+
*
|
|
11
|
+
* Pure + deterministic: it takes already-aggregated rows (no transcripts, no
|
|
12
|
+
* clock — the period bounds are passed in), so the whole thing is unit-tested
|
|
13
|
+
* without touching disk. The command layer in ../commands/usage.ts feeds it the
|
|
14
|
+
* rows the parser already produces. See #47.
|
|
15
|
+
*/
|
|
16
|
+
import type { DayRow, ModelRow } from './diff.js';
|
|
17
|
+
export interface ModelShare {
|
|
18
|
+
model: string;
|
|
19
|
+
costUsd: number;
|
|
20
|
+
totalTokens: number;
|
|
21
|
+
/** share of total cost, 0..1 (0 when the period cost is 0) */
|
|
22
|
+
pct: number;
|
|
23
|
+
}
|
|
24
|
+
export interface ReflectionTotals {
|
|
25
|
+
costUsd: number;
|
|
26
|
+
totalTokens: number;
|
|
27
|
+
inputTokens: number;
|
|
28
|
+
outputTokens: number;
|
|
29
|
+
cacheReadTokens: number;
|
|
30
|
+
cacheWriteTokens: number;
|
|
31
|
+
entries: number;
|
|
32
|
+
/** days in [from,to] with any token activity */
|
|
33
|
+
activeDays: number;
|
|
34
|
+
}
|
|
35
|
+
export interface Reflection {
|
|
36
|
+
period: {
|
|
37
|
+
from: string;
|
|
38
|
+
to: string; /** inclusive calendar span in days */
|
|
39
|
+
spanDays: number;
|
|
40
|
+
};
|
|
41
|
+
totals: ReflectionTotals;
|
|
42
|
+
/** highest-cost active day in the period, or null if the period is empty */
|
|
43
|
+
busiestDay: {
|
|
44
|
+
day: string;
|
|
45
|
+
costUsd: number;
|
|
46
|
+
totalTokens: number;
|
|
47
|
+
} | null;
|
|
48
|
+
/** models ranked by cost desc (capped to opts.topModels) */
|
|
49
|
+
topModels: ModelShare[];
|
|
50
|
+
/** projects ranked by cost desc (ModelShare.model holds the project path) */
|
|
51
|
+
topProjects: ModelShare[];
|
|
52
|
+
/** busiest local hour-of-day by cost (0..23), or null if no activity */
|
|
53
|
+
peakHour: {
|
|
54
|
+
hour: number;
|
|
55
|
+
value: number;
|
|
56
|
+
} | null;
|
|
57
|
+
/** cost per local hour-of-day, as supplied by the caller (length 24 when set) */
|
|
58
|
+
hourHistogram: number[];
|
|
59
|
+
/** longest run of consecutive active calendar days */
|
|
60
|
+
longestStreak: number;
|
|
61
|
+
/** cacheRead / (input + cacheWrite + cacheRead), 0..1 */
|
|
62
|
+
cacheReadPct: number;
|
|
63
|
+
/** total cost / active days (0 when no active days) */
|
|
64
|
+
avgCostPerActiveDay: number;
|
|
65
|
+
/** first-half vs second-half spend across the period */
|
|
66
|
+
trend: {
|
|
67
|
+
firstHalfCost: number;
|
|
68
|
+
secondHalfCost: number;
|
|
69
|
+
direction: 'up' | 'down' | 'flat';
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export interface ReflectOptions {
|
|
73
|
+
/** how many models to keep in topModels (default 5) */
|
|
74
|
+
topModels?: number;
|
|
75
|
+
/** relative change below which the trend reads 'flat' (default 0.05 = 5%) */
|
|
76
|
+
flatThreshold?: number;
|
|
77
|
+
}
|
|
78
|
+
/** The calendar day after an ISO date (handles month/year rollover). Pure. */
|
|
79
|
+
export declare function nextDay(iso: string): string;
|
|
80
|
+
/**
|
|
81
|
+
* The ISO date `n` whole months before `iso`, clamping the day to the target
|
|
82
|
+
* month's length so "Mar 31 − 1 month" is Feb 28/29 (not a rolled-over Mar 3).
|
|
83
|
+
* Pure. Used to derive a `--period 1m|3m|…` window start from today.
|
|
84
|
+
*/
|
|
85
|
+
export declare function monthsBefore(iso: string, n: number): string;
|
|
86
|
+
/** Inclusive whole-day span between two ISO dates (from <= to). Pure. */
|
|
87
|
+
export declare function spanDays(from: string, to: string): number;
|
|
88
|
+
/** Longest run of consecutive calendar days present in the set. Pure. */
|
|
89
|
+
export declare function longestStreakOf(days: string[]): number;
|
|
90
|
+
/** Render a numeric series as a Unicode block sparkline (one char per bucket).
|
|
91
|
+
* Zero buckets are blank; the rest scale ▁..█ against the max. Pure — used for
|
|
92
|
+
* the terminal cost-by-hour view (the HTML dashboard draws the same data). */
|
|
93
|
+
export declare function hourSparkline(hist: number[]): string;
|
|
94
|
+
/** Window + aggregate `{key, day, totals}` rows into cost-ranked shares. Pure.
|
|
95
|
+
* Reused for both models and projects (ModelShare.model carries whichever key). */
|
|
96
|
+
export declare function rankShares(rows: ModelRow[], from: string, to: string, totalCost: number, topN: number): ModelShare[];
|
|
97
|
+
/** Busiest bucket in a per-hour cost histogram (argmax; earliest hour wins ties),
|
|
98
|
+
* or null when every bucket is empty. Pure. */
|
|
99
|
+
export declare function peakHourOf(hourHistogram: number[]): {
|
|
100
|
+
hour: number;
|
|
101
|
+
value: number;
|
|
102
|
+
} | null;
|
|
103
|
+
/** Fold pre-aggregated usage rows into a retrospective. Pure. */
|
|
104
|
+
export declare function computeReflection(dayRows: DayRow[], modelRows: ModelRow[], from: string, to: string, opts?: ReflectOptions, projectRows?: ModelRow[], hourHistogram?: number[]): Reflection;
|
|
105
|
+
//# sourceMappingURL=reflect.d.ts.map
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reflect.ts — a "wrapped"-style retrospective over Claude Code usage.
|
|
3
|
+
*
|
|
4
|
+
* Anthropic's Reflect (2026-07-09) summarizes claude.ai *chats*; it doesn't
|
|
5
|
+
* touch Claude Code terminal work. swarmdo already parses the local transcripts
|
|
6
|
+
* for `usage daily/monthly/blocks/errors/cache`, so this is the retrospective
|
|
7
|
+
* layer on top: fold the existing per-day and per-(model,day) aggregation rows
|
|
8
|
+
* into headline stats — totals, the busiest day, the top models, the longest
|
|
9
|
+
* active-day streak, the cost trend, and cache efficiency.
|
|
10
|
+
*
|
|
11
|
+
* Pure + deterministic: it takes already-aggregated rows (no transcripts, no
|
|
12
|
+
* clock — the period bounds are passed in), so the whole thing is unit-tested
|
|
13
|
+
* without touching disk. The command layer in ../commands/usage.ts feeds it the
|
|
14
|
+
* rows the parser already produces. See #47.
|
|
15
|
+
*/
|
|
16
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
17
|
+
/** The calendar day after an ISO date (handles month/year rollover). Pure. */
|
|
18
|
+
export function nextDay(iso) {
|
|
19
|
+
const [y, m, d] = iso.split('-').map(Number);
|
|
20
|
+
const dt = new Date(y, m - 1, d + 1);
|
|
21
|
+
const yy = dt.getFullYear();
|
|
22
|
+
const mm = String(dt.getMonth() + 1).padStart(2, '0');
|
|
23
|
+
const dd = String(dt.getDate()).padStart(2, '0');
|
|
24
|
+
return `${yy}-${mm}-${dd}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The ISO date `n` whole months before `iso`, clamping the day to the target
|
|
28
|
+
* month's length so "Mar 31 − 1 month" is Feb 28/29 (not a rolled-over Mar 3).
|
|
29
|
+
* Pure. Used to derive a `--period 1m|3m|…` window start from today.
|
|
30
|
+
*/
|
|
31
|
+
export function monthsBefore(iso, n) {
|
|
32
|
+
const [y, m, d] = iso.split('-').map(Number);
|
|
33
|
+
const monthIndex = m - 1 - n; // may be negative — Date normalizes the year
|
|
34
|
+
const lastDay = new Date(y, monthIndex + 1, 0).getDate();
|
|
35
|
+
const dt = new Date(y, monthIndex, Math.min(d, lastDay));
|
|
36
|
+
const yy = dt.getFullYear();
|
|
37
|
+
const mm = String(dt.getMonth() + 1).padStart(2, '0');
|
|
38
|
+
const dd = String(dt.getDate()).padStart(2, '0');
|
|
39
|
+
return `${yy}-${mm}-${dd}`;
|
|
40
|
+
}
|
|
41
|
+
/** Inclusive whole-day span between two ISO dates (from <= to). Pure. */
|
|
42
|
+
export function spanDays(from, to) {
|
|
43
|
+
const a = new Date(from + 'T00:00:00');
|
|
44
|
+
const b = new Date(to + 'T00:00:00');
|
|
45
|
+
return Math.floor((b.getTime() - a.getTime()) / 86_400_000) + 1;
|
|
46
|
+
}
|
|
47
|
+
/** Longest run of consecutive calendar days present in the set. Pure. */
|
|
48
|
+
export function longestStreakOf(days) {
|
|
49
|
+
const uniq = [...new Set(days)].sort();
|
|
50
|
+
let best = 0;
|
|
51
|
+
let run = 0;
|
|
52
|
+
let prev = null;
|
|
53
|
+
for (const d of uniq) {
|
|
54
|
+
run = prev !== null && nextDay(prev) === d ? run + 1 : 1;
|
|
55
|
+
if (run > best)
|
|
56
|
+
best = run;
|
|
57
|
+
prev = d;
|
|
58
|
+
}
|
|
59
|
+
return best;
|
|
60
|
+
}
|
|
61
|
+
const within = (key, from, to) => key >= from && key <= to;
|
|
62
|
+
const SPARK_BLOCKS = ' ▁▂▃▄▅▆▇█'; // index 0 = empty (zero), 1..8 = ▁..█ scaled to max
|
|
63
|
+
/** Render a numeric series as a Unicode block sparkline (one char per bucket).
|
|
64
|
+
* Zero buckets are blank; the rest scale ▁..█ against the max. Pure — used for
|
|
65
|
+
* the terminal cost-by-hour view (the HTML dashboard draws the same data). */
|
|
66
|
+
export function hourSparkline(hist) {
|
|
67
|
+
const max = Math.max(0, ...hist);
|
|
68
|
+
if (max <= 0)
|
|
69
|
+
return ' '.repeat(hist.length);
|
|
70
|
+
return hist.map((v) => SPARK_BLOCKS[v <= 0 ? 0 : Math.min(8, 1 + Math.floor((v / max) * 7))]).join('');
|
|
71
|
+
}
|
|
72
|
+
/** Window + aggregate `{key, day, totals}` rows into cost-ranked shares. Pure.
|
|
73
|
+
* Reused for both models and projects (ModelShare.model carries whichever key). */
|
|
74
|
+
export function rankShares(rows, from, to, totalCost, topN) {
|
|
75
|
+
const per = new Map();
|
|
76
|
+
for (const r of rows) {
|
|
77
|
+
if (!within(r.day, from, to))
|
|
78
|
+
continue;
|
|
79
|
+
const slot = per.get(r.key) ?? { costUsd: 0, totalTokens: 0 };
|
|
80
|
+
slot.costUsd += r.totals.costUsd;
|
|
81
|
+
slot.totalTokens += r.totals.totalTokens;
|
|
82
|
+
per.set(r.key, slot);
|
|
83
|
+
}
|
|
84
|
+
return [...per.entries()]
|
|
85
|
+
.map(([key, v]) => ({ model: key, costUsd: v.costUsd, totalTokens: v.totalTokens, pct: totalCost > 0 ? v.costUsd / totalCost : 0 }))
|
|
86
|
+
.filter((m) => m.costUsd > 0 || m.totalTokens > 0)
|
|
87
|
+
.sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens || (a.model < b.model ? -1 : 1))
|
|
88
|
+
.slice(0, topN);
|
|
89
|
+
}
|
|
90
|
+
/** Busiest bucket in a per-hour cost histogram (argmax; earliest hour wins ties),
|
|
91
|
+
* or null when every bucket is empty. Pure. */
|
|
92
|
+
export function peakHourOf(hourHistogram) {
|
|
93
|
+
let best = -1;
|
|
94
|
+
let bestVal = 0;
|
|
95
|
+
for (let h = 0; h < hourHistogram.length; h++) {
|
|
96
|
+
if (hourHistogram[h] > bestVal) {
|
|
97
|
+
bestVal = hourHistogram[h];
|
|
98
|
+
best = h;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return best < 0 ? null : { hour: best, value: bestVal };
|
|
102
|
+
}
|
|
103
|
+
/** Fold pre-aggregated usage rows into a retrospective. Pure. */
|
|
104
|
+
export function computeReflection(dayRows, modelRows, from, to, opts = {}, projectRows = [], hourHistogram = []) {
|
|
105
|
+
if (!DATE.test(from) || !DATE.test(to))
|
|
106
|
+
throw new Error(`bad period bounds: ${from}..${to}`);
|
|
107
|
+
if (from > to)
|
|
108
|
+
throw new Error(`period "${from}..${to}" is reversed (from > to)`);
|
|
109
|
+
const topN = opts.topModels ?? 5;
|
|
110
|
+
const flat = opts.flatThreshold ?? 0.05;
|
|
111
|
+
const days = dayRows.filter((r) => within(r.key, from, to));
|
|
112
|
+
const totals = {
|
|
113
|
+
costUsd: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0,
|
|
114
|
+
cacheReadTokens: 0, cacheWriteTokens: 0, entries: 0, activeDays: 0,
|
|
115
|
+
};
|
|
116
|
+
let busiestDay = null;
|
|
117
|
+
const activeDayKeys = [];
|
|
118
|
+
const mid = spanDays(from, to) / 2;
|
|
119
|
+
let firstHalfCost = 0;
|
|
120
|
+
let secondHalfCost = 0;
|
|
121
|
+
for (const r of days) {
|
|
122
|
+
const t = r.totals;
|
|
123
|
+
totals.costUsd += t.costUsd;
|
|
124
|
+
totals.totalTokens += t.totalTokens;
|
|
125
|
+
totals.inputTokens += t.inputTokens;
|
|
126
|
+
totals.outputTokens += t.outputTokens;
|
|
127
|
+
totals.cacheReadTokens += t.cacheReadTokens;
|
|
128
|
+
totals.cacheWriteTokens += t.cacheWriteTokens;
|
|
129
|
+
if (t.totalTokens > 0) {
|
|
130
|
+
totals.activeDays += 1;
|
|
131
|
+
activeDayKeys.push(r.key);
|
|
132
|
+
}
|
|
133
|
+
if (!busiestDay || t.costUsd > busiestDay.costUsd) {
|
|
134
|
+
busiestDay = { day: r.key, costUsd: t.costUsd, totalTokens: t.totalTokens };
|
|
135
|
+
}
|
|
136
|
+
// Trend: which half of the period does this day fall in?
|
|
137
|
+
if (spanDays(from, r.key) <= mid)
|
|
138
|
+
firstHalfCost += t.costUsd;
|
|
139
|
+
else
|
|
140
|
+
secondHalfCost += t.costUsd;
|
|
141
|
+
}
|
|
142
|
+
// A period with zero active days has no meaningful busiest day.
|
|
143
|
+
if (totals.activeDays === 0)
|
|
144
|
+
busiestDay = null;
|
|
145
|
+
// Cost-ranked shares over the same window, for both models and projects.
|
|
146
|
+
const topModels = rankShares(modelRows, from, to, totals.costUsd, topN);
|
|
147
|
+
const topProjects = rankShares(projectRows, from, to, totals.costUsd, topN);
|
|
148
|
+
const inputSide = totals.inputTokens + totals.cacheWriteTokens + totals.cacheReadTokens;
|
|
149
|
+
const relChange = firstHalfCost > 0 ? (secondHalfCost - firstHalfCost) / firstHalfCost : (secondHalfCost > 0 ? 1 : 0);
|
|
150
|
+
const direction = Math.abs(relChange) < flat ? 'flat' : relChange > 0 ? 'up' : 'down';
|
|
151
|
+
return {
|
|
152
|
+
period: { from, to, spanDays: spanDays(from, to) },
|
|
153
|
+
totals,
|
|
154
|
+
busiestDay,
|
|
155
|
+
topModels,
|
|
156
|
+
topProjects,
|
|
157
|
+
peakHour: peakHourOf(hourHistogram),
|
|
158
|
+
hourHistogram,
|
|
159
|
+
longestStreak: longestStreakOf(activeDayKeys),
|
|
160
|
+
cacheReadPct: inputSide > 0 ? totals.cacheReadTokens / inputSide : 0,
|
|
161
|
+
avgCostPerActiveDay: totals.activeDays > 0 ? totals.costUsd / totals.activeDays : 0,
|
|
162
|
+
trend: { firstHalfCost, secondHalfCost, direction },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=reflect.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spend-forecast.ts — project month-end Claude Code spend from month-to-date
|
|
3
|
+
* burn, for budgeting ("on pace for ~$420 this month").
|
|
4
|
+
*
|
|
5
|
+
* The quota forecaster (limits.ts) answers "will I hit the rolling cap"; this
|
|
6
|
+
* answers "what will the calendar month cost at the current daily average".
|
|
7
|
+
* Pure + deterministic: month-to-date total, the day-of-month, and the days in
|
|
8
|
+
* the month are passed in, so it's unit-tested without a clock.
|
|
9
|
+
*/
|
|
10
|
+
export interface SpendProjection {
|
|
11
|
+
monthToDateUsd: number;
|
|
12
|
+
dayOfMonth: number;
|
|
13
|
+
daysInMonth: number;
|
|
14
|
+
/** month-to-date / day-of-month */
|
|
15
|
+
dailyAverageUsd: number;
|
|
16
|
+
/** dailyAverage × daysInMonth — projected month-end total */
|
|
17
|
+
projectedUsd: number;
|
|
18
|
+
/** projectedUsd − monthToDateUsd — the remaining projected spend */
|
|
19
|
+
remainingUsd: number;
|
|
20
|
+
}
|
|
21
|
+
/** Days in the calendar month of an ISO `YYYY-MM` (or `YYYY-MM-DD`). Pure. */
|
|
22
|
+
export declare function daysInMonthOf(iso: string): number;
|
|
23
|
+
/**
|
|
24
|
+
* Project month-end spend at the current daily average. `dayOfMonth` is how many
|
|
25
|
+
* days (inclusive of today) have accrued the month-to-date total. Pure.
|
|
26
|
+
*/
|
|
27
|
+
export declare function projectMonthEnd(monthToDateUsd: number, dayOfMonth: number, daysInMonth: number): SpendProjection;
|
|
28
|
+
//# sourceMappingURL=spend-forecast.d.ts.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spend-forecast.ts — project month-end Claude Code spend from month-to-date
|
|
3
|
+
* burn, for budgeting ("on pace for ~$420 this month").
|
|
4
|
+
*
|
|
5
|
+
* The quota forecaster (limits.ts) answers "will I hit the rolling cap"; this
|
|
6
|
+
* answers "what will the calendar month cost at the current daily average".
|
|
7
|
+
* Pure + deterministic: month-to-date total, the day-of-month, and the days in
|
|
8
|
+
* the month are passed in, so it's unit-tested without a clock.
|
|
9
|
+
*/
|
|
10
|
+
/** Days in the calendar month of an ISO `YYYY-MM` (or `YYYY-MM-DD`). Pure. */
|
|
11
|
+
export function daysInMonthOf(iso) {
|
|
12
|
+
const [y, m] = iso.split('-').map(Number);
|
|
13
|
+
if (!Number.isFinite(y) || !Number.isFinite(m) || m < 1 || m > 12)
|
|
14
|
+
return NaN;
|
|
15
|
+
return new Date(y, m, 0).getDate(); // day 0 of the next month = last day of this one
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Project month-end spend at the current daily average. `dayOfMonth` is how many
|
|
19
|
+
* days (inclusive of today) have accrued the month-to-date total. Pure.
|
|
20
|
+
*/
|
|
21
|
+
export function projectMonthEnd(monthToDateUsd, dayOfMonth, daysInMonth) {
|
|
22
|
+
const dailyAverageUsd = dayOfMonth > 0 ? monthToDateUsd / dayOfMonth : 0;
|
|
23
|
+
const projectedUsd = dailyAverageUsd * daysInMonth;
|
|
24
|
+
return {
|
|
25
|
+
monthToDateUsd,
|
|
26
|
+
dayOfMonth,
|
|
27
|
+
daysInMonth,
|
|
28
|
+
dailyAverageUsd,
|
|
29
|
+
projectedUsd,
|
|
30
|
+
remainingUsd: Math.max(0, projectedUsd - monthToDateUsd),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=spend-forecast.js.map
|
|
@@ -31,6 +31,20 @@ export interface ToolErrorReport {
|
|
|
31
31
|
filesScanned: number;
|
|
32
32
|
sessionsWithErrors: number;
|
|
33
33
|
}
|
|
34
|
+
export interface Delegation {
|
|
35
|
+
/** tool_use calls to the `Task` tool (subagent spawns) */
|
|
36
|
+
taskCalls: number;
|
|
37
|
+
/** all tool_use calls */
|
|
38
|
+
toolCalls: number;
|
|
39
|
+
/** taskCalls / toolCalls, 0..1 (0 when no tool calls) */
|
|
40
|
+
ratio: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Delegation ratio: what fraction of tool calls spawned a subagent (the `Task`
|
|
44
|
+
* tool), from an already-collected tool report. The report's parsing/counting
|
|
45
|
+
* is the tested collectToolErrors path — this just extracts the Task share. Pure.
|
|
46
|
+
*/
|
|
47
|
+
export declare function delegationFromReport(report: Pick<ToolErrorReport, 'tools' | 'totalCalls'>): Delegation;
|
|
34
48
|
export interface ParsedLine {
|
|
35
49
|
type?: string;
|
|
36
50
|
timestamp?: string;
|
|
@@ -12,6 +12,16 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import * as fs from 'node:fs';
|
|
14
14
|
import { defaultClaudeProjectDirs, findTranscriptFiles, normalizeDateBound } from './transcript-usage.js';
|
|
15
|
+
/**
|
|
16
|
+
* Delegation ratio: what fraction of tool calls spawned a subagent (the `Task`
|
|
17
|
+
* tool), from an already-collected tool report. The report's parsing/counting
|
|
18
|
+
* is the tested collectToolErrors path — this just extracts the Task share. Pure.
|
|
19
|
+
*/
|
|
20
|
+
export function delegationFromReport(report) {
|
|
21
|
+
const taskCalls = report.tools.find((t) => t.tool === 'Task')?.calls ?? 0;
|
|
22
|
+
const toolCalls = report.totalCalls;
|
|
23
|
+
return { taskCalls, toolCalls, ratio: toolCalls > 0 ? taskCalls / toolCalls : 0 };
|
|
24
|
+
}
|
|
15
25
|
export function newAccum() {
|
|
16
26
|
return { idToTool: new Map(), calls: new Map(), errors: new Map(), sigs: new Map(), sessionsWithErrors: new Set() };
|
|
17
27
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* csv.ts — minimal RFC 4180 CSV serialization for exporting tabular command
|
|
3
|
+
* output (e.g. `usage --csv` into a spreadsheet / expense report).
|
|
4
|
+
*
|
|
5
|
+
* Pure and dependency-free. A field is quoted only when it must be — it contains
|
|
6
|
+
* a comma, a double-quote, or a line break — and interior double-quotes are
|
|
7
|
+
* doubled. Rows are joined with '\n' (LF), which every spreadsheet importer
|
|
8
|
+
* accepts.
|
|
9
|
+
*/
|
|
10
|
+
/** Quote + escape a single CSV field per RFC 4180 (only when necessary). Pure. */
|
|
11
|
+
export declare function escapeCsvField(value: string | number | boolean | null | undefined): string;
|
|
12
|
+
/** Serialize a header row + data rows to a CSV string. Pure. */
|
|
13
|
+
export declare function toCsv(headers: string[], rows: Array<Array<string | number | boolean | null | undefined>>): string;
|
|
14
|
+
//# sourceMappingURL=csv.d.ts.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* csv.ts — minimal RFC 4180 CSV serialization for exporting tabular command
|
|
3
|
+
* output (e.g. `usage --csv` into a spreadsheet / expense report).
|
|
4
|
+
*
|
|
5
|
+
* Pure and dependency-free. A field is quoted only when it must be — it contains
|
|
6
|
+
* a comma, a double-quote, or a line break — and interior double-quotes are
|
|
7
|
+
* doubled. Rows are joined with '\n' (LF), which every spreadsheet importer
|
|
8
|
+
* accepts.
|
|
9
|
+
*/
|
|
10
|
+
/** Quote + escape a single CSV field per RFC 4180 (only when necessary). Pure. */
|
|
11
|
+
export function escapeCsvField(value) {
|
|
12
|
+
const s = value === null || value === undefined ? '' : String(value);
|
|
13
|
+
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
14
|
+
}
|
|
15
|
+
/** Serialize a header row + data rows to a CSV string. Pure. */
|
|
16
|
+
export function toCsv(headers, rows) {
|
|
17
|
+
const line = (cells) => cells.map(escapeCsvField).join(',');
|
|
18
|
+
return [line(headers), ...rows.map(line)].join('\n');
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=csv.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmdo/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Swarmdo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
|
|
6
6
|
"main": "dist/src/index.js",
|