@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,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tokenflow doctor` audit checks — the defects the 2026-09 data-quality audit
|
|
3
|
+
* found on a real store, expressed as automated checks so `doctor` reports
|
|
4
|
+
* them on every run instead of needing another manual pass.
|
|
5
|
+
*
|
|
6
|
+
* `auditChecks` takes a single pass over the last three months of records
|
|
7
|
+
* (`Store#scanRecords` already supports a `months` filter) and feeds every
|
|
8
|
+
* check below from that one scan, so adding a check never costs another scan.
|
|
9
|
+
* A store with more than `maxScanRecords` records in that window is sampled:
|
|
10
|
+
* the scan stops there and every affected check's detail says so.
|
|
11
|
+
*
|
|
12
|
+
* Each check returns `{ id, level, title, detail, fix }` as the task
|
|
13
|
+
* requires, plus a `data` object carrying the structured counts behind the
|
|
14
|
+
* human-readable `detail` string — `renderChecks` never reads `data`, but a
|
|
15
|
+
* caller that wants exact numbers (a test, a `--json` mode) does not have to
|
|
16
|
+
* parse the sentence back out.
|
|
17
|
+
*/
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { decodeRecord, readJson } from '../core/store.js';
|
|
20
|
+
import { repoRootOf } from '../core/repo.js';
|
|
21
|
+
import { buildPriceBook, PRICING_TABLE_VERSION } from '../core/pricing.js';
|
|
22
|
+
import { paths } from '../core/config.js';
|
|
23
|
+
import { int, compact, pct, usd } from '../core/units.js';
|
|
24
|
+
|
|
25
|
+
/** Hard ceiling on how much of the store one `doctor` run reads. */
|
|
26
|
+
const MAX_SCAN_RECORDS = 200000;
|
|
27
|
+
/** How many trailing months `scanRecords` is asked for. */
|
|
28
|
+
const MONTHS_BACK = 3;
|
|
29
|
+
/**
|
|
30
|
+
* Sources known to report one row per session (or per session×model), not per
|
|
31
|
+
* request/turn — so any per-turn statistic under-counts them. There is no
|
|
32
|
+
* registry-level flag for this (see src/core/registry.js#getMetadata), so this
|
|
33
|
+
* is a short, explicitly named list; today that is only Hermes
|
|
34
|
+
* (src/providers/hermes/index.js: "There is no per-request log to read").
|
|
35
|
+
*/
|
|
36
|
+
const SESSION_LEVEL_SOURCES = ['hermes'];
|
|
37
|
+
|
|
38
|
+
/** Age, in days, past which the built-in price table is flagged. */
|
|
39
|
+
const STALE_WARN_DAYS = 60;
|
|
40
|
+
const STALE_FAIL_DAYS = 180;
|
|
41
|
+
|
|
42
|
+
/** Share of a repo's spend hidden across worktrees before it is a `warn`. */
|
|
43
|
+
const WORKTREE_WARN_SHARE = 0.2;
|
|
44
|
+
/** Share of scanned tokens with no price before it is `warn` / `fail`. */
|
|
45
|
+
const UNPRICED_WARN_SHARE = 0.1;
|
|
46
|
+
const UNPRICED_FAIL_SHARE = 0.3;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `YYYY-MM` for `now`'s month and the `n - 1` months before it, newest first —
|
|
50
|
+
* the same granularity `Store#shardFor` uses, so it can be passed straight to
|
|
51
|
+
* `scanRecords`'s `months` filter.
|
|
52
|
+
* @param {Date} now
|
|
53
|
+
* @param {number} n
|
|
54
|
+
* @returns {string[]}
|
|
55
|
+
*/
|
|
56
|
+
function lastMonths(now, n) {
|
|
57
|
+
const out = [];
|
|
58
|
+
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
59
|
+
for (let i = 0; i < n; i++) {
|
|
60
|
+
out.push(`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`);
|
|
61
|
+
d.setUTCMonth(d.getUTCMonth() - 1);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function round2(n) {
|
|
67
|
+
return Math.round(n * 100) / 100;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function withScanNote(detail, truncated, maxScanRecords) {
|
|
71
|
+
return truncated ? `${detail} (capped at ${int(maxScanRecords)} records scanned — some may be missed)` : detail;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @typedef {{id:string, level:'ok'|'info'|'warn'|'fail', title:string, detail:string, fix:string|null, data:object}} DoctorCheck */
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Run every audit check over the last `MONTHS_BACK` months of the store.
|
|
78
|
+
*
|
|
79
|
+
* @param {{store: import('../core/store.js').Store, config?: object, now?: Date, maxScanRecords?: number}} opt
|
|
80
|
+
* `config` is accepted for interface parity with the rest of the `doctor`
|
|
81
|
+
* surface (and in case a future check needs a configured preference); no
|
|
82
|
+
* check currently reads it. `maxScanRecords` defaults to `MAX_SCAN_RECORDS`
|
|
83
|
+
* and exists mainly so a test can exercise the cap cheaply.
|
|
84
|
+
* @returns {DoctorCheck[]}
|
|
85
|
+
*/
|
|
86
|
+
export function auditChecks({ store, config = {}, now = new Date(), maxScanRecords = MAX_SCAN_RECORDS }) {
|
|
87
|
+
const months = lastMonths(now, MONTHS_BACK);
|
|
88
|
+
const repoCache = new Map();
|
|
89
|
+
const book = buildPriceBook(readJson(paths().pricing, {}));
|
|
90
|
+
|
|
91
|
+
/** trueRepo (basename of the resolved main checkout) -> aggregate */
|
|
92
|
+
const worktree = new Map();
|
|
93
|
+
/** records whose cwd resolves to no repository at all */
|
|
94
|
+
const cwdBasename = { count: 0, names: new Map() };
|
|
95
|
+
/** month ('YYYY-MM') -> { total, noBranch } for openai-source primary records */
|
|
96
|
+
const codex = new Map();
|
|
97
|
+
/** model -> { tokens, provider } for primary-measurement records */
|
|
98
|
+
const modelTokens = new Map();
|
|
99
|
+
let totalPricedScopeTokens = 0;
|
|
100
|
+
/** session-level source id -> record count */
|
|
101
|
+
const sessionLevel = new Map();
|
|
102
|
+
let repoResolvedFieldSeen = false;
|
|
103
|
+
let repoResolvedFalse = 0;
|
|
104
|
+
|
|
105
|
+
let scanned = 0;
|
|
106
|
+
let truncated = false;
|
|
107
|
+
store.scanRecords((o) => {
|
|
108
|
+
scanned++;
|
|
109
|
+
if (scanned > maxScanRecords) { truncated = true; return false; }
|
|
110
|
+
|
|
111
|
+
const r = decodeRecord(o);
|
|
112
|
+
const md = r.metadata || {};
|
|
113
|
+
|
|
114
|
+
// (a) / (b) — repo identity derived from the recorded cwd.
|
|
115
|
+
if (md.cwd) {
|
|
116
|
+
const root = repoRootOf(md.cwd, repoCache);
|
|
117
|
+
if (root) {
|
|
118
|
+
const trueRepo = path.basename(root);
|
|
119
|
+
let g = worktree.get(trueRepo);
|
|
120
|
+
if (!g) { g = { totalRecords: 0, totalSpend: 0, misfiledRecords: 0, misfiledSpend: 0 }; worktree.set(trueRepo, g); }
|
|
121
|
+
const spend = r.estimated_cost ?? 0;
|
|
122
|
+
g.totalRecords++;
|
|
123
|
+
g.totalSpend += spend;
|
|
124
|
+
if ((r.project || null) !== trueRepo) {
|
|
125
|
+
g.misfiledRecords++;
|
|
126
|
+
g.misfiledSpend += spend;
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
cwdBasename.count++;
|
|
130
|
+
const name = r.project || path.basename(md.cwd);
|
|
131
|
+
cwdBasename.names.set(name, (cwdBasename.names.get(name) || 0) + 1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// (c) — Codex (the openai adapter) records carrying no git_branch.
|
|
136
|
+
if (r.source === 'openai' && r.measurement === 'primary') {
|
|
137
|
+
const month = (r.date || '').slice(0, 7);
|
|
138
|
+
if (months.includes(month)) {
|
|
139
|
+
let c = codex.get(month);
|
|
140
|
+
if (!c) { c = { total: 0, noBranch: 0 }; codex.set(month, c); }
|
|
141
|
+
c.total++;
|
|
142
|
+
if (!r.git_branch) c.noBranch++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// (d) — token volume per model, primary measurement only (the app's
|
|
147
|
+
// default in-scope view; overlay records are excluded from totals
|
|
148
|
+
// everywhere else and would double-count here too).
|
|
149
|
+
if (r.measurement === 'primary' && r.total_tokens !== null && r.total_tokens !== undefined) {
|
|
150
|
+
totalPricedScopeTokens += r.total_tokens;
|
|
151
|
+
const m = modelTokens.get(r.model);
|
|
152
|
+
if (m) m.tokens += r.total_tokens;
|
|
153
|
+
else modelTokens.set(r.model, { tokens: r.total_tokens, provider: r.provider });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// (f) — session-level sources present in this window.
|
|
157
|
+
if (SESSION_LEVEL_SOURCES.includes(r.source)) {
|
|
158
|
+
sessionLevel.set(r.source, (sessionLevel.get(r.source) || 0) + 1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// (g) — an explicit metadata.repoResolved === false marker, if present.
|
|
162
|
+
// Absence is normal (no adapter sets it yet) and must not be an error.
|
|
163
|
+
if (Object.prototype.hasOwnProperty.call(md, 'repoResolved')) {
|
|
164
|
+
repoResolvedFieldSeen = true;
|
|
165
|
+
if (md.repoResolved === false) repoResolvedFalse++;
|
|
166
|
+
}
|
|
167
|
+
}, { months });
|
|
168
|
+
|
|
169
|
+
return [
|
|
170
|
+
checkWorktreeSplit(worktree, truncated, maxScanRecords),
|
|
171
|
+
checkCwdBasename(cwdBasename, truncated, maxScanRecords),
|
|
172
|
+
checkCodexBranch(codex, truncated, maxScanRecords),
|
|
173
|
+
checkUnpricedModels(modelTokens, totalPricedScopeTokens, book, truncated, maxScanRecords),
|
|
174
|
+
checkStalePriceTable(now),
|
|
175
|
+
checkSessionLevelSources(sessionLevel),
|
|
176
|
+
checkRepoResolvedFalse(repoResolvedFieldSeen, repoResolvedFalse),
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @returns {DoctorCheck} */
|
|
181
|
+
function checkWorktreeSplit(worktree, truncated, maxScanRecords) {
|
|
182
|
+
const groups = [...worktree.entries()]
|
|
183
|
+
.filter(([, g]) => g.misfiledRecords > 0)
|
|
184
|
+
.map(([repo, g]) => ({
|
|
185
|
+
repo,
|
|
186
|
+
misfiledRecords: g.misfiledRecords,
|
|
187
|
+
misfiledSpend: round2(g.misfiledSpend),
|
|
188
|
+
totalRecords: g.totalRecords,
|
|
189
|
+
totalSpend: round2(g.totalSpend),
|
|
190
|
+
// Share of THIS repo's own resolved spend hidden across worktree names —
|
|
191
|
+
// not a share of the whole store, which would read as near-zero for
|
|
192
|
+
// every individual repo regardless of how badly it is fragmented.
|
|
193
|
+
share: g.totalSpend > 0 ? g.misfiledSpend / g.totalSpend : null,
|
|
194
|
+
}))
|
|
195
|
+
.sort((a, b) => b.misfiledSpend - a.misfiledSpend);
|
|
196
|
+
const top = groups.slice(0, 5);
|
|
197
|
+
const totalMisfiledRecords = groups.reduce((a, g) => a + g.misfiledRecords, 0);
|
|
198
|
+
const totalMisfiledSpend = round2(groups.reduce((a, g) => a + g.misfiledSpend, 0));
|
|
199
|
+
const level = groups.length === 0 ? 'ok' : groups.some((g) => g.share !== null && g.share >= WORKTREE_WARN_SHARE) ? 'warn' : 'info';
|
|
200
|
+
const detail = groups.length
|
|
201
|
+
? `${groups.length} repo(s) split across git worktrees — ${int(totalMisfiledRecords)} record(s) / ${usd(totalMisfiledSpend)} filed under a worktree name instead of the repo: `
|
|
202
|
+
+ top.map((g) => `${g.repo} (${int(g.misfiledRecords)} rec, ${usd(g.misfiledSpend)}${g.share !== null ? `, ${pct(g.share)} of its spend` : ''})`).join('; ')
|
|
203
|
+
: 'no worktree-split projects found in the scanned window';
|
|
204
|
+
return {
|
|
205
|
+
id: 'worktree-split-projects',
|
|
206
|
+
level,
|
|
207
|
+
title: "Git worktrees fragmenting a repo's spend",
|
|
208
|
+
detail: withScanNote(detail, truncated, maxScanRecords),
|
|
209
|
+
fix: groups.length
|
|
210
|
+
? 'adapters record project as basename(cwd); resolve through repoRootOf() (src/core/repo.js) before filing a record, or merge worktrees by resolved repo before reporting.'
|
|
211
|
+
: null,
|
|
212
|
+
data: { groups: top, totalMisfiledRecords, totalMisfiledSpend, repoCount: groups.length },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @returns {DoctorCheck} */
|
|
217
|
+
function checkCwdBasename(acc, truncated, maxScanRecords) {
|
|
218
|
+
const names = [...acc.names.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([name, count]) => ({ name, count }));
|
|
219
|
+
const level = acc.count === 0 ? 'ok' : 'info';
|
|
220
|
+
const detail = acc.count
|
|
221
|
+
? `${int(acc.count)} record(s) have a cwd outside any git repository, so their project is just a directory name: `
|
|
222
|
+
+ names.map((n) => `${n.name} (${int(n.count)})`).join(', ')
|
|
223
|
+
: 'every recorded cwd resolves inside a repository';
|
|
224
|
+
return {
|
|
225
|
+
id: 'cwd-basename-projects',
|
|
226
|
+
level,
|
|
227
|
+
title: 'Projects that are really just a directory name',
|
|
228
|
+
detail: withScanNote(detail, truncated, maxScanRecords),
|
|
229
|
+
fix: acc.count
|
|
230
|
+
? 'expected for ad hoc / non-repo directories; if any name above is actually a repo, confirm it has a .git it can see (or a worktree gitdir pointing at one).'
|
|
231
|
+
: null,
|
|
232
|
+
data: { count: acc.count, topNames: names },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** @returns {DoctorCheck} */
|
|
237
|
+
function checkCodexBranch(codex, truncated, maxScanRecords) {
|
|
238
|
+
const monthRows = [...codex.entries()]
|
|
239
|
+
.sort((a, b) => (a[0] < b[0] ? 1 : -1))
|
|
240
|
+
.map(([month, c]) => ({ month, total: c.total, noBranch: c.noBranch, share: c.total ? c.noBranch / c.total : null }));
|
|
241
|
+
const totalRecords = monthRows.reduce((a, m) => a + m.total, 0);
|
|
242
|
+
const noBranchRecords = monthRows.reduce((a, m) => a + m.noBranch, 0);
|
|
243
|
+
const overallShare = totalRecords ? noBranchRecords / totalRecords : null;
|
|
244
|
+
/** @type {'ok'|'info'|'warn'|'fail'} */
|
|
245
|
+
let level = 'ok';
|
|
246
|
+
if (totalRecords > 0) {
|
|
247
|
+
if (overallShare >= 0.99) level = 'fail';
|
|
248
|
+
else if (overallShare > 0) level = 'warn';
|
|
249
|
+
}
|
|
250
|
+
const detail = totalRecords === 0
|
|
251
|
+
? 'no Codex (openai-source) records in the scanned window'
|
|
252
|
+
: `${pct(overallShare)} of ${int(totalRecords)} Codex record(s) have no git_branch — `
|
|
253
|
+
+ monthRows.map((m) => `${m.month}: ${pct(m.share)} of ${int(m.total)}`).join(', ');
|
|
254
|
+
return {
|
|
255
|
+
id: 'codex-missing-branch',
|
|
256
|
+
level,
|
|
257
|
+
title: 'Codex records cannot be attributed to a branch',
|
|
258
|
+
detail: withScanNote(detail, truncated, maxScanRecords),
|
|
259
|
+
fix: noBranchRecords > 0
|
|
260
|
+
? 'src/providers/openai/index.js does not record git_branch; derive it from the rollout\'s turn_context/cwd via git, the way the worktree resolver already does.'
|
|
261
|
+
: null,
|
|
262
|
+
data: { months: monthRows, totalRecords, noBranchRecords, overallShare },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** @returns {DoctorCheck} */
|
|
267
|
+
function checkUnpricedModels(modelTokens, totalTokens, book, truncated, maxScanRecords) {
|
|
268
|
+
const unpriced = [];
|
|
269
|
+
for (const [model, m] of modelTokens.entries()) {
|
|
270
|
+
if (!m.tokens) continue;
|
|
271
|
+
if (book.lookup(model, m.provider) === null) unpriced.push({ model, tokens: m.tokens, provider: m.provider });
|
|
272
|
+
}
|
|
273
|
+
unpriced.sort((a, b) => b.tokens - a.tokens);
|
|
274
|
+
const unpricedTokens = unpriced.reduce((a, m) => a + m.tokens, 0);
|
|
275
|
+
const share = totalTokens ? unpricedTokens / totalTokens : null;
|
|
276
|
+
/** @type {'ok'|'info'|'warn'|'fail'} */
|
|
277
|
+
let level = 'ok';
|
|
278
|
+
if (unpriced.length > 0) {
|
|
279
|
+
if (share !== null && share >= UNPRICED_FAIL_SHARE) level = 'fail';
|
|
280
|
+
else if (share !== null && share >= UNPRICED_WARN_SHARE) level = 'warn';
|
|
281
|
+
else level = 'info';
|
|
282
|
+
}
|
|
283
|
+
const top = unpriced.slice(0, 10).map((m) => ({ ...m, share: totalTokens ? m.tokens / totalTokens : null }));
|
|
284
|
+
const detail = unpriced.length
|
|
285
|
+
? `${unpriced.length} model(s) with no price cover ${pct(share)} of scanned tokens — `
|
|
286
|
+
+ top.slice(0, 5).map((m) => `${m.model} (${compact(m.tokens)}, ${pct(m.share)})`).join(', ')
|
|
287
|
+
: 'every model with token volume in the scanned window has a price';
|
|
288
|
+
return {
|
|
289
|
+
id: 'unpriced-models',
|
|
290
|
+
level,
|
|
291
|
+
title: 'Unpriced models',
|
|
292
|
+
detail: withScanNote(detail, truncated, maxScanRecords),
|
|
293
|
+
fix: unpriced.length
|
|
294
|
+
? 'tokenflow pricing --set "<model>=<input$/1M>,<output$/1M>" for the models above, or tokenflow pricing diff <table.json> --apply once you have rates.'
|
|
295
|
+
: null,
|
|
296
|
+
data: { models: top, unpricedCount: unpriced.length, unpricedTokens, totalTokens, share },
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** @returns {DoctorCheck} */
|
|
301
|
+
function checkStalePriceTable(now) {
|
|
302
|
+
const versionDate = new Date(`${PRICING_TABLE_VERSION}T00:00:00Z`);
|
|
303
|
+
const ageDays = Number.isNaN(versionDate.getTime()) ? null : Math.floor((now.getTime() - versionDate.getTime()) / 86400000);
|
|
304
|
+
/** @type {'ok'|'info'|'warn'|'fail'} */
|
|
305
|
+
let level = 'ok';
|
|
306
|
+
if (ageDays === null) level = 'warn';
|
|
307
|
+
else if (ageDays > STALE_FAIL_DAYS) level = 'fail';
|
|
308
|
+
else if (ageDays > STALE_WARN_DAYS) level = 'warn';
|
|
309
|
+
const detail = ageDays === null
|
|
310
|
+
? `could not parse the price table version "${PRICING_TABLE_VERSION}"`
|
|
311
|
+
: `built-in price table ${PRICING_TABLE_VERSION} is ${int(ageDays)} day(s) old`;
|
|
312
|
+
return {
|
|
313
|
+
id: 'stale-price-table',
|
|
314
|
+
level,
|
|
315
|
+
title: 'Built-in price table freshness',
|
|
316
|
+
detail,
|
|
317
|
+
fix: level !== 'ok'
|
|
318
|
+
? 'refresh BUILTIN_PRICES / PRICING_TABLE_VERSION in src/core/pricing.js against current vendor pricing pages, or apply an updated table with tokenflow pricing diff --apply.'
|
|
319
|
+
: null,
|
|
320
|
+
data: { version: PRICING_TABLE_VERSION, ageDays },
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** @returns {DoctorCheck} */
|
|
325
|
+
function checkSessionLevelSources(sessionLevel) {
|
|
326
|
+
const sources = [...sessionLevel.entries()].map(([id, count]) => ({ id, count }));
|
|
327
|
+
const level = sources.length ? 'info' : 'ok';
|
|
328
|
+
const detail = sources.length
|
|
329
|
+
? `session-level source(s) present — one row per session, not per request/turn, so per-turn views under-count them: `
|
|
330
|
+
+ sources.map((s) => `${s.id} (${int(s.count)} record(s))`).join(', ')
|
|
331
|
+
: 'no session-level sources in the scanned window';
|
|
332
|
+
return {
|
|
333
|
+
id: 'session-level-sources',
|
|
334
|
+
level,
|
|
335
|
+
title: 'Session-level sources present',
|
|
336
|
+
detail,
|
|
337
|
+
fix: sources.length ? 'filter these sources out of any per-turn/per-request statistic (see src/analytics/receipt.js for the pattern).' : null,
|
|
338
|
+
data: { sources },
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** @returns {DoctorCheck} */
|
|
343
|
+
function checkRepoResolvedFalse(seen, count) {
|
|
344
|
+
const level = seen && count > 0 ? 'warn' : 'ok';
|
|
345
|
+
const detail = !seen
|
|
346
|
+
? 'no record carries a metadata.repoResolved marker (not set by anything in this store)'
|
|
347
|
+
: count > 0
|
|
348
|
+
? `${int(count)} record(s) carry metadata.repoResolved === false`
|
|
349
|
+
: 'metadata.repoResolved is present and never false on the records seen';
|
|
350
|
+
return {
|
|
351
|
+
id: 'repo-resolved-false',
|
|
352
|
+
level,
|
|
353
|
+
title: 'Records flagged as unresolved by repository',
|
|
354
|
+
detail,
|
|
355
|
+
fix: count > 0 ? 'these records could not be attributed to a repository by whatever set the marker; see its source for why.' : null,
|
|
356
|
+
data: { present: seen, count },
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const MARK = { ok: '✓', info: '○', warn: '!', fail: '✗' };
|
|
361
|
+
const TITLE_COL = 44;
|
|
362
|
+
|
|
363
|
+
function colorFor(level) {
|
|
364
|
+
if (level === 'ok') return 'g';
|
|
365
|
+
if (level === 'info') return 'dim';
|
|
366
|
+
if (level === 'warn') return 'y';
|
|
367
|
+
return 'red';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function pad(s, n) {
|
|
371
|
+
const t = String(s ?? '');
|
|
372
|
+
return t.length >= n ? t : t + ' '.repeat(n - t.length);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Format the checks the way `cmdDoctor`'s existing lines read: one line per
|
|
377
|
+
* check (mark, title, detail), with a dim `fix:` continuation line only when
|
|
378
|
+
* the check is not clean — the same shape `cmdRefresh` uses for its notes.
|
|
379
|
+
* @param {ReturnType<typeof auditChecks>} rows
|
|
380
|
+
* @param {{print?: boolean, color?: boolean}} [opt] `print: false` returns the
|
|
381
|
+
* text without writing it to stdout — useful for tests and for a future
|
|
382
|
+
* `--json`-adjacent text capture.
|
|
383
|
+
* @returns {string}
|
|
384
|
+
*/
|
|
385
|
+
export function renderChecks(rows, opt = {}) {
|
|
386
|
+
const useColor = opt.color ?? (typeof process !== 'undefined' && !!(process.stdout && process.stdout.isTTY) && !process.env.NO_COLOR);
|
|
387
|
+
const C = useColor
|
|
388
|
+
? { r: '\x1b[0m', dim: '\x1b[2m', g: '\x1b[32m', y: '\x1b[33m', red: '\x1b[31m' }
|
|
389
|
+
: { r: '', dim: '', g: '', y: '', red: '' };
|
|
390
|
+
const lines = [];
|
|
391
|
+
for (const row of rows) {
|
|
392
|
+
const mark = MARK[row.level] || '?';
|
|
393
|
+
const color = C[colorFor(row.level)];
|
|
394
|
+
lines.push(` ${color}${mark}${C.r} ${pad(row.title, TITLE_COL)} ${C.dim}${row.detail || ''}${C.r}`);
|
|
395
|
+
if (row.fix && row.level !== 'ok') lines.push(` ${C.dim}fix: ${row.fix}${C.r}`);
|
|
396
|
+
}
|
|
397
|
+
const text = lines.join('\n');
|
|
398
|
+
if (opt.print !== false) console.log(text);
|
|
399
|
+
return text;
|
|
400
|
+
}
|