@vimoxshah/tokenflow 1.1.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/CONTRIBUTING.md +84 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/Refresh & Open Dashboard.command +22 -0
- package/SECURITY.md +42 -0
- package/bin/tokenflow.js +1342 -0
- package/docs/architecture.md +193 -0
- package/docs/cli.md +390 -0
- package/docs/configuration.md +281 -0
- package/docs/creating-provider.md +262 -0
- package/docs/data-model.md +213 -0
- package/docs/getting-started.md +266 -0
- package/docs/live-mode.md +199 -0
- package/docs/media/architecture-hero.svg +86 -0
- package/docs/media/cost-editorial-dark.png +0 -0
- package/docs/media/health-terminal-light.png +0 -0
- package/docs/media/menubar-dark.png +0 -0
- package/docs/media/menubar-light.png +0 -0
- package/docs/media/models-terminal-dark.png +0 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/time-aurora-light.png +0 -0
- package/docs/providers.md +309 -0
- package/docs/skill.md +64 -0
- package/docs/troubleshooting.md +207 -0
- package/examples/config.example.yaml +92 -0
- package/examples/demo-data/README.md +38 -0
- package/examples/demo-data/sample-usage.csv +11 -0
- package/package.json +74 -0
- package/scripts/build-dmg.sh +33 -0
- package/scripts/build-menubar-app.sh +67 -0
- package/scripts/lint.js +111 -0
- package/scripts/validate-install.js +140 -0
- package/skills/tokenflow/SKILL.md +392 -0
- package/skills/tokenflow/examples/config.yaml +92 -0
- package/skills/tokenflow/examples/generic-mapping.json +26 -0
- package/skills/tokenflow/examples/session-transcript.md +191 -0
- package/skills/tokenflow/providers/adapter-template.js +135 -0
- package/skills/tokenflow/providers/detection-matrix.md +142 -0
- package/skills/tokenflow/schemas/config.schema.json +107 -0
- package/skills/tokenflow/schemas/normalized-record.json +63 -0
- package/src/analytics/aggregate.js +247 -0
- package/src/analytics/anomalies.js +222 -0
- package/src/analytics/capacity.js +278 -0
- package/src/analytics/comparison.js +96 -0
- package/src/analytics/dimensions.js +230 -0
- package/src/analytics/efficiency.js +138 -0
- package/src/analytics/forecast.js +202 -0
- package/src/analytics/index.js +327 -0
- package/src/analytics/insights.js +283 -0
- package/src/analytics/milestones.js +91 -0
- package/src/analytics/peak.js +106 -0
- package/src/analytics/productivity.js +166 -0
- package/src/analytics/token-usage.js +267 -0
- package/src/commands/diagnostics.js +88 -0
- package/src/commands/digest.js +155 -0
- package/src/commands/models-compare.js +96 -0
- package/src/core/budget.js +142 -0
- package/src/core/bundle.js +191 -0
- package/src/core/config.js +202 -0
- package/src/core/delivery.js +109 -0
- package/src/core/geo.js +99 -0
- package/src/core/ingest.js +457 -0
- package/src/core/interface-map.js +55 -0
- package/src/core/jsonl.js +124 -0
- package/src/core/live-status.js +417 -0
- package/src/core/model-map.js +157 -0
- package/src/core/notify.js +83 -0
- package/src/core/pricing.js +288 -0
- package/src/core/prompt-analytics.js +127 -0
- package/src/core/registry.js +107 -0
- package/src/core/restore.js +261 -0
- package/src/core/schedule.js +120 -0
- package/src/core/schema.js +316 -0
- package/src/core/sqlite.js +96 -0
- package/src/core/store.js +493 -0
- package/src/core/sync.js +151 -0
- package/src/core/units.js +147 -0
- package/src/core/validate.js +123 -0
- package/src/core/watch.js +287 -0
- package/src/core/yaml.js +209 -0
- package/src/export/bundler.js +107 -0
- package/src/export/csv.js +100 -0
- package/src/export/html-snapshot.js +101 -0
- package/src/export/menubar.js +158 -0
- package/src/index.js +18 -0
- package/src/providers/anthropic/index.js +294 -0
- package/src/providers/cline/index.js +120 -0
- package/src/providers/cursor/index.js +143 -0
- package/src/providers/generic/index.js +268 -0
- package/src/providers/git/index.js +188 -0
- package/src/providers/headroom/index.js +114 -0
- package/src/providers/hermes/index.js +299 -0
- package/src/providers/mock/index.js +117 -0
- package/src/providers/openai/index.js +370 -0
- package/src/providers/opencode/index.js +245 -0
- package/src/sdk.js +46 -0
- package/src/server/server.js +264 -0
- package/src/ui/app.js +2473 -0
- package/src/ui/charts.js +925 -0
- package/src/ui/index.html +42 -0
- package/src/ui/styles.css +644 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming line reader tuned for very large append-only logs.
|
|
3
|
+
*
|
|
4
|
+
* Two things matter for a 1.5 GB corpus of session transcripts:
|
|
5
|
+
* 1. never hold a whole file in memory;
|
|
6
|
+
* 2. never JSON.parse a line you don't need — a cheap substring prefilter
|
|
7
|
+
* skips the ~85% of transcript lines that carry no usage block, which is
|
|
8
|
+
* the difference between a 20-second and a 3-minute refresh.
|
|
9
|
+
*
|
|
10
|
+
* `readLines` returns the byte offset of the last COMPLETE line so an
|
|
11
|
+
* interrupted or still-being-written file can be resumed exactly.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
|
|
15
|
+
const NL = 0x0a;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} file
|
|
19
|
+
* @param {(line:string, offsetAfter:number)=>void} onLine
|
|
20
|
+
* @param {{start?:number, must?:string[], chunkSize?:number, maxBytes?:number}} [opt]
|
|
21
|
+
* must: if given, a line is only decoded+delivered when it contains at least
|
|
22
|
+
* one of these ASCII substrings (checked on the raw buffer).
|
|
23
|
+
* @returns {{offset:number, bytes:number, lines:number, delivered:number, truncated:boolean}}
|
|
24
|
+
*/
|
|
25
|
+
export function readLines(file, onLine, opt = {}) {
|
|
26
|
+
const start = opt.start || 0;
|
|
27
|
+
const chunkSize = opt.chunkSize || 1 << 20;
|
|
28
|
+
const maxBytes = opt.maxBytes ?? Infinity;
|
|
29
|
+
const must = (opt.must || []).map((s) => Buffer.from(s, 'latin1'));
|
|
30
|
+
|
|
31
|
+
const fd = fs.openSync(file, 'r');
|
|
32
|
+
try {
|
|
33
|
+
const stat = fs.fstatSync(fd);
|
|
34
|
+
let pos = Math.min(start, stat.size);
|
|
35
|
+
const limit = Math.min(stat.size, start + maxBytes);
|
|
36
|
+
let buf = Buffer.alloc(0);
|
|
37
|
+
let lineStart = pos;
|
|
38
|
+
let lines = 0;
|
|
39
|
+
let delivered = 0;
|
|
40
|
+
let lastComplete = pos;
|
|
41
|
+
const chunk = Buffer.allocUnsafe(chunkSize);
|
|
42
|
+
|
|
43
|
+
while (pos < limit) {
|
|
44
|
+
const want = Math.min(chunkSize, limit - pos);
|
|
45
|
+
const read = fs.readSync(fd, chunk, 0, want, pos);
|
|
46
|
+
if (read <= 0) break;
|
|
47
|
+
pos += read;
|
|
48
|
+
buf = buf.length === 0 ? Buffer.from(chunk.subarray(0, read)) : Buffer.concat([buf, chunk.subarray(0, read)]);
|
|
49
|
+
|
|
50
|
+
let searchFrom = 0;
|
|
51
|
+
let nl;
|
|
52
|
+
while ((nl = buf.indexOf(NL, searchFrom)) !== -1) {
|
|
53
|
+
const raw = buf.subarray(searchFrom, nl);
|
|
54
|
+
lines++;
|
|
55
|
+
lineStart += raw.length + 1;
|
|
56
|
+
lastComplete = lineStart;
|
|
57
|
+
searchFrom = nl + 1;
|
|
58
|
+
if (raw.length === 0) continue;
|
|
59
|
+
if (must.length && !must.some((m) => raw.includes(m))) continue;
|
|
60
|
+
delivered++;
|
|
61
|
+
onLine(raw.toString('utf8'), lastComplete);
|
|
62
|
+
}
|
|
63
|
+
buf = buf.subarray(searchFrom);
|
|
64
|
+
}
|
|
65
|
+
// A trailing line with no newline is deliberately NOT consumed: the writer
|
|
66
|
+
// may still be appending to it. It will be picked up on the next refresh.
|
|
67
|
+
return {
|
|
68
|
+
offset: lastComplete,
|
|
69
|
+
bytes: lastComplete - start,
|
|
70
|
+
lines,
|
|
71
|
+
delivered,
|
|
72
|
+
truncated: limit < stat.size,
|
|
73
|
+
};
|
|
74
|
+
} finally {
|
|
75
|
+
fs.closeSync(fd);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Convenience: parse every JSON line, tolerating corruption. */
|
|
80
|
+
export function readJsonLines(file, onObj, opt = {}) {
|
|
81
|
+
let bad = 0;
|
|
82
|
+
const res = readLines(
|
|
83
|
+
file,
|
|
84
|
+
(line, off) => {
|
|
85
|
+
let o;
|
|
86
|
+
try {
|
|
87
|
+
o = JSON.parse(line);
|
|
88
|
+
} catch {
|
|
89
|
+
bad++;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
onObj(o, off);
|
|
93
|
+
},
|
|
94
|
+
opt,
|
|
95
|
+
);
|
|
96
|
+
return { ...res, malformed: bad };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Append-only NDJSON writer with a buffered flush. */
|
|
100
|
+
export class JsonlWriter {
|
|
101
|
+
constructor(file, { flushBytes = 1 << 20 } = {}) {
|
|
102
|
+
this.file = file;
|
|
103
|
+
this.parts = [];
|
|
104
|
+
this.size = 0;
|
|
105
|
+
this.flushBytes = flushBytes;
|
|
106
|
+
this.written = 0;
|
|
107
|
+
}
|
|
108
|
+
write(obj) {
|
|
109
|
+
const s = JSON.stringify(obj) + '\n';
|
|
110
|
+
this.parts.push(s);
|
|
111
|
+
this.size += s.length;
|
|
112
|
+
this.written++;
|
|
113
|
+
if (this.size >= this.flushBytes) this.flush();
|
|
114
|
+
}
|
|
115
|
+
flush() {
|
|
116
|
+
if (!this.parts.length) return;
|
|
117
|
+
fs.appendFileSync(this.file, this.parts.join(''));
|
|
118
|
+
this.parts = [];
|
|
119
|
+
this.size = 0;
|
|
120
|
+
}
|
|
121
|
+
close() {
|
|
122
|
+
this.flush();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live status snapshot.
|
|
3
|
+
*
|
|
4
|
+
* One small JSON file — `$TOKENFLOW_HOME/data/status.json` — that every
|
|
5
|
+
* "right now" surface reads: the menu bar plugin, `tokenflow status --bar`,
|
|
6
|
+
* `--live`, and the dashboard's freshness pill. The watch daemon refreshes it
|
|
7
|
+
* after every cycle; anything can also build it on demand.
|
|
8
|
+
*
|
|
9
|
+
* It answers, with sources: what happened today / this week / this month, who
|
|
10
|
+
* consumed it, where each configured limit stands, where usage is heading,
|
|
11
|
+
* and how fresh all of it is. Numbers come from the same cube + analytics as
|
|
12
|
+
* the dashboard, so no surface can disagree with another.
|
|
13
|
+
*
|
|
14
|
+
* Formatting helpers (bar line, countdowns) are pure and exported for tests.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { buildBundle } from './bundle.js';
|
|
19
|
+
import { computeView } from '../analytics/index.js';
|
|
20
|
+
import { filterCube, filterSessions, indexCube, rank, finalize, sumRows, weekStart, addDays } from '../analytics/aggregate.js';
|
|
21
|
+
import { loadConfig, paths, ensureDirs } from './config.js';
|
|
22
|
+
import { readJson } from './store.js';
|
|
23
|
+
import { compact, usd, countdown } from './units.js';
|
|
24
|
+
import { detectMilestones } from '../analytics/milestones.js';
|
|
25
|
+
|
|
26
|
+
// Formatting adapters over the shared units.js formatters (which the browser
|
|
27
|
+
// bundle also uses): null means "nothing to show", never "—", never 0.
|
|
28
|
+
const compactTokens = (n) => (n === null || n === undefined || !Number.isFinite(n) ? null : compact(n));
|
|
29
|
+
const money = (n) => (n === null || n === undefined ? null : usd(n));
|
|
30
|
+
export { countdown, compactTokens, money };
|
|
31
|
+
|
|
32
|
+
const STATUS_SCHEMA = 1;
|
|
33
|
+
|
|
34
|
+
function costOf(m) {
|
|
35
|
+
// Estimated and measured stay separate everywhere in this codebase; a live
|
|
36
|
+
// surface must not silently merge a price-table estimate with a gateway's
|
|
37
|
+
// billed number. `cost` is null when nothing was priced.
|
|
38
|
+
return {
|
|
39
|
+
cost: m.costReq > 0 ? m.cost : null,
|
|
40
|
+
costMeasured: m.costMeasured > 0 ? m.costMeasured : null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function usageSlice(m, extra = {}) {
|
|
45
|
+
return {
|
|
46
|
+
tokens: { total: m.total, input: m.in, output: m.out, cacheRead: m.cr, cacheWrite: m.cw },
|
|
47
|
+
requests: m.req,
|
|
48
|
+
...costOf(m),
|
|
49
|
+
...extra,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the status object from the store (or an existing bundle).
|
|
55
|
+
* @param {{config?:object, bundle?:object, nowMs?:number}} opt
|
|
56
|
+
*/
|
|
57
|
+
export function buildLiveStatus(opt = {}) {
|
|
58
|
+
const config = opt.config || loadConfig();
|
|
59
|
+
const b = opt.bundle || buildBundle({ config });
|
|
60
|
+
const nowMs = opt.nowMs ?? Date.now();
|
|
61
|
+
const v = computeView(b, {});
|
|
62
|
+
const ix = indexCube(b.cube);
|
|
63
|
+
const today = b.meta.today;
|
|
64
|
+
const tzOffsetMinutes = b.meta.tzOffsetMinutes ?? 0;
|
|
65
|
+
const includeOverlay = !!b.meta.includeOverlayDefault;
|
|
66
|
+
|
|
67
|
+
const measuresFor = (from, to) =>
|
|
68
|
+
finalize(sumRows(filterCube(ix, { from, to, includeOverlay }), ix));
|
|
69
|
+
const todayM = measuresFor(today, today);
|
|
70
|
+
const yesterday = addDays(today, -1);
|
|
71
|
+
const yesterdayM = measuresFor(yesterday, yesterday);
|
|
72
|
+
const wtdM = measuresFor(weekStart(today), today);
|
|
73
|
+
const mtdM = measuresFor(`${today.slice(0, 7)}-01`, today);
|
|
74
|
+
|
|
75
|
+
const todayRows = filterCube(ix, { from: today, to: today, includeOverlay });
|
|
76
|
+
// Zero-token rows (activity-only sources) never earn a slot in token
|
|
77
|
+
// rankings — an "unknown · 0 tok" row is noise, not information.
|
|
78
|
+
const providersToday = rank(todayRows, ix, (r) => r[ix.d.p])
|
|
79
|
+
.filter((g) => g.m.total > 0).slice(0, 5)
|
|
80
|
+
.map((g) => ({ key: g.key, tokens: g.m.total, requests: g.m.req, ...costOf(g.m) }));
|
|
81
|
+
const modelsToday = rank(todayRows, ix, (r) => r[ix.d.m])
|
|
82
|
+
.filter((g) => g.m.total > 0).slice(0, 5)
|
|
83
|
+
.map((g) => ({ key: g.key, tokens: g.m.total, requests: g.m.req, ...costOf(g.m) }));
|
|
84
|
+
// By SOURCE (the tool that wrote the log: claude-code, opencode, hermes,
|
|
85
|
+
// git, …). Provider answers "who made the model"; source answers "which
|
|
86
|
+
// app did I use" — hermes traffic shows here even when its models belong
|
|
87
|
+
// to other vendors.
|
|
88
|
+
const sourcesToday = rank(todayRows, ix, (r) => r[ix.d.c])
|
|
89
|
+
.filter((g) => g.m.total > 0).slice(0, 6)
|
|
90
|
+
.map((g) => ({ key: g.key, tokens: g.m.total, requests: g.m.req, ...costOf(g.m) }));
|
|
91
|
+
|
|
92
|
+
// ---- rolling windows (measured locally — CodexBar-style "current window") --
|
|
93
|
+
// Hour-granular slices of the cube: the boundary is the top of an hour, so a
|
|
94
|
+
// window may be up to 59 minutes conservative. That approximation is stated
|
|
95
|
+
// here rather than hidden.
|
|
96
|
+
const sumSince = (hoursBack) => {
|
|
97
|
+
const cutoff = new Date(nowMs + tzOffsetMinutes * 60000 - hoursBack * 3600000);
|
|
98
|
+
const cutoffKey = `${cutoff.toISOString().slice(0, 10)}T${String(cutoff.getUTCHours()).padStart(2, '0')}`;
|
|
99
|
+
const rows = ix.rows.filter((r) => `${r[ix.d.d]}T${String(r[ix.d.h]).padStart(2, '0')}` >= cutoffKey);
|
|
100
|
+
return usageSlice(finalize(sumRows(rows, ix)));
|
|
101
|
+
};
|
|
102
|
+
const windows = {
|
|
103
|
+
last5h: sumSince(5),
|
|
104
|
+
last24h: sumSince(24),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// ---- per-provider rolling windows ------------------------------------------
|
|
108
|
+
// Same hour-granular slices as the totals above, scoped to one provider.
|
|
109
|
+
const sumSinceFor = (hoursBack, provider) => {
|
|
110
|
+
const cutoff = new Date(nowMs + tzOffsetMinutes * 60000 - hoursBack * 3600000);
|
|
111
|
+
const cutoffKey = `${cutoff.toISOString().slice(0, 10)}T${String(cutoff.getUTCHours()).padStart(2, '0')}`;
|
|
112
|
+
const rows = ix.rows.filter((r) =>
|
|
113
|
+
r[ix.d.p] === provider &&
|
|
114
|
+
`${r[ix.d.d]}T${String(r[ix.d.h]).padStart(2, '0')}` >= cutoffKey);
|
|
115
|
+
return usageSlice(finalize(sumRows(rows, ix)));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const providerWindows = providersToday.slice(0, 4).map((p) => ({
|
|
119
|
+
key: p.key,
|
|
120
|
+
h5: sumSinceFor(5, p.key),
|
|
121
|
+
d1: sumSinceFor(24, p.key),
|
|
122
|
+
d7: sumSinceFor(168, p.key),
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
// ---- Claude/Codex-style 5h session blocks -----------------------------------
|
|
126
|
+
// Measured locally from activity clusters: a new block starts after >=5h of
|
|
127
|
+
// silence, each block spans exactly 5h from its first active hour. This is
|
|
128
|
+
// a model of how session windows behave — stated here, not hidden.
|
|
129
|
+
const sessionBlockFor = (provider, label) => {
|
|
130
|
+
const cutoff48Key = new Date(nowMs + tzOffsetMinutes * 60000 - 48 * 3600000)
|
|
131
|
+
.toISOString().slice(0, 13) + ':00';
|
|
132
|
+
const GAP = 5 * 3600000;
|
|
133
|
+
const keyToMs = (k) => Date.parse(`${k}:00:00Z`) - tzOffsetMinutes * 60000;
|
|
134
|
+
const msToKey = (ms) => {
|
|
135
|
+
const local = new Date(ms + tzOffsetMinutes * 60000);
|
|
136
|
+
return `${local.toISOString().slice(0, 10)}T${String(local.getUTCHours()).padStart(2, '0')}`;
|
|
137
|
+
};
|
|
138
|
+
const active = [];
|
|
139
|
+
for (const r of ix.rows) {
|
|
140
|
+
if (r[ix.d.p] !== provider) continue;
|
|
141
|
+
const t = r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
|
|
142
|
+
if (!(t > 0)) continue;
|
|
143
|
+
const k = `${r[ix.d.d]}T${String(r[ix.d.h]).padStart(2, '0')}`;
|
|
144
|
+
if (k >= cutoff48Key.slice(0, 16)) active.push(k);
|
|
145
|
+
}
|
|
146
|
+
active.sort();
|
|
147
|
+
let startMs = null; let endActiveMs = null;
|
|
148
|
+
for (const k of active) {
|
|
149
|
+
const ms = keyToMs(k);
|
|
150
|
+
if (startMs === null || ms - endActiveMs >= GAP) startMs = ms;
|
|
151
|
+
endActiveMs = ms;
|
|
152
|
+
}
|
|
153
|
+
if (startMs === null) return null;
|
|
154
|
+
const resetsInMs = Math.max(0, startMs + GAP - nowMs);
|
|
155
|
+
const rows = ix.rows.filter((r) => {
|
|
156
|
+
const k = `${r[ix.d.d]}T${String(r[ix.d.h]).padStart(2, '0')}`;
|
|
157
|
+
return r[ix.d.p] === provider && k >= msToKey(startMs);
|
|
158
|
+
});
|
|
159
|
+
const m = finalize(sumRows(rows, ix));
|
|
160
|
+
return {
|
|
161
|
+
key: provider,
|
|
162
|
+
label,
|
|
163
|
+
startMs,
|
|
164
|
+
resetsInMs,
|
|
165
|
+
windowTokens: m.total,
|
|
166
|
+
windowRequests: m.req,
|
|
167
|
+
windowCost: m.costReq > 0 ? m.cost : null,
|
|
168
|
+
blocksToday: blocksTodayCount(active, keyToMs, GAP),
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
function blocksTodayCount(activeKeys, keyToMs, gap) {
|
|
172
|
+
let count = 0; let prevEnd = null;
|
|
173
|
+
for (const k of activeKeys) {
|
|
174
|
+
const ms = keyToMs(k);
|
|
175
|
+
if (prevEnd === null || ms - prevEnd >= gap) count++;
|
|
176
|
+
prevEnd = ms;
|
|
177
|
+
}
|
|
178
|
+
return count;
|
|
179
|
+
}
|
|
180
|
+
const sessionBlocks = [
|
|
181
|
+
sessionBlockFor('anthropic', 'Claude'),
|
|
182
|
+
sessionBlockFor('openai', 'Codex'),
|
|
183
|
+
].filter(Boolean);
|
|
184
|
+
|
|
185
|
+
// ---- velocity: today's pace vs your trailing-14-day average -----------------
|
|
186
|
+
const trailing14 = v.daily.slice(-15, -1);
|
|
187
|
+
const avgDaily14 = trailing14.length
|
|
188
|
+
? trailing14.reduce((a, d) => a + (d.total || 0), 0) / trailing14.length
|
|
189
|
+
: null;
|
|
190
|
+
const hoursElapsedToday = Math.max(((nowMs / 60000 + tzOffsetMinutes) % 1440) / 60, 0.25);
|
|
191
|
+
const velocity = {
|
|
192
|
+
todayTokensPerHour: todayM.total / hoursElapsedToday,
|
|
193
|
+
avgTokensPerHour: avgDaily14 !== null ? avgDaily14 / 24 : null,
|
|
194
|
+
ratio: avgDaily14 > 0 ? (todayM.total / hoursElapsedToday) / (avgDaily14 / 24) : null,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// ---- recent days for sparklines + milestones -------------------------------
|
|
198
|
+
const recentDays = v.daily.slice(-14).map((d) => ({
|
|
199
|
+
key: d.key,
|
|
200
|
+
total: d.total || 0,
|
|
201
|
+
cost: Number(d.cost) || 0,
|
|
202
|
+
active: !!d.tokenActive,
|
|
203
|
+
}));
|
|
204
|
+
const milestones = detectMilestones(v.daily);
|
|
205
|
+
|
|
206
|
+
const lastRefresh = b.meta.lastRefresh || null;
|
|
207
|
+
const ageMs = lastRefresh ? Math.max(0, nowMs - new Date(lastRefresh).getTime()) : null;
|
|
208
|
+
const staleAfterMs = (config.watch?.staleAfterSeconds ?? 600) * 1000;
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
schema: STATUS_SCHEMA,
|
|
212
|
+
generatedAt: new Date(nowMs).toISOString(),
|
|
213
|
+
appVersion: b.meta.appVersion,
|
|
214
|
+
demo: b.meta.demo,
|
|
215
|
+
timezone: b.meta.timezone,
|
|
216
|
+
freshness: {
|
|
217
|
+
lastRefresh,
|
|
218
|
+
ageMs,
|
|
219
|
+
staleAfterMs,
|
|
220
|
+
stale: ageMs === null ? true : ageMs > staleAfterMs,
|
|
221
|
+
computeMs: Date.now() - nowMs > 0 ? Date.now() - nowMs : null,
|
|
222
|
+
},
|
|
223
|
+
health: {
|
|
224
|
+
records: b.health.records,
|
|
225
|
+
sessions: b.health.sessions,
|
|
226
|
+
grade: b.health.grade,
|
|
227
|
+
coverage: b.health.coverage,
|
|
228
|
+
},
|
|
229
|
+
usage: {
|
|
230
|
+
today: usageSlice(todayM, { date: today, sessions: filterSessions(b.sessions, { from: today, to: today, includeOverlay }).length }),
|
|
231
|
+
yesterday: usageSlice(yesterdayM, { date: yesterday }),
|
|
232
|
+
weekToDate: usageSlice(wtdM),
|
|
233
|
+
monthToDate: usageSlice(mtdM),
|
|
234
|
+
},
|
|
235
|
+
providersToday,
|
|
236
|
+
modelsToday,
|
|
237
|
+
sourcesToday,
|
|
238
|
+
windows,
|
|
239
|
+
providerWindows,
|
|
240
|
+
velocity,
|
|
241
|
+
sessionBlocks,
|
|
242
|
+
recentDays,
|
|
243
|
+
milestones,
|
|
244
|
+
capacity: {
|
|
245
|
+
summary: trimSummary(v.capacity.summary),
|
|
246
|
+
states: v.capacity.states.map(trimLimitState),
|
|
247
|
+
invalidCount: v.capacity.invalid.length,
|
|
248
|
+
},
|
|
249
|
+
forecast: v.forecast,
|
|
250
|
+
anomalies: v.anomalies.slice(0, 8).map((a) => ({
|
|
251
|
+
id: a.id, type: a.type, date: a.date, severity: a.severity, detail: a.detail,
|
|
252
|
+
})),
|
|
253
|
+
firstSeen: v.firstSeen,
|
|
254
|
+
insights: v.insights.slice(0, 3).map((i) => ({ icon: i.icon, text: i.text })),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function trimSummary(s) {
|
|
259
|
+
if (!s) return s;
|
|
260
|
+
return {
|
|
261
|
+
anyExceeded: s.anyExceeded,
|
|
262
|
+
anyWarn: s.anyWarn,
|
|
263
|
+
counts: s.counts ?? null,
|
|
264
|
+
worst: s.worst ? trimLimitState(s.worst) : null,
|
|
265
|
+
firstToHit: s.firstToHit ? trimLimitState(s.firstToHit) : null,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function trimLimitState(s) {
|
|
270
|
+
return {
|
|
271
|
+
id: s.id, label: s.label, scope: s.scope, metric: s.metric,
|
|
272
|
+
provider: s.provider, model: s.model, project: s.project,
|
|
273
|
+
used: s.used, cap: s.cap, remaining: s.remaining, pctUsed: s.pctUsed,
|
|
274
|
+
status: s.status, unit: s.unit,
|
|
275
|
+
burn: s.burn,
|
|
276
|
+
etaHours: s.etaHours, etaVia: s.etaVia,
|
|
277
|
+
resetsAtMs: s.resetsAtMs, resetsInMs: s.resetsInMs,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Atomic write: tmp file + rename, so readers never see a half-file. */
|
|
282
|
+
export function writeLiveStatus(status) {
|
|
283
|
+
const p = paths();
|
|
284
|
+
ensureDirs();
|
|
285
|
+
const tmp = `${p.status}.${process.pid}.tmp`;
|
|
286
|
+
fs.writeFileSync(tmp, JSON.stringify(status));
|
|
287
|
+
fs.renameSync(tmp, p.status);
|
|
288
|
+
return p.status;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Latest written status, or null when absent/corrupt (never throws). */
|
|
292
|
+
export function readLiveStatus() {
|
|
293
|
+
try {
|
|
294
|
+
return JSON.parse(fs.readFileSync(paths().status, 'utf8'));
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Recompute freshness against the CURRENT clock.
|
|
302
|
+
*
|
|
303
|
+
* A stored status file carries the staleness verdict of the moment it was
|
|
304
|
+
* written — left alone, "updated just now" stays true forever while the data
|
|
305
|
+
* underneath quietly ages. Every read path passes through here so "fresh"
|
|
306
|
+
* always means fresh right now.
|
|
307
|
+
*/
|
|
308
|
+
export function withComputedFreshness(status, nowMs = Date.now()) {
|
|
309
|
+
if (!status || typeof status !== 'object') return status;
|
|
310
|
+
const f = status.freshness || {};
|
|
311
|
+
const lastRefresh = f.lastRefresh ?? null;
|
|
312
|
+
const ageMs = lastRefresh ? Math.max(0, nowMs - new Date(lastRefresh).getTime()) : null;
|
|
313
|
+
const staleAfterMs = f.staleAfterMs ?? 600000;
|
|
314
|
+
return {
|
|
315
|
+
...status,
|
|
316
|
+
freshness: { ...f, ageMs, staleAfterMs, stale: ageMs === null ? true : ageMs > staleAfterMs },
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Freshness-aware status for live surfaces: prefer the watch daemon's file,
|
|
322
|
+
* fall back to computing fresh right now. Returns `{status, fromWatch}`.
|
|
323
|
+
*
|
|
324
|
+
* The cache window derives from the watcher's own cadence (interval + slack),
|
|
325
|
+
* because a snapshot written 90 seconds into a 120-second cycle is exactly as
|
|
326
|
+
* current as the product promised — not stale. When a fallback compute does
|
|
327
|
+
* happen, daemon identity (pid, cycles, last error) is carried over from the
|
|
328
|
+
* cached file: a slow poll must never make the UI claim no watcher is running.
|
|
329
|
+
*/
|
|
330
|
+
export function currentStatus(opt = {}) {
|
|
331
|
+
const cached = readLiveStatus();
|
|
332
|
+
const cfg = opt.config || loadConfig();
|
|
333
|
+
const maxAgeMs = opt.maxAgeMs
|
|
334
|
+
?? ((cfg.watch?.intervalSeconds ?? 120) * 1000) + 60000;
|
|
335
|
+
if (cached && !withComputedFreshness(cached).freshness.stale) {
|
|
336
|
+
const age = Date.now() - new Date(cached.generatedAt).getTime();
|
|
337
|
+
if (age <= maxAgeMs) return { status: withComputedFreshness(cached), fromWatch: true };
|
|
338
|
+
}
|
|
339
|
+
const fresh = buildLiveStatus({ config: cfg });
|
|
340
|
+
if (cached?.watcher) fresh.watcher = cached.watcher;
|
|
341
|
+
if (!fresh.lastCycle && cached?.lastCycle) fresh.lastCycle = cached.lastCycle;
|
|
342
|
+
if (!fresh.lastError && cached?.lastError) fresh.lastError = cached.lastError;
|
|
343
|
+
return { status: fresh, fromWatch: false };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------- format ----
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The one-line menu-bar summary.
|
|
350
|
+
*
|
|
351
|
+
* Display modes:
|
|
352
|
+
* auto — the most urgent signal wins: worst limit % when limits exist,
|
|
353
|
+
* else today's cost when priced, else today's tokens
|
|
354
|
+
* limit — worst limit only (— when none configured)
|
|
355
|
+
* cost — today's estimated cost (falls back through measured/tokens)
|
|
356
|
+
* tokens — today's total tokens
|
|
357
|
+
*
|
|
358
|
+
* Alert glyphs travel with their state: ⚠ approaching, ✗ exceeded.
|
|
359
|
+
*
|
|
360
|
+
* @returns {{text:string, tooltip:string}} empty text when there is nothing honest to show
|
|
361
|
+
*/
|
|
362
|
+
export function barLine(status, mode = 'auto', prefix = 'TF') {
|
|
363
|
+
const parts = [];
|
|
364
|
+
const warnGlyph = (s) => (s === 'exceeded' ? '✗ ' : s === 'warn' ? '⚠ ' : '');
|
|
365
|
+
|
|
366
|
+
const worst = status.capacity?.summary?.worst || null;
|
|
367
|
+
const showLimit = mode === 'limit' || ((mode === 'auto') && worst && worst.pctUsed !== null);
|
|
368
|
+
if (mode === 'limit' && (!worst || worst.pctUsed === null)) {
|
|
369
|
+
return { text: `${prefix} —`, tooltip: 'No limits configured' };
|
|
370
|
+
}
|
|
371
|
+
if (showLimit && worst && worst.pctUsed !== null) {
|
|
372
|
+
const pctText = `${Math.round(worst.pctUsed * 100)}%`;
|
|
373
|
+
const resetIn = countdown(worst.resetsInMs);
|
|
374
|
+
parts.push(`${warnGlyph(worst.status)}${worst.label} ${pctText}${resetIn ? ` · ${resetIn}` : ''}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const t = status.usage?.today;
|
|
378
|
+
if (!t) return { text: `${prefix} —`, tooltip: 'No data yet' };
|
|
379
|
+
|
|
380
|
+
if (mode === 'cost' || (mode === 'auto' && !showLimit)) {
|
|
381
|
+
const c = t.cost ?? t.costMeasured;
|
|
382
|
+
if (c !== null && c !== undefined) parts.push(money(c));
|
|
383
|
+
}
|
|
384
|
+
if (mode === 'tokens' || ((mode === 'auto' || mode === 'cost') && parts.length === 0)) {
|
|
385
|
+
const todayTotal = t.tokens?.total ?? 0;
|
|
386
|
+
if (todayTotal > 0 || mode !== 'auto') {
|
|
387
|
+
parts.push(compactTokens(todayTotal));
|
|
388
|
+
} else if ((status.usage?.weekToDate?.tokens?.total ?? 0) > 0) {
|
|
389
|
+
// A day that simply hasn't started yet is not a zero-usage day; say what
|
|
390
|
+
// the week looks like instead of showing a misleading "0".
|
|
391
|
+
parts.push(`7d ${compactTokens(status.usage.weekToDate.tokens.total)}`);
|
|
392
|
+
} else {
|
|
393
|
+
parts.push('0');
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (!parts.length) return { text: `${prefix} —`, tooltip: 'Nothing measurable today' };
|
|
398
|
+
return {
|
|
399
|
+
text: `${prefix} ${parts.join(' · ')}`,
|
|
400
|
+
tooltip: tooltipFor(status),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function tooltipFor(status) {
|
|
405
|
+
const u = status.usage?.today || {};
|
|
406
|
+
const bits = [];
|
|
407
|
+
bits.push(`Today ${compactTokens(u.tokens?.total ?? 0)} tokens`);
|
|
408
|
+
const c = u.cost ?? u.costMeasured;
|
|
409
|
+
if (c != null) bits.push(money(c));
|
|
410
|
+
if (status.usage?.weekToDate?.tokens?.total != null) {
|
|
411
|
+
bits.push(`Week ${compactTokens(status.usage.weekToDate.tokens.total)}`);
|
|
412
|
+
}
|
|
413
|
+
const f = status.freshness;
|
|
414
|
+
if (f?.stale) bits.push(`data stale (${countdown(f.ageMs) ?? 'unknown'} old)`);
|
|
415
|
+
else if (f?.lastRefresh) bits.push(`updated ${new Date(f.lastRefresh).toLocaleTimeString()}`);
|
|
416
|
+
return bits.join(' · ');
|
|
417
|
+
}
|