@vimoxshah/tokenflow 1.1.2 → 1.2.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/CHANGELOG.md +180 -0
- package/Dockerfile.team +20 -0
- package/README.md +30 -11
- package/bin/tokenflow.js +147 -12
- package/design/tokens.yaml +330 -0
- package/docs/architecture.md +5 -4
- package/docs/cli.md +204 -0
- package/docs/configuration.md +117 -2
- package/docs/design-system.md +187 -0
- package/docs/exports-and-budgets.md +85 -0
- package/docs/guard-codex.md +132 -0
- package/docs/ledger.md +144 -0
- package/docs/live-mode.md +40 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/receipts-aurora-dark.png +0 -0
- package/docs/providers-otel.md +179 -0
- package/docs/providers.md +54 -1
- package/docs/receipt-schema.md +74 -0
- package/docs/roadmap.md +182 -0
- package/docs/team-server.md +170 -0
- package/docs/ui-views.md +322 -0
- package/package.json +7 -2
- package/schemas/receipt.v0.json +160 -0
- package/scripts/build-menubar-app.sh +3 -1
- package/scripts/design-build.js +475 -0
- package/src/analytics/anatomy.js +467 -0
- package/src/analytics/branch-compare.js +159 -0
- package/src/analytics/cache-health.js +141 -0
- package/src/analytics/live-view.js +266 -0
- package/src/analytics/receipt-schema.js +214 -0
- package/src/analytics/receipt.js +709 -0
- package/src/analytics/rhythm.js +184 -0
- package/src/analytics/whatif.js +263 -0
- package/src/commands/budget-scopes.js +133 -0
- package/src/commands/doctor-checks.js +400 -0
- package/src/commands/guard.js +531 -0
- package/src/commands/hooks.js +238 -0
- package/src/commands/pricing-diff.js +316 -0
- package/src/commands/receipt.js +226 -0
- package/src/commands/team-serve.js +407 -0
- package/src/commands/week.js +86 -0
- package/src/core/annotations.js +97 -0
- package/src/core/budget.js +33 -0
- package/src/core/bundle.js +45 -2
- package/src/core/ingest.js +33 -0
- package/src/core/live-status.js +227 -2
- package/src/core/policy.js +103 -0
- package/src/core/receipt-note.js +123 -0
- package/src/core/repo.js +64 -0
- package/src/core/sync.js +163 -26
- package/src/core/team.js +0 -0
- package/src/export/html-snapshot.js +28 -1
- package/src/export/menubar.js +21 -0
- package/src/export/receipt-card.js +210 -0
- package/src/export/week-card.js +185 -0
- package/src/providers/mock/index.js +383 -52
- package/src/providers/openai/index.js +31 -1
- package/src/providers/otel/index.js +656 -0
- package/src/server/routes/annotations.js +42 -0
- package/src/server/routes/cache-health.js +95 -0
- package/src/server/routes/index.js +54 -0
- package/src/server/routes/session.js +157 -0
- package/src/server/server.js +47 -1
- package/src/ui/app.js +541 -308
- package/src/ui/charts.js +95 -0
- package/src/ui/first-run.js +144 -0
- package/src/ui/index.html +4 -1
- package/src/ui/palette.js +335 -0
- package/src/ui/styles/anatomy.css +117 -0
- package/src/ui/styles/annotations.css +40 -0
- package/src/ui/styles/branches.css +99 -0
- package/src/ui/styles/cache.css +6 -0
- package/src/ui/styles/first-run.css +31 -0
- package/src/ui/styles/live.css +100 -0
- package/src/ui/styles/palette.css +85 -0
- package/src/ui/styles/rhythm.css +8 -0
- package/src/ui/styles/whatif.css +55 -0
- package/src/ui/styles.css +303 -196
- package/src/ui/views/anatomy.js +567 -0
- package/src/ui/views/annotations.js +121 -0
- package/src/ui/views/branches.js +304 -0
- package/src/ui/views/cache.js +232 -0
- package/src/ui/views/index.js +85 -0
- package/src/ui/views/live.js +683 -0
- package/src/ui/views/rhythm.js +206 -0
- package/src/ui/views/whatif.js +196 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache health analytics: how well the prompt cache is working, and where it
|
|
3
|
+
* gets invalidated and rewritten at a premium.
|
|
4
|
+
*
|
|
5
|
+
* Two kinds of input, kept apart on purpose:
|
|
6
|
+
*
|
|
7
|
+
* - `hitRateSeries` and `writeSplitSeries` read the pre-aggregated cube
|
|
8
|
+
* (daily rows from `calculateDailyUsage`, e.g. `ctx.view.daily`). They work
|
|
9
|
+
* offline, because the cube ships in every bundle and every snapshot.
|
|
10
|
+
* - `detectChurn` and `summarize` read request-level records, one session at
|
|
11
|
+
* a time. Records never ship in a snapshot, so churn detection is a live
|
|
12
|
+
* dashboard feature only. See src/server/routes/cache-health.js.
|
|
13
|
+
*
|
|
14
|
+
* This module has no Node imports, so it runs the same in the browser, the
|
|
15
|
+
* CLI and a test.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A churn event needs at least this many write tokens. */
|
|
19
|
+
export const CHURN_MIN_WRITE_TOKENS = 10000;
|
|
20
|
+
/** ...and at least this share of the previous turn's cache read. */
|
|
21
|
+
export const CHURN_MIN_READ_SHARE = 0.5;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Daily cache hit rate: cache_read / (cache_read + input).
|
|
25
|
+
* @param {{key?:string, date?:string, cr:number, in:number}[]} rows daily cube rows, e.g. ctx.view.daily
|
|
26
|
+
* @returns {{date:string, hitRate:number|null}[]} hitRate is null when neither field was measured that day
|
|
27
|
+
*/
|
|
28
|
+
export function hitRateSeries(rows) {
|
|
29
|
+
return (rows || []).map((d) => {
|
|
30
|
+
const date = d.key ?? d.date;
|
|
31
|
+
const cr = d.cr || 0;
|
|
32
|
+
const inTok = d.in || 0;
|
|
33
|
+
const denom = cr + inTok;
|
|
34
|
+
return { date, hitRate: denom > 0 ? cr / denom : null };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Daily cache write split: short-TTL writes (cache_write minus cache_refresh)
|
|
40
|
+
* versus long-TTL writes (cache_refresh). Both are real sums from the cube,
|
|
41
|
+
* so they are 0 (not null) on a day with no writes at all.
|
|
42
|
+
* @param {{key?:string, date?:string, cw:number, cf:number}[]} rows daily cube rows
|
|
43
|
+
* @returns {{date:string, shortTTL:number, longTTL:number}[]}
|
|
44
|
+
*/
|
|
45
|
+
export function writeSplitSeries(rows) {
|
|
46
|
+
return (rows || []).map((d) => {
|
|
47
|
+
const date = d.key ?? d.date;
|
|
48
|
+
const cw = d.cw || 0;
|
|
49
|
+
const cf = d.cf || 0;
|
|
50
|
+
return { date, shortTTL: Math.max(0, cw - cf), longTTL: cf };
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Find churn events in one session's turns: a turn whose cache write is at
|
|
56
|
+
* least `CHURN_MIN_WRITE_TOKENS` and at least `CHURN_MIN_READ_SHARE` of the
|
|
57
|
+
* PREVIOUS turn's cache read, in the same session. That pattern means the
|
|
58
|
+
* cache was invalidated and rewritten, as happens when a system prompt
|
|
59
|
+
* changes mid-session.
|
|
60
|
+
*
|
|
61
|
+
* Records are sorted by timestamp internally, so callers do not have to
|
|
62
|
+
* guarantee order. A turn with a null write or a previous turn with a null
|
|
63
|
+
* read cannot be evaluated and is skipped, never treated as a zero.
|
|
64
|
+
*
|
|
65
|
+
* @param {{timestamp:string, session_id?:string|null, project?:string|null,
|
|
66
|
+
* git_branch?:string|null, model?:string, provider?:string,
|
|
67
|
+
* cache_read_tokens:number|null, cache_write_tokens:number|null}[]} sessionRecords
|
|
68
|
+
* every record for one session
|
|
69
|
+
* @param {ReturnType<typeof import('../core/pricing.js').buildPriceBook>} book price book for the cost premium
|
|
70
|
+
* @returns {object[]} churn events, oldest first
|
|
71
|
+
*/
|
|
72
|
+
export function detectChurn(sessionRecords, book) {
|
|
73
|
+
const turns = [...(sessionRecords || [])].sort((a, b) => {
|
|
74
|
+
if (a.timestamp === b.timestamp) return 0;
|
|
75
|
+
return a.timestamp < b.timestamp ? -1 : 1;
|
|
76
|
+
});
|
|
77
|
+
const events = [];
|
|
78
|
+
for (let i = 1; i < turns.length; i++) {
|
|
79
|
+
const cur = turns[i];
|
|
80
|
+
const prev = turns[i - 1];
|
|
81
|
+
const write = cur.cache_write_tokens;
|
|
82
|
+
const prevRead = prev.cache_read_tokens;
|
|
83
|
+
if (write === null || write === undefined) continue;
|
|
84
|
+
if (prevRead === null || prevRead === undefined) continue;
|
|
85
|
+
if (write < CHURN_MIN_WRITE_TOKENS) continue;
|
|
86
|
+
if (write < CHURN_MIN_READ_SHARE * prevRead) continue;
|
|
87
|
+
|
|
88
|
+
const rates = book ? book.lookup(cur.model, cur.provider) : null;
|
|
89
|
+
const premiumUsd = rates && rates.cacheWrite !== null && rates.cacheWrite !== undefined
|
|
90
|
+
&& rates.cacheRead !== null && rates.cacheRead !== undefined
|
|
91
|
+
? (write / 1e6) * (rates.cacheWrite - rates.cacheRead)
|
|
92
|
+
: null;
|
|
93
|
+
|
|
94
|
+
events.push({
|
|
95
|
+
sessionId: cur.session_id ?? null,
|
|
96
|
+
project: cur.project ?? null,
|
|
97
|
+
branch: cur.git_branch ?? null,
|
|
98
|
+
turnIndex: i,
|
|
99
|
+
writeTokens: write,
|
|
100
|
+
previousReadTokens: prevRead,
|
|
101
|
+
model: cur.model ?? null,
|
|
102
|
+
provider: cur.provider ?? null,
|
|
103
|
+
premiumUsd,
|
|
104
|
+
timestamp: cur.timestamp ?? null,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return events;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Per-day event counts and the total estimated cost premium across every
|
|
112
|
+
* churn event. `totalPremiumUsd` is a real 0 when there are no events, but
|
|
113
|
+
* null (not available) when there are events and every one of them is priced
|
|
114
|
+
* with an unknown model, so the total is never a silent zero standing in for
|
|
115
|
+
* "unknown".
|
|
116
|
+
* @param {ReturnType<typeof detectChurn>} events
|
|
117
|
+
* @returns {{totalEvents:number, byDay:{date:string,count:number}[], totalPremiumUsd:number|null, premiumPartial:boolean}}
|
|
118
|
+
*/
|
|
119
|
+
export function summarize(events) {
|
|
120
|
+
const list = events || [];
|
|
121
|
+
const byDayMap = new Map();
|
|
122
|
+
let totalPremium = 0;
|
|
123
|
+
let premiumKnown = 0;
|
|
124
|
+
for (const e of list) {
|
|
125
|
+
const day = (e.timestamp || '').slice(0, 10);
|
|
126
|
+
if (day) byDayMap.set(day, (byDayMap.get(day) || 0) + 1);
|
|
127
|
+
if (e.premiumUsd !== null && e.premiumUsd !== undefined) {
|
|
128
|
+
totalPremium += e.premiumUsd;
|
|
129
|
+
premiumKnown++;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const byDay = [...byDayMap.entries()]
|
|
133
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
134
|
+
.map(([date, count]) => ({ date, count }));
|
|
135
|
+
return {
|
|
136
|
+
totalEvents: list.length,
|
|
137
|
+
byDay,
|
|
138
|
+
totalPremiumUsd: list.length === 0 ? 0 : (premiumKnown > 0 ? totalPremium : null),
|
|
139
|
+
premiumPartial: list.length > 0 && premiumKnown < list.length,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure shaping for the Live tab's "right now" sections.
|
|
3
|
+
*
|
|
4
|
+
* `src/core/live-status.js` writes four sections into `data/status.json` every
|
|
5
|
+
* watcher cycle — `liveSessions`, `receiptsToday`, `guard`, `sparklines`. This
|
|
6
|
+
* module turns that JSON into the small display facts the view needs: a gauge
|
|
7
|
+
* ratio, a stable colour order for sparkline sources, the resolved context cap,
|
|
8
|
+
* and the labels that must not be assembled twice in two places.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here touches the DOM, so it runs under `node --test` unchanged. Every
|
|
11
|
+
* label routes through `core/units.js`, the same formatters the CLI uses, with
|
|
12
|
+
* `n/a` passed for the missing case: a dash is not a number and neither is 0.
|
|
13
|
+
*/
|
|
14
|
+
import { compact, int, pct, relativeTime, usd } from '../core/units.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Context window assumed when no `maxContextTokens` cap is declared.
|
|
18
|
+
*
|
|
19
|
+
* It is a drawing scale, not a claim about the model: the gauge needs a
|
|
20
|
+
* denominator, and 200K is the common frontier context length. `declared`
|
|
21
|
+
* comes back false so the view can say which one the reader is looking at.
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_CONTEXT_CAP = 200000;
|
|
24
|
+
|
|
25
|
+
/** The five guard policy keys, in the order `live-status.js` writes them. */
|
|
26
|
+
const CAP_FIELDS = [
|
|
27
|
+
{ key: 'warnCostUsd', label: 'Warn at session spend', kind: 'usd' },
|
|
28
|
+
{ key: 'maxCostUsd', label: 'Block at session spend', kind: 'usd' },
|
|
29
|
+
{ key: 'warnContextTokens', label: 'Warn at context', kind: 'tokens' },
|
|
30
|
+
{ key: 'maxContextTokens', label: 'Block at context', kind: 'tokens' },
|
|
31
|
+
{ key: 'warnMarginalUsd', label: 'Warn at cost per turn', kind: 'usd' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** A positive finite number, or null. Mirrors the guard policy's own rule. */
|
|
35
|
+
function positive(v) {
|
|
36
|
+
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Deterministic id ordering: plain code-unit compare, never locale-dependent. */
|
|
40
|
+
function compareIds(a, b) {
|
|
41
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The denominator the context gauge draws against.
|
|
46
|
+
*
|
|
47
|
+
* A declared `maxContextTokens` wins, because that is the number the guard
|
|
48
|
+
* will actually block on. Otherwise the default scale, flagged as undeclared.
|
|
49
|
+
*
|
|
50
|
+
* @param {object|null|undefined} policy `status.guard.policy`
|
|
51
|
+
* @returns {{cap:number, label:string, declared:boolean}}
|
|
52
|
+
*/
|
|
53
|
+
export function resolveContextCap(policy) {
|
|
54
|
+
const declaredCap = positive(policy ? policy.maxContextTokens : null);
|
|
55
|
+
const cap = declaredCap ?? DEFAULT_CONTEXT_CAP;
|
|
56
|
+
return { cap, label: `of ${compact(cap, { na: 'n/a' })}`, declared: declaredCap !== null };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Fraction of `cap` that `value` fills, clamped to 0..1.
|
|
61
|
+
*
|
|
62
|
+
* Null when either side is unknown, so a gauge with no measurement draws
|
|
63
|
+
* empty and says "n/a" rather than sitting at zero as if it had measured one.
|
|
64
|
+
*
|
|
65
|
+
* @param {number|null|undefined} value
|
|
66
|
+
* @param {number|null|undefined} cap
|
|
67
|
+
* @returns {number|null}
|
|
68
|
+
*/
|
|
69
|
+
export function gaugeRatio(value, cap) {
|
|
70
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
|
71
|
+
const c = positive(cap);
|
|
72
|
+
if (c === null) return null;
|
|
73
|
+
return Math.max(0, Math.min(1, value / c));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Everything the context gauge shows for one session.
|
|
78
|
+
*
|
|
79
|
+
* @param {{contextTokens?:number|null, policy?:object|null}} opt
|
|
80
|
+
* @returns {{ratio:number|null, cap:number, declared:boolean, value:string,
|
|
81
|
+
* capLabel:string, text:string, pctText:string}}
|
|
82
|
+
*/
|
|
83
|
+
export function contextGauge(opt) {
|
|
84
|
+
const o = opt || {};
|
|
85
|
+
const { cap, label, declared } = resolveContextCap(o.policy);
|
|
86
|
+
const tokens = typeof o.contextTokens === 'number' && Number.isFinite(o.contextTokens) ? o.contextTokens : null;
|
|
87
|
+
const ratio = gaugeRatio(tokens, cap);
|
|
88
|
+
const value = tokens === null ? 'n/a' : compact(tokens, { na: 'n/a' });
|
|
89
|
+
return {
|
|
90
|
+
ratio,
|
|
91
|
+
cap,
|
|
92
|
+
declared,
|
|
93
|
+
value,
|
|
94
|
+
capLabel: label,
|
|
95
|
+
text: tokens === null ? 'n/a' : `${value} ${label}`,
|
|
96
|
+
pctText: ratio === null ? 'n/a' : pct(ratio, 0, 'n/a'),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* "3 min ago" for an `asOf` stamp, or "n/a" when there is none.
|
|
102
|
+
*
|
|
103
|
+
* `relativeTime` answers "never" for a missing timestamp, which reads as a
|
|
104
|
+
* claim about the watcher rather than about the field. An absent asOf is a
|
|
105
|
+
* missing value, so it says so the way every other missing value here does.
|
|
106
|
+
*
|
|
107
|
+
* @param {string|null|undefined} iso
|
|
108
|
+
* @param {number} [now] epoch ms, injectable for tests
|
|
109
|
+
*/
|
|
110
|
+
export function asOfLabel(iso, now = Date.now()) {
|
|
111
|
+
if (!iso || Number.isNaN(Date.parse(iso))) return 'n/a';
|
|
112
|
+
return relativeTime(iso, now);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Session spend, with its pricing coverage when some turns had no price.
|
|
117
|
+
*
|
|
118
|
+
* @param {{costUsd?:number|null, coverage?:number|null}} opt
|
|
119
|
+
* @returns {{text:string, coverageText:string|null, priced:boolean}}
|
|
120
|
+
*/
|
|
121
|
+
export function costLabel(opt) {
|
|
122
|
+
const o = opt || {};
|
|
123
|
+
const cost = typeof o.costUsd === 'number' && Number.isFinite(o.costUsd) ? o.costUsd : null;
|
|
124
|
+
const coverage = typeof o.coverage === 'number' && Number.isFinite(o.coverage) ? o.coverage : null;
|
|
125
|
+
return {
|
|
126
|
+
text: cost === null ? 'n/a' : usd(cost, 'n/a'),
|
|
127
|
+
coverageText: coverage !== null && coverage < 1 ? `priced ${pct(coverage, 0, 'n/a')} of turns` : null,
|
|
128
|
+
priced: cost !== null,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* "179 turns", "1 turn", "n/a". A counted noun agrees with its count.
|
|
134
|
+
* @param {number|null|undefined} n
|
|
135
|
+
* @param {string} singular
|
|
136
|
+
*/
|
|
137
|
+
export function countLabel(n, singular) {
|
|
138
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) return 'n/a';
|
|
139
|
+
return `${int(n, 'n/a')} ${singular}${n === 1 ? '' : 's'}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* "260 turns, 4 by subagents". Subagents appear only when there are any.
|
|
144
|
+
* @param {{turns?:number|null, subagentTurns?:number|null}} session
|
|
145
|
+
*/
|
|
146
|
+
export function turnsLabel(session) {
|
|
147
|
+
const s = session || {};
|
|
148
|
+
const sub = typeof s.subagentTurns === 'number' && Number.isFinite(s.subagentTurns) ? s.subagentTurns : 0;
|
|
149
|
+
const base = countLabel(s.turns, 'turn');
|
|
150
|
+
return sub > 0 ? `${base}, ${int(sub, 'n/a')} by subagents` : base;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Where a session is working: project first, repository as the fallback.
|
|
155
|
+
* @param {object} session one entry of `status.liveSessions.sessions`
|
|
156
|
+
* @returns {{where:string, branch:string}}
|
|
157
|
+
*/
|
|
158
|
+
export function sessionPlace(session) {
|
|
159
|
+
const s = session || {};
|
|
160
|
+
return {
|
|
161
|
+
where: s.project || s.repository || 'n/a',
|
|
162
|
+
branch: s.branch || 'n/a',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The guard chip for one session or for the last verdict.
|
|
168
|
+
*
|
|
169
|
+
* `title` is the first reason, or null when there is none — an empty string
|
|
170
|
+
* would render an empty tooltip box on hover.
|
|
171
|
+
*
|
|
172
|
+
* @param {{level?:string, reasons?:string[]}|null|undefined} guard
|
|
173
|
+
* @returns {{level:string, text:string, title:string|null}}
|
|
174
|
+
*/
|
|
175
|
+
export function guardChip(guard) {
|
|
176
|
+
const g = guard || {};
|
|
177
|
+
const level = g.level === 'block' || g.level === 'warn' ? g.level : 'ok';
|
|
178
|
+
const reasons = Array.isArray(g.reasons) ? g.reasons : [];
|
|
179
|
+
return { level, text: `guard ${level}`, title: reasons.length ? reasons[0] : null };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The declared caps, in policy order. Empty when nothing is declared.
|
|
184
|
+
* @param {object|null|undefined} policy `status.guard.policy`
|
|
185
|
+
* @returns {{key:string, label:string, value:number, text:string}[]}
|
|
186
|
+
*/
|
|
187
|
+
export function capRows(policy) {
|
|
188
|
+
const p = policy || {};
|
|
189
|
+
const rows = [];
|
|
190
|
+
for (const f of CAP_FIELDS) {
|
|
191
|
+
const v = positive(p[f.key]);
|
|
192
|
+
if (v === null) continue;
|
|
193
|
+
rows.push({
|
|
194
|
+
key: f.key,
|
|
195
|
+
label: f.label,
|
|
196
|
+
value: v,
|
|
197
|
+
text: f.kind === 'usd' ? usd(v, 'n/a') : compact(v, { na: 'n/a' }),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return rows;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Sum of the finite entries of a bucket array. */
|
|
204
|
+
function sumFinite(values) {
|
|
205
|
+
let t = 0;
|
|
206
|
+
for (const v of values) if (typeof v === 'number' && Number.isFinite(v)) t += v;
|
|
207
|
+
return t;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Which sparkline sources get their own chart, and which colour each keeps.
|
|
212
|
+
*
|
|
213
|
+
* Two orderings, deliberately different:
|
|
214
|
+
*
|
|
215
|
+
* - **Selection** is by volume, because a chart per source is only worth its
|
|
216
|
+
* space for the sources that moved tokens. Everything past `max` folds into
|
|
217
|
+
* one summed "other" series rather than disappearing.
|
|
218
|
+
* - **Colour** is by alphabetical position among *all* sources present, so a
|
|
219
|
+
* source keeps its colour when a quiet day drops it out of the top five and
|
|
220
|
+
* a busy one brings it back. `colorIndex` is null for "other", which the
|
|
221
|
+
* view draws in the muted no-series colour.
|
|
222
|
+
*
|
|
223
|
+
* Series come back in alphabetical order with "other" last, so a series never
|
|
224
|
+
* jumps position as its numbers change.
|
|
225
|
+
*
|
|
226
|
+
* @param {{bySource?:object, max?:number}} [opt] `status.sparklines.bySource`
|
|
227
|
+
* @returns {{series:{id:string, values:number[], colorIndex:number|null,
|
|
228
|
+
* total:number, folded?:string[]}[], folded:string[]}}
|
|
229
|
+
*/
|
|
230
|
+
export function orderSparkSources(opt) {
|
|
231
|
+
const o = opt || {};
|
|
232
|
+
const bySource = o.bySource && typeof o.bySource === 'object' ? o.bySource : {};
|
|
233
|
+
const max = typeof o.max === 'number' && Number.isFinite(o.max) && o.max > 0 ? Math.floor(o.max) : 5;
|
|
234
|
+
|
|
235
|
+
const keys = Object.keys(bySource).filter((k) => Array.isArray(bySource[k])).sort(compareIds);
|
|
236
|
+
const rank = new Map(keys.map((k, i) => [k, i]));
|
|
237
|
+
const totals = new Map(keys.map((k) => [k, sumFinite(bySource[k])]));
|
|
238
|
+
|
|
239
|
+
const kept = new Set(
|
|
240
|
+
keys.slice()
|
|
241
|
+
.sort((a, b) => (totals.get(b) - totals.get(a)) || compareIds(a, b))
|
|
242
|
+
.slice(0, max),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
/** @type {{id:string, values:number[], colorIndex:number|null, total:number, folded?:string[]}[]} */
|
|
246
|
+
const series = keys.filter((k) => kept.has(k)).map((k) => ({
|
|
247
|
+
id: k,
|
|
248
|
+
values: bySource[k].slice(),
|
|
249
|
+
colorIndex: rank.get(k),
|
|
250
|
+
total: totals.get(k),
|
|
251
|
+
}));
|
|
252
|
+
|
|
253
|
+
const folded = keys.filter((k) => !kept.has(k));
|
|
254
|
+
if (folded.length) {
|
|
255
|
+
const width = Math.max(...folded.map((k) => bySource[k].length));
|
|
256
|
+
const values = new Array(width).fill(0);
|
|
257
|
+
for (const k of folded) {
|
|
258
|
+
bySource[k].forEach((v, i) => {
|
|
259
|
+
if (typeof v === 'number' && Number.isFinite(v)) values[i] += v;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
series.push({ id: 'other', values, colorIndex: null, total: sumFinite(values), folded });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { series, folded };
|
|
266
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receipt schema v0 — a branch receipt shaped to travel with the code (as a
|
|
3
|
+
* git note) rather than live on a server. Three things live here:
|
|
4
|
+
*
|
|
5
|
+
* toReceiptV0() maps one buildReceipts() branch entry (the shape
|
|
6
|
+
* src/analytics/receipt.js produces) into the
|
|
7
|
+
* portable schema described by
|
|
8
|
+
* schemas/receipt.v0.json.
|
|
9
|
+
* validateReceiptV0() a small hand-written validator for that schema.
|
|
10
|
+
* No dependencies; kept in lockstep with the
|
|
11
|
+
* `required` list in schemas/receipt.v0.json —
|
|
12
|
+
* test/receipt-schema.test.js checks the two
|
|
13
|
+
* don't drift apart.
|
|
14
|
+
* renderReceiptV0Markdown() the same PR-comment table shape as
|
|
15
|
+
* renderReceiptMarkdown() in receipt.js, plus an
|
|
16
|
+
* `<!-- tokenflow-receipt -->` marker on the first
|
|
17
|
+
* line so a CI comment can be found and updated.
|
|
18
|
+
*
|
|
19
|
+
* Every dollar figure here is an estimate from local token counts against a
|
|
20
|
+
* local price table — never measured billing, never a network round trip.
|
|
21
|
+
* See docs/receipt-schema.md.
|
|
22
|
+
*/
|
|
23
|
+
import { usd, compact, pct, shortDate } from '../core/units.js';
|
|
24
|
+
|
|
25
|
+
/** The one receipt schema version this module speaks. */
|
|
26
|
+
export const RECEIPT_SCHEMA_VERSION = 0;
|
|
27
|
+
|
|
28
|
+
/** Fields schemas/receipt.v0.json marks `required`. Kept in sync by hand. */
|
|
29
|
+
const REQUIRED_FIELDS = [
|
|
30
|
+
'schemaVersion', 'generatedAt', 'toolVersion', 'repo', 'branch', 'headSha',
|
|
31
|
+
'window', 'costUsd', 'contextShare', 'turns', 'sessions', 'subagentTurns',
|
|
32
|
+
'models', 'coverage', 'largestPromptTokens', 'changedLines', 'pr',
|
|
33
|
+
'longLived', 'costPer100Lines', 'notes',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Honesty caveats for one branch receipt, as plain sentences (no markdown).
|
|
38
|
+
* Mirrors the notes renderReceiptMarkdown() prints beside the table, minus
|
|
39
|
+
* the fields (like vsMedian and the raw prWindow) the v0 schema does not keep.
|
|
40
|
+
* @param {object} b one branch receipt from buildReceipts()
|
|
41
|
+
* @returns {string[]}
|
|
42
|
+
*/
|
|
43
|
+
function buildNotes(b) {
|
|
44
|
+
const notes = [];
|
|
45
|
+
const w = b.prWindow;
|
|
46
|
+
if (w && w.beforeOpened.turns > 0 && w.beforeOpened.days !== null && w.beforeOpened.days > 14) {
|
|
47
|
+
const preShare = b.cost ? (w.beforeOpened.cost ?? 0) / b.cost : null;
|
|
48
|
+
const pct100 = preShare !== null ? `${Math.round(preShare * 100)}%` : 'some';
|
|
49
|
+
notes.push(`${pct100} of the total predates the pull request by up to ${Math.round(w.beforeOpened.days)} days; on a long-lived branch that is earlier work, not this PR's.`);
|
|
50
|
+
}
|
|
51
|
+
if (b.longLived) notes.push(`"${b.key}" is a long-lived branch: this is a receipt for a period of work on it, not for one change.`);
|
|
52
|
+
if (w && w.priorPrs.count > 0) notes.push(`${w.priorPrs.count} earlier merged pull request(s) on this branch hold additional spend not counted here.`);
|
|
53
|
+
if (b.unpricedTurns > 0) notes.push(`${b.unpricedTurns} turn(s) used a model with no configured price and are excluded from costUsd.`);
|
|
54
|
+
notes.push('Estimated locally by TokenFlow from the session logs already on this machine, against a local price table — never measured billing, and no prompt or code content was read.');
|
|
55
|
+
return notes;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Map one buildReceipts()/createReceiptBuilder() branch entry into the
|
|
60
|
+
* portable receipt.v0 shape (schemas/receipt.v0.json).
|
|
61
|
+
* @param {object} b one branch receipt (an entry of `result.repos[i].branches`)
|
|
62
|
+
* @param {{repo:string, headSha:string, toolVersion:string, generatedAt?:string}} meta
|
|
63
|
+
* @returns {object} a receipt.v0 object
|
|
64
|
+
*/
|
|
65
|
+
export function toReceiptV0(b, meta) {
|
|
66
|
+
const hasWindow = (b.first !== null && b.first !== undefined) || (b.last !== null && b.last !== undefined);
|
|
67
|
+
return {
|
|
68
|
+
schemaVersion: RECEIPT_SCHEMA_VERSION,
|
|
69
|
+
generatedAt: meta.generatedAt || new Date().toISOString(),
|
|
70
|
+
toolVersion: meta.toolVersion,
|
|
71
|
+
repo: meta.repo,
|
|
72
|
+
branch: b.key,
|
|
73
|
+
headSha: meta.headSha,
|
|
74
|
+
window: hasWindow ? { first: b.first ?? null, last: b.last ?? null } : null,
|
|
75
|
+
costUsd: b.cost ?? null,
|
|
76
|
+
contextShare: b.contextShare ?? null,
|
|
77
|
+
turns: b.turns,
|
|
78
|
+
sessions: b.sessions,
|
|
79
|
+
subagentTurns: b.subagentTurns,
|
|
80
|
+
models: (b.models || []).map((m) => ({ model: m.model, costUsd: m.cost ?? null, share: m.share ?? null })),
|
|
81
|
+
coverage: b.coverage ?? null,
|
|
82
|
+
largestPromptTokens: b.maxPrompt ?? null,
|
|
83
|
+
changedLines: b.changedLines ?? null,
|
|
84
|
+
pr: b.pr ? { number: b.pr.number, mergedAt: b.pr.mergedAt ?? null } : null,
|
|
85
|
+
longLived: !!b.longLived,
|
|
86
|
+
costPer100Lines: b.costPer100Lines ?? null,
|
|
87
|
+
notes: buildNotes(b),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
92
|
+
const isNullableNum = (v) => v === null || isNum(v);
|
|
93
|
+
const isShare = (v) => v === null || (isNum(v) && v >= 0 && v <= 1);
|
|
94
|
+
const isInt = (v) => Number.isInteger(v);
|
|
95
|
+
const isNullableNonNegInt = (v) => v === null || (isInt(v) && v >= 0);
|
|
96
|
+
const isStr = (v) => typeof v === 'string';
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Hand-written validator for the receipt.v0 shape — no dependencies, mirrors
|
|
100
|
+
* schemas/receipt.v0.json field-for-field.
|
|
101
|
+
* @param {*} obj
|
|
102
|
+
* @returns {{ok:boolean, errors:string[]}}
|
|
103
|
+
*/
|
|
104
|
+
export function validateReceiptV0(obj) {
|
|
105
|
+
const errors = [];
|
|
106
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
107
|
+
return { ok: false, errors: ['receipt must be an object'] };
|
|
108
|
+
}
|
|
109
|
+
for (const key of REQUIRED_FIELDS) {
|
|
110
|
+
if (!(key in obj)) errors.push(`missing required field "${key}"`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if ('schemaVersion' in obj && obj.schemaVersion !== RECEIPT_SCHEMA_VERSION) errors.push(`schemaVersion must be ${RECEIPT_SCHEMA_VERSION}`);
|
|
114
|
+
if ('generatedAt' in obj && !isStr(obj.generatedAt)) errors.push('generatedAt must be a string');
|
|
115
|
+
if ('toolVersion' in obj && !isStr(obj.toolVersion)) errors.push('toolVersion must be a string');
|
|
116
|
+
if ('repo' in obj && !(isStr(obj.repo) && obj.repo.length > 0)) errors.push('repo must be a non-empty string');
|
|
117
|
+
if ('branch' in obj && !(isStr(obj.branch) && obj.branch.length > 0)) errors.push('branch must be a non-empty string');
|
|
118
|
+
if ('headSha' in obj && !(isStr(obj.headSha) && /^[0-9a-f]{7,40}$/.test(obj.headSha))) errors.push('headSha must be a 7-40 char lowercase hex sha');
|
|
119
|
+
|
|
120
|
+
if ('window' in obj) {
|
|
121
|
+
const w = obj.window;
|
|
122
|
+
if (w !== null) {
|
|
123
|
+
if (typeof w !== 'object' || Array.isArray(w)) errors.push('window must be an object or null');
|
|
124
|
+
else {
|
|
125
|
+
if (!('first' in w) || !('last' in w)) errors.push('window must have "first" and "last"');
|
|
126
|
+
if ('first' in w && w.first !== null && !isStr(w.first)) errors.push('window.first must be a string or null');
|
|
127
|
+
if ('last' in w && w.last !== null && !isStr(w.last)) errors.push('window.last must be a string or null');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if ('costUsd' in obj && !isNullableNum(obj.costUsd)) errors.push('costUsd must be a number or null');
|
|
133
|
+
if ('contextShare' in obj && !isShare(obj.contextShare)) errors.push('contextShare must be null or a number between 0 and 1');
|
|
134
|
+
if ('turns' in obj && !isInt(obj.turns)) errors.push('turns must be an integer');
|
|
135
|
+
if ('sessions' in obj && !isInt(obj.sessions)) errors.push('sessions must be an integer');
|
|
136
|
+
if ('subagentTurns' in obj && !isInt(obj.subagentTurns)) errors.push('subagentTurns must be an integer');
|
|
137
|
+
|
|
138
|
+
if ('models' in obj) {
|
|
139
|
+
if (!Array.isArray(obj.models)) errors.push('models must be an array');
|
|
140
|
+
else obj.models.forEach((m, i) => {
|
|
141
|
+
if (typeof m !== 'object' || m === null || Array.isArray(m)) { errors.push(`models[${i}] must be an object`); return; }
|
|
142
|
+
if (!isStr(m.model)) errors.push(`models[${i}].model must be a string`);
|
|
143
|
+
if (!isNullableNum(m.costUsd)) errors.push(`models[${i}].costUsd must be a number or null`);
|
|
144
|
+
if (!isShare(m.share)) errors.push(`models[${i}].share must be null or a number between 0 and 1`);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if ('coverage' in obj && !isShare(obj.coverage)) errors.push('coverage must be null or a number between 0 and 1');
|
|
149
|
+
if ('largestPromptTokens' in obj && !isNullableNonNegInt(obj.largestPromptTokens)) errors.push('largestPromptTokens must be null or a non-negative integer');
|
|
150
|
+
if ('changedLines' in obj && !isNullableNonNegInt(obj.changedLines)) errors.push('changedLines must be null or a non-negative integer');
|
|
151
|
+
|
|
152
|
+
if ('pr' in obj) {
|
|
153
|
+
const pr = obj.pr;
|
|
154
|
+
if (pr !== null) {
|
|
155
|
+
if (typeof pr !== 'object' || Array.isArray(pr)) errors.push('pr must be an object or null');
|
|
156
|
+
else {
|
|
157
|
+
if (!isInt(pr.number)) errors.push('pr.number must be an integer');
|
|
158
|
+
if (!(pr.mergedAt === null || isStr(pr.mergedAt))) errors.push('pr.mergedAt must be a string or null');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if ('longLived' in obj && typeof obj.longLived !== 'boolean') errors.push('longLived must be a boolean');
|
|
164
|
+
if ('costPer100Lines' in obj && !isNullableNum(obj.costPer100Lines)) errors.push('costPer100Lines must be a number or null');
|
|
165
|
+
|
|
166
|
+
if ('notes' in obj) {
|
|
167
|
+
if (!Array.isArray(obj.notes)) errors.push('notes must be an array');
|
|
168
|
+
else obj.notes.forEach((n, i) => { if (!isStr(n)) errors.push(`notes[${i}] must be a string`); });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { ok: errors.length === 0, errors };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const money = (v) => (v === null || v === undefined ? '—' : usd(v));
|
|
175
|
+
const share = (v) => (v === null || v === undefined ? '—' : pct(v, 0));
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A receipt.v0 object rendered for a PR comment — the same table shape as
|
|
179
|
+
* renderReceiptMarkdown() in src/analytics/receipt.js, plus a leading
|
|
180
|
+
* `<!-- tokenflow-receipt -->` marker so CI can find and update its own
|
|
181
|
+
* comment instead of piling up a new one per push.
|
|
182
|
+
* @param {object} receipt a receipt.v0 object (see toReceiptV0())
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
export function renderReceiptV0Markdown(receipt) {
|
|
186
|
+
const L = [];
|
|
187
|
+
L.push('<!-- tokenflow-receipt -->');
|
|
188
|
+
const title = receipt.pr ? `\`${receipt.branch}\` · PR #${receipt.pr.number}` : `\`${receipt.branch}\``;
|
|
189
|
+
L.push(`### 🧾 AI cost receipt — ${title}`);
|
|
190
|
+
if (receipt.repo) L.push(`_${receipt.repo}_`);
|
|
191
|
+
L.push('');
|
|
192
|
+
L.push('| | |');
|
|
193
|
+
L.push('|---|---|');
|
|
194
|
+
const costLine = receipt.costUsd === null
|
|
195
|
+
? '— (no priced turns)'
|
|
196
|
+
: `**${money(receipt.costUsd)}**${receipt.contextShare !== null ? ` · ${share(receipt.contextShare)} re-sent context, ${share(1 - receipt.contextShare)} fresh work` : ''}`;
|
|
197
|
+
L.push(`| Estimated spend | ${costLine} |`);
|
|
198
|
+
const subagentShare = receipt.turns > 0 ? receipt.subagentTurns / receipt.turns : null;
|
|
199
|
+
L.push(`| Sessions · turns | ${receipt.sessions} · ${receipt.turns}${subagentShare !== null && receipt.subagentTurns > 0 ? ` (${share(subagentShare)} subagent)` : ''} |`);
|
|
200
|
+
if (receipt.models.length) {
|
|
201
|
+
L.push(`| Models | ${receipt.models.slice(0, 3).map((m) => `${m.model} ${share(m.share)}`).join(', ')}${receipt.models.length > 3 ? ', …' : ''} |`);
|
|
202
|
+
}
|
|
203
|
+
if (receipt.pr && receipt.changedLines !== null) {
|
|
204
|
+
L.push(`| Changed lines | ${receipt.changedLines}${receipt.costPer100Lines !== null ? ` → ${money(receipt.costPer100Lines)} per 100 lines` : ''} |`);
|
|
205
|
+
}
|
|
206
|
+
if (receipt.largestPromptTokens !== null) L.push(`| Largest prompt | ${compact(receipt.largestPromptTokens)} tokens |`);
|
|
207
|
+
if (receipt.window && receipt.window.first && receipt.window.last) {
|
|
208
|
+
const merged = receipt.pr && receipt.pr.mergedAt ? ` (merged ${shortDate(receipt.pr.mergedAt.slice(0, 10))})` : receipt.pr ? ' (PR open)' : '';
|
|
209
|
+
L.push(`| Window | ${shortDate(receipt.window.first.slice(0, 10))} → ${shortDate(receipt.window.last.slice(0, 10))}${merged} |`);
|
|
210
|
+
}
|
|
211
|
+
L.push('');
|
|
212
|
+
L.push(`<sub>${receipt.notes.join(' ')}</sub>`);
|
|
213
|
+
return L.join('\n');
|
|
214
|
+
}
|