swarmdo 1.27.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 +71 -34
- package/.claude-plugin/README.md +3 -3
- package/.claude-plugin/docs/INSTALLATION.md +1 -1
- package/.claude-plugin/docs/PLUGIN_SUMMARY.md +1 -1
- package/.claude-plugin/docs/QUICKSTART.md +3 -3
- package/.claude-plugin/hooks/hooks.json +19 -1
- package/.claude-plugin/marketplace.json +17 -17
- package/.claude-plugin/plugin.json +7 -39
- package/.claude-plugin/scripts/install.sh +3 -3
- package/.claude-plugin/scripts/swarmdo-hook.sh +1 -1
- package/.claude-plugin/scripts/verify.sh +2 -2
- package/README.md +11 -9
- package/package.json +1 -1
- package/v3/@swarmdo/cli/.claude/helpers/auto-memory-hook.mjs +70 -33
- package/v3/@swarmdo/cli/bin/cli.js +38 -0
- package/v3/@swarmdo/cli/dist/src/apply/apply.d.ts +6 -0
- package/v3/@swarmdo/cli/dist/src/apply/apply.js +41 -8
- package/v3/@swarmdo/cli/dist/src/commands/apply.js +17 -4
- package/v3/@swarmdo/cli/dist/src/commands/compact-snapshot.d.ts +17 -0
- package/v3/@swarmdo/cli/dist/src/commands/compact-snapshot.js +133 -0
- package/v3/@swarmdo/cli/dist/src/commands/index.js +4 -3
- package/v3/@swarmdo/cli/dist/src/commands/pack.d.ts +8 -0
- package/v3/@swarmdo/cli/dist/src/commands/pack.js +28 -2
- package/v3/@swarmdo/cli/dist/src/commands/usage.js +174 -2
- package/v3/@swarmdo/cli/dist/src/compact/compact.js +17 -7
- package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.d.ts +58 -0
- package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +104 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +16 -2
- package/v3/@swarmdo/cli/dist/src/init/helpers-generator.js +33 -3
- package/v3/@swarmdo/cli/dist/src/redact/redact.js +1 -1
- package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +5 -2
- package/v3/@swarmdo/cli/dist/src/swarmvector/model-prices.js +12 -2
- package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +11 -2
- package/v3/@swarmdo/cli/dist/src/transcript/export.d.ts +5 -0
- package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -1
- package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.d.ts +6 -2
- package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.js +33 -4
- 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/usage/transcript-usage.js +1 -1
- 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 +2 -2
|
@@ -16,11 +16,20 @@
|
|
|
16
16
|
* Price families, matched by LONGEST normalized-id prefix so dated ids like
|
|
17
17
|
* `claude-sonnet-4-6-20260115` resolve without per-snapshot entries.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* Opus pricing split (#41): Opus 4.0/4.1 were $15/$75, but Opus 4.5 dropped
|
|
20
|
+
* the tier to $5/$25 and 4.6/4.7/4.8 kept it. The bare 'claude-opus-4' entry
|
|
21
|
+
* stays at legacy rates for 4.0/4.1; the longer per-version prefixes win for
|
|
22
|
+
* 4.5+ via longest-prefix matching.
|
|
21
23
|
*/
|
|
22
24
|
const PRICE_FAMILIES = {
|
|
25
|
+
'claude-fable-5': { in: 10, out: 50, cacheWrite: 12.5, cacheWrite1h: 20, cacheRead: 1 },
|
|
26
|
+
'claude-mythos-5': { in: 10, out: 50, cacheWrite: 12.5, cacheWrite1h: 20, cacheRead: 1 },
|
|
23
27
|
'claude-opus-4': { in: 15, out: 75, cacheWrite: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
|
|
28
|
+
'claude-opus-4-5': { in: 5, out: 25, cacheWrite: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
|
|
29
|
+
'claude-opus-4-6': { in: 5, out: 25, cacheWrite: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
|
|
30
|
+
'claude-opus-4-7': { in: 5, out: 25, cacheWrite: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
|
|
31
|
+
'claude-opus-4-8': { in: 5, out: 25, cacheWrite: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
|
|
32
|
+
'claude-sonnet-5': { in: 3, out: 15, cacheWrite: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
|
|
24
33
|
'claude-sonnet-4': { in: 3, out: 15, cacheWrite: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
|
|
25
34
|
'claude-haiku-4': { in: 1, out: 5, cacheWrite: 1.25, cacheWrite1h: 2, cacheRead: 0.1 },
|
|
26
35
|
'claude-3-7-sonnet': { in: 3, out: 15, cacheWrite: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
|
|
@@ -29,6 +38,15 @@ const PRICE_FAMILIES = {
|
|
|
29
38
|
'claude-3-opus': { in: 15, out: 75, cacheWrite: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
|
|
30
39
|
'claude-3-haiku': { in: 0.25, out: 1.25, cacheWrite: 0.3, cacheWrite1h: 0.5, cacheRead: 0.03 },
|
|
31
40
|
};
|
|
41
|
+
const PROMOS = [
|
|
42
|
+
{
|
|
43
|
+
// Sonnet 5 introductory pricing: $2/$10 per Mtok through 2026-08-31.
|
|
44
|
+
prefix: 'claude-sonnet-5',
|
|
45
|
+
fromMs: Date.UTC(2026, 5, 30), // launched 2026-06-30
|
|
46
|
+
untilMs: Date.UTC(2026, 7, 31, 23, 59, 59, 999),
|
|
47
|
+
price: { in: 2, out: 10, cacheWrite: 2.5, cacheWrite1h: 4, cacheRead: 0.2 },
|
|
48
|
+
},
|
|
49
|
+
];
|
|
32
50
|
/**
|
|
33
51
|
* Reduce a transcript/gateway model id to the bare Anthropic id:
|
|
34
52
|
* 'anthropic/claude-opus-4-8' → 'claude-opus-4-8'
|
|
@@ -47,9 +65,20 @@ export function normalizeTranscriptModelId(raw) {
|
|
|
47
65
|
id = id.replace(/-v\d+$/, ''); // Bedrock '-v2'
|
|
48
66
|
return id;
|
|
49
67
|
}
|
|
50
|
-
/**
|
|
51
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Longest-prefix price lookup; undefined = unpriced (report it, don't guess).
|
|
70
|
+
* Pass the transcript entry's timestamp as `atMs` to get date-ranged promo
|
|
71
|
+
* rates (e.g. Sonnet 5 intro pricing); omitted → sticker price.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveTranscriptPrice(rawModelId, atMs) {
|
|
52
74
|
const id = normalizeTranscriptModelId(rawModelId);
|
|
75
|
+
if (atMs !== undefined) {
|
|
76
|
+
for (const promo of PROMOS) {
|
|
77
|
+
if (id.startsWith(promo.prefix) && atMs >= promo.fromMs && atMs <= promo.untilMs) {
|
|
78
|
+
return promo.price;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
53
82
|
let best;
|
|
54
83
|
let bestLen = 0;
|
|
55
84
|
for (const [prefix, price] of Object.entries(PRICE_FAMILIES)) {
|
|
@@ -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
|