@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.
Files changed (101) hide show
  1. package/CONTRIBUTING.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -0
  4. package/Refresh & Open Dashboard.command +22 -0
  5. package/SECURITY.md +42 -0
  6. package/bin/tokenflow.js +1342 -0
  7. package/docs/architecture.md +193 -0
  8. package/docs/cli.md +390 -0
  9. package/docs/configuration.md +281 -0
  10. package/docs/creating-provider.md +262 -0
  11. package/docs/data-model.md +213 -0
  12. package/docs/getting-started.md +266 -0
  13. package/docs/live-mode.md +199 -0
  14. package/docs/media/architecture-hero.svg +86 -0
  15. package/docs/media/cost-editorial-dark.png +0 -0
  16. package/docs/media/health-terminal-light.png +0 -0
  17. package/docs/media/menubar-dark.png +0 -0
  18. package/docs/media/menubar-light.png +0 -0
  19. package/docs/media/models-terminal-dark.png +0 -0
  20. package/docs/media/overview-aurora-dark.png +0 -0
  21. package/docs/media/time-aurora-light.png +0 -0
  22. package/docs/providers.md +309 -0
  23. package/docs/skill.md +64 -0
  24. package/docs/troubleshooting.md +207 -0
  25. package/examples/config.example.yaml +92 -0
  26. package/examples/demo-data/README.md +38 -0
  27. package/examples/demo-data/sample-usage.csv +11 -0
  28. package/package.json +74 -0
  29. package/scripts/build-dmg.sh +33 -0
  30. package/scripts/build-menubar-app.sh +67 -0
  31. package/scripts/lint.js +111 -0
  32. package/scripts/validate-install.js +140 -0
  33. package/skills/tokenflow/SKILL.md +392 -0
  34. package/skills/tokenflow/examples/config.yaml +92 -0
  35. package/skills/tokenflow/examples/generic-mapping.json +26 -0
  36. package/skills/tokenflow/examples/session-transcript.md +191 -0
  37. package/skills/tokenflow/providers/adapter-template.js +135 -0
  38. package/skills/tokenflow/providers/detection-matrix.md +142 -0
  39. package/skills/tokenflow/schemas/config.schema.json +107 -0
  40. package/skills/tokenflow/schemas/normalized-record.json +63 -0
  41. package/src/analytics/aggregate.js +247 -0
  42. package/src/analytics/anomalies.js +222 -0
  43. package/src/analytics/capacity.js +278 -0
  44. package/src/analytics/comparison.js +96 -0
  45. package/src/analytics/dimensions.js +230 -0
  46. package/src/analytics/efficiency.js +138 -0
  47. package/src/analytics/forecast.js +202 -0
  48. package/src/analytics/index.js +327 -0
  49. package/src/analytics/insights.js +283 -0
  50. package/src/analytics/milestones.js +91 -0
  51. package/src/analytics/peak.js +106 -0
  52. package/src/analytics/productivity.js +166 -0
  53. package/src/analytics/token-usage.js +267 -0
  54. package/src/commands/diagnostics.js +88 -0
  55. package/src/commands/digest.js +155 -0
  56. package/src/commands/models-compare.js +96 -0
  57. package/src/core/budget.js +142 -0
  58. package/src/core/bundle.js +191 -0
  59. package/src/core/config.js +202 -0
  60. package/src/core/delivery.js +109 -0
  61. package/src/core/geo.js +99 -0
  62. package/src/core/ingest.js +457 -0
  63. package/src/core/interface-map.js +55 -0
  64. package/src/core/jsonl.js +124 -0
  65. package/src/core/live-status.js +417 -0
  66. package/src/core/model-map.js +157 -0
  67. package/src/core/notify.js +83 -0
  68. package/src/core/pricing.js +288 -0
  69. package/src/core/prompt-analytics.js +127 -0
  70. package/src/core/registry.js +107 -0
  71. package/src/core/restore.js +261 -0
  72. package/src/core/schedule.js +120 -0
  73. package/src/core/schema.js +316 -0
  74. package/src/core/sqlite.js +96 -0
  75. package/src/core/store.js +493 -0
  76. package/src/core/sync.js +151 -0
  77. package/src/core/units.js +147 -0
  78. package/src/core/validate.js +123 -0
  79. package/src/core/watch.js +287 -0
  80. package/src/core/yaml.js +209 -0
  81. package/src/export/bundler.js +107 -0
  82. package/src/export/csv.js +100 -0
  83. package/src/export/html-snapshot.js +101 -0
  84. package/src/export/menubar.js +158 -0
  85. package/src/index.js +18 -0
  86. package/src/providers/anthropic/index.js +294 -0
  87. package/src/providers/cline/index.js +120 -0
  88. package/src/providers/cursor/index.js +143 -0
  89. package/src/providers/generic/index.js +268 -0
  90. package/src/providers/git/index.js +188 -0
  91. package/src/providers/headroom/index.js +114 -0
  92. package/src/providers/hermes/index.js +299 -0
  93. package/src/providers/mock/index.js +117 -0
  94. package/src/providers/openai/index.js +370 -0
  95. package/src/providers/opencode/index.js +245 -0
  96. package/src/sdk.js +46 -0
  97. package/src/server/server.js +264 -0
  98. package/src/ui/app.js +2473 -0
  99. package/src/ui/charts.js +925 -0
  100. package/src/ui/index.html +42 -0
  101. 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
+ }