@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,493 @@
1
+ /**
2
+ * Persistence + the aggregate cube.
3
+ *
4
+ * Three artefacts, each with a different job:
5
+ *
6
+ * data/records/YYYY-MM.jsonl request-level facts (Data Explorer, full CSV)
7
+ * data/cube.json the pre-aggregated fact table the dashboard
8
+ * loads once and then filters entirely in the
9
+ * browser — so changing a filter costs zero
10
+ * API calls
11
+ * data/sessions.json one row per session (session counts, tokens
12
+ * per session, long/short session analysis)
13
+ *
14
+ * ## How incremental refresh stays exact
15
+ *
16
+ * Session transcripts are append-only files. `state.json` remembers each
17
+ * file's `{size, mtimeMs, offset, gen}`. On refresh:
18
+ * - unchanged (same size+mtime) -> skipped entirely, zero reads
19
+ * - grew -> read resumes at `offset`; the bytes
20
+ * already ingested are never re-read,
21
+ * so no dedup index is needed
22
+ * - shrank / rewritten -> `gen` is bumped and the file's old
23
+ * records become stale; a compaction
24
+ * pass rewrites the shards without
25
+ * them and rebuilds the cube
26
+ *
27
+ * Because dedup is structural rather than probabilistic, the cube can be
28
+ * updated by pure addition, which is what makes a 1.5 GB corpus refreshable
29
+ * in seconds instead of minutes.
30
+ */
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+ import { paths, ensureDirs } from './config.js';
34
+ import { JsonlWriter, readLines } from './jsonl.js';
35
+ import { hashId, BILLABLE_TOKEN_FIELDS, MEASUREMENT } from './schema.js';
36
+
37
+ export const CUBE_VERSION = 4;
38
+
39
+ /** Cube dimension order — also the on-disk column order. */
40
+ export const CUBE_DIMS = ['d', 'h', 'w', 'p', 'm', 'mf', 'c', 'i', 'g', 'pj', 'rp', 'st', 'ms'];
41
+ /** Cube measure order. */
42
+ export const CUBE_MEASURES = [
43
+ 'in', 'out', 'cr', 'cw', 'cf', 'rs',
44
+ 'req', 'cost', 'costMeasured', 'costReq',
45
+ 'naIn', 'naOut', 'naCr', 'naCw',
46
+ ];
47
+
48
+ /** Compact on-disk record codec (short keys, nulls omitted). */
49
+ const REC_KEYS = [
50
+ ['ts', 'timestamp'], ['d', 'date'], ['h', 'hour'], ['w', 'dow'], ['tz', 'tz_offset'],
51
+ ['p', 'provider'], ['pl', 'provider_label'], ['g', 'gateway'],
52
+ ['m', 'model'], ['mf', 'model_family'],
53
+ ['c', 'client'], ['ap', 'application'], ['i', 'interface'],
54
+ ['in', 'input_tokens'], ['ou', 'output_tokens'], ['cr', 'cache_read_tokens'],
55
+ ['cw', 'cache_write_tokens'], ['cf', 'cache_refresh_tokens'], ['rs', 'reasoning_tokens'],
56
+ ['tt', 'total_tokens'], ['tp', 'total_is_partial'],
57
+ ['s', 'session_id'], ['cv', 'conversation_id'], ['rq', 'request_id'],
58
+ ['pj', 'project'], ['rp', 'repository'], ['br', 'git_branch'], ['k', 'category'],
59
+ ['tr', 'service_tier'],
60
+ ['co', 'estimated_cost'], ['cb', 'cost_basis'],
61
+ ['so', 'source'], ['ms', 'measurement'], ['u', 'user'], ['mc', 'machine'],
62
+ ['du', 'duration_ms'], ['x', 'metadata'], ['id', 'id'],
63
+ ['f', '_fileId'], ['gn', '_gen'],
64
+ ];
65
+
66
+ export function encodeRecord(r) {
67
+ const o = {};
68
+ for (const [k, full] of REC_KEYS) {
69
+ const v = r[full];
70
+ if (v === null || v === undefined || v === false) continue;
71
+ if (full === 'metadata' && (!v || Object.keys(v).length === 0)) continue;
72
+ o[k] = v;
73
+ }
74
+ return o;
75
+ }
76
+
77
+ export function decodeRecord(o) {
78
+ const r = {};
79
+ for (const [k, full] of REC_KEYS) r[full] = o[k] === undefined ? null : o[k];
80
+ r.total_is_partial = !!o.tp;
81
+ if (!r.metadata) r.metadata = {};
82
+ return r;
83
+ }
84
+
85
+ export class Store {
86
+ constructor() {
87
+ this.p = ensureDirs();
88
+ this.state = readJson(this.p.state, {
89
+ version: CUBE_VERSION,
90
+ sources: {},
91
+ lastRefresh: null,
92
+ lastRefreshDurationMs: null,
93
+ stale: [],
94
+ counters: { records: 0, malformed: 0 },
95
+ });
96
+ this._cube = null;
97
+ this._sessions = null;
98
+ this._writers = new Map();
99
+ }
100
+
101
+ // -------------------------------------------------------------- state ----
102
+ sourceState(id) {
103
+ if (!this.state.sources[id]) {
104
+ this.state.sources[id] = { files: {}, cursor: null, lastRefresh: null, records: 0, notes: [] };
105
+ }
106
+ return this.state.sources[id];
107
+ }
108
+
109
+ /**
110
+ * Decide what to do with a source file.
111
+ * @returns {{action:'skip'|'append'|'rewrite', start:number, gen:number, prev:object|null}}
112
+ */
113
+ planFile(sourceId, key, stat) {
114
+ const st = this.sourceState(sourceId);
115
+ const prev = st.files[key];
116
+ if (!prev) return { action: 'rewrite', start: 0, gen: 1, prev: null };
117
+ if (prev.size === stat.size && prev.mtimeMs === stat.mtimeMs) {
118
+ return { action: 'skip', start: prev.offset || 0, gen: prev.gen, prev };
119
+ }
120
+ if (stat.size >= prev.size) {
121
+ return { action: 'append', start: prev.offset || 0, gen: prev.gen, prev };
122
+ }
123
+ // Truncated or rewritten: everything previously ingested from this file is
124
+ // now suspect. Bump the generation and mark the old one stale.
125
+ return { action: 'rewrite', start: 0, gen: (prev.gen || 1) + 1, prev };
126
+ }
127
+
128
+ commitFile(sourceId, key, stat, gen, offset, records, prevGen) {
129
+ const st = this.sourceState(sourceId);
130
+ if (prevGen && prevGen !== gen) {
131
+ this.state.stale.push([fileId(sourceId, key), prevGen]);
132
+ }
133
+ st.files[key] = { size: stat.size, mtimeMs: stat.mtimeMs, offset, gen, records: (st.files[key]?.gen === gen ? (st.files[key].records || 0) : 0) + records, at: new Date().toISOString() };
134
+ }
135
+
136
+ saveState() {
137
+ writeJson(this.p.state, this.state);
138
+ }
139
+
140
+ // ------------------------------------------------------------- records ---
141
+ shardFor(date) {
142
+ return path.join(this.p.records, `${date.slice(0, 7)}.jsonl`);
143
+ }
144
+
145
+ writer(date) {
146
+ const f = this.shardFor(date);
147
+ let w = this._writers.get(f);
148
+ if (!w) {
149
+ w = new JsonlWriter(f);
150
+ this._writers.set(f, w);
151
+ }
152
+ return w;
153
+ }
154
+
155
+ closeWriters() {
156
+ for (const w of this._writers.values()) w.close();
157
+ this._writers.clear();
158
+ }
159
+
160
+ listShards() {
161
+ try {
162
+ return fs.readdirSync(this.p.records).filter((f) => f.endsWith('.jsonl')).sort();
163
+ } catch {
164
+ return [];
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Stream request-level records. `onRec` may return `false` to stop early.
170
+ * @param {(r:object)=>boolean|void} onRec
171
+ * @param {{months?:string[], stale?:Set<string>}} [opt]
172
+ */
173
+ scanRecords(onRec, opt = {}) {
174
+ const stale = opt.stale || this.staleSet();
175
+ let n = 0;
176
+ for (const shard of this.listShards()) {
177
+ if (opt.months && !opt.months.includes(shard.slice(0, 7))) continue;
178
+ let stop = false;
179
+ readLines(path.join(this.p.records, shard), (line) => {
180
+ if (stop) return;
181
+ let o;
182
+ try { o = JSON.parse(line); } catch { return; }
183
+ if (stale.size && stale.has(`${o.f}:${o.gn}`)) return;
184
+ n++;
185
+ if (onRec(o) === false) stop = true;
186
+ });
187
+ if (stop) break;
188
+ }
189
+ return n;
190
+ }
191
+
192
+ staleSet() {
193
+ return new Set((this.state.stale || []).map(([f, g]) => `${f}:${g}`));
194
+ }
195
+
196
+ // ---------------------------------------------------------------- cube ---
197
+ cube() {
198
+ if (!this._cube) {
199
+ const raw = readJson(this.p.cube, null);
200
+ this._cube = raw && raw.version === CUBE_VERSION ? raw : emptyCube();
201
+ }
202
+ return this._cube;
203
+ }
204
+
205
+ /** In-memory Map keyed by the dimension tuple, for additive updates. */
206
+ cubeMap() {
207
+ if (!this._cubeMap) {
208
+ const c = this.cube();
209
+ const map = new Map();
210
+ for (const row of c.rows) map.set(row.slice(0, CUBE_DIMS.length).join(''), row);
211
+ this._cubeMap = map;
212
+ }
213
+ return this._cubeMap;
214
+ }
215
+
216
+ addToCube(rec) {
217
+ const map = this.cubeMap();
218
+ const dims = [
219
+ rec.date, rec.hour, rec.dow, rec.provider, rec.model, rec.model_family,
220
+ rec.client, rec.interface, rec.gateway || 'direct', rec.project || 'unknown',
221
+ rec.repository || rec.project || 'unknown', rec.service_tier || 'unspecified',
222
+ rec.measurement,
223
+ ];
224
+ const key = dims.join('');
225
+ let row = map.get(key);
226
+ if (!row) {
227
+ row = [...dims, ...CUBE_MEASURES.map(() => 0)];
228
+ map.set(key, row);
229
+ }
230
+ const B = CUBE_DIMS.length;
231
+ const M = (name) => B + CUBE_MEASURES.indexOf(name);
232
+ addNullable(row, M('in'), rec.input_tokens, M('naIn'));
233
+ addNullable(row, M('out'), rec.output_tokens, M('naOut'));
234
+ addNullable(row, M('cr'), rec.cache_read_tokens, M('naCr'));
235
+ addNullable(row, M('cw'), rec.cache_write_tokens, M('naCw'));
236
+ if (rec.cache_refresh_tokens !== null) row[M('cf')] += rec.cache_refresh_tokens;
237
+ if (rec.reasoning_tokens !== null) row[M('rs')] += rec.reasoning_tokens;
238
+ row[M('req')] += 1;
239
+ if (rec.estimated_cost !== null) {
240
+ if (rec.cost_basis === 'measured') row[M('costMeasured')] += rec.estimated_cost;
241
+ else { row[M('cost')] += rec.estimated_cost; row[M('costReq')] += 1; }
242
+ }
243
+ }
244
+
245
+ saveCube(meta = {}) {
246
+ const rows = [...this.cubeMap().values()];
247
+ // Round floats so the JSON stays small and stable across refreshes.
248
+ const ci = CUBE_DIMS.length + CUBE_MEASURES.indexOf('cost');
249
+ const cmi = CUBE_DIMS.length + CUBE_MEASURES.indexOf('costMeasured');
250
+ for (const r of rows) {
251
+ r[ci] = round6(r[ci]);
252
+ r[cmi] = round6(r[cmi]);
253
+ }
254
+ rows.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] - b[1]));
255
+ const cube = {
256
+ version: CUBE_VERSION,
257
+ dims: CUBE_DIMS,
258
+ measures: CUBE_MEASURES,
259
+ builtAt: new Date().toISOString(),
260
+ ...meta,
261
+ rows,
262
+ };
263
+ writeJson(this.p.cube, cube);
264
+ this._cube = cube;
265
+ return cube;
266
+ }
267
+
268
+ resetCube() {
269
+ this._cube = emptyCube();
270
+ this._cubeMap = new Map();
271
+ }
272
+
273
+ // ------------------------------------------------------------ activity ---
274
+ /**
275
+ * Daily rollup of *work* signals (commits, line churn, AI-authored edits).
276
+ * Kept separate from the token cube because these are a different kind of
277
+ * measurement — they belong in the correlation panel, never in a token total.
278
+ */
279
+ activity() {
280
+ if (!this._activity) {
281
+ const raw = readJson(this.p.activity ?? path.join(this.p.data, 'activity.json'), null);
282
+ this._activity = raw && raw.version === CUBE_VERSION ? raw : { version: CUBE_VERSION, rows: {} };
283
+ }
284
+ return this._activity;
285
+ }
286
+
287
+ addToActivity(rec) {
288
+ const md = rec.metadata || {};
289
+ const isCommit = rec.category === 'commit';
290
+ const isEdit = typeof rec.category === 'string' && rec.category.startsWith('ai-edit');
291
+ if (!isCommit && !isEdit) return;
292
+ const key = `${rec.date}|${rec.source}|${rec.project || 'unknown'}`;
293
+ const rows = this.activity().rows;
294
+ let a = rows[key];
295
+ if (!a) {
296
+ a = rows[key] = {
297
+ d: rec.date, so: rec.source, pj: rec.project || 'unknown',
298
+ commits: 0, files: 0, ins: 0, del: 0, aiLines: 0, tabLines: 0, humanLines: 0, edits: 0,
299
+ };
300
+ }
301
+ if (isCommit) {
302
+ a.commits++;
303
+ a.files += num(md.files_changed);
304
+ a.ins += num(md.insertions ?? md.lines_added);
305
+ a.del += num(md.deletions ?? md.lines_deleted);
306
+ a.aiLines += num(md.ai_lines_added);
307
+ a.tabLines += num(md.tab_lines_added);
308
+ a.humanLines += num(md.human_lines_added);
309
+ } else {
310
+ a.edits++;
311
+ }
312
+ }
313
+
314
+ saveActivity() {
315
+ writeJson(this.p.activity ?? path.join(this.p.data, 'activity.json'), this.activity());
316
+ }
317
+
318
+ resetActivity() {
319
+ this._activity = { version: CUBE_VERSION, rows: {} };
320
+ }
321
+
322
+ // ------------------------------------------------------------ sessions ---
323
+ sessions() {
324
+ if (!this._sessions) {
325
+ const raw = readJson(this.p.sessions, null);
326
+ this._sessions = raw && raw.version === CUBE_VERSION ? raw : { version: CUBE_VERSION, rows: {} };
327
+ }
328
+ return this._sessions;
329
+ }
330
+
331
+ upsertSession(rec) {
332
+ const sid = rec.session_id || `${rec.source}:${rec.date}:${rec.project || 'unknown'}`;
333
+ const rows = this.sessions().rows;
334
+ let s = rows[sid];
335
+ if (!s) {
336
+ s = rows[sid] = {
337
+ id: sid, so: rec.source, p: rec.provider, m: rec.model, mf: rec.model_family,
338
+ c: rec.client, i: rec.interface, g: rec.gateway || 'direct',
339
+ pj: rec.project || 'unknown', rp: rec.repository || rec.project || 'unknown',
340
+ br: rec.git_branch || null, st: rec.service_tier || 'unspecified', ms: rec.measurement,
341
+ start: rec.timestamp, end: rec.timestamp, d: rec.date, h: rec.hour, w: rec.dow,
342
+ req: 0, in: 0, out: 0, cr: 0, cw: 0, cf: 0, rs: 0, cost: 0, models: {},
343
+ };
344
+ }
345
+ if (rec.timestamp < s.start) { s.start = rec.timestamp; s.d = rec.date; s.h = rec.hour; s.w = rec.dow; }
346
+ if (rec.timestamp > s.end) s.end = rec.timestamp;
347
+ s.req++;
348
+ for (const [k, f] of [['in', 'input_tokens'], ['out', 'output_tokens'], ['cr', 'cache_read_tokens'], ['cw', 'cache_write_tokens'], ['cf', 'cache_refresh_tokens'], ['rs', 'reasoning_tokens']]) {
349
+ if (rec[f] !== null) s[k] += rec[f];
350
+ }
351
+ if (rec.estimated_cost !== null) s.cost += rec.estimated_cost;
352
+ if (rec.model) s.models[rec.model] = (s.models[rec.model] || 0) + 1;
353
+ // Session-level model is the one it spent most requests on.
354
+ let top = null, best = -1;
355
+ for (const [mm, n] of Object.entries(s.models)) if (n > best) { best = n; top = mm; }
356
+ s.m = top;
357
+ return s;
358
+ }
359
+
360
+ saveSessions() {
361
+ const s = this.sessions();
362
+ for (const row of Object.values(s.rows)) row.cost = round6(row.cost);
363
+ writeJson(this.p.sessions, s);
364
+ }
365
+
366
+ resetSessions() {
367
+ this._sessions = { version: CUBE_VERSION, rows: {} };
368
+ }
369
+
370
+ /**
371
+ * Sessions as a flat array with derived duration + total.
372
+ * The per-session model histogram is dropped: `m` already carries the model
373
+ * the session spent most of its requests on, and keeping the histogram would
374
+ * roughly double the size of the bundle the browser downloads.
375
+ */
376
+ sessionList() {
377
+ return Object.values(this.sessions().rows).map((s) => {
378
+ const { models, ...rest } = s;
379
+ return {
380
+ ...rest,
381
+ total: s.in + s.out + s.cr + s.cw,
382
+ durationMs: new Date(s.end).getTime() - new Date(s.start).getTime(),
383
+ };
384
+ });
385
+ }
386
+ }
387
+
388
+ function num(v) {
389
+ return v === null || v === undefined || Number.isNaN(Number(v)) ? 0 : Number(v);
390
+ }
391
+
392
+ function addNullable(row, idx, v, naIdx) {
393
+ if (v === null || v === undefined) row[naIdx] += 1;
394
+ else row[idx] += v;
395
+ }
396
+
397
+ function emptyCube() {
398
+ return { version: CUBE_VERSION, dims: CUBE_DIMS, measures: CUBE_MEASURES, builtAt: null, rows: [] };
399
+ }
400
+
401
+ /**
402
+ * Rewrite the shards without records from stale generations.
403
+ *
404
+ * Called automatically after a per-provider re-ingest, and available as
405
+ * `tokenflow compact`. Falls back to an in-place rewrite on mounts that refuse
406
+ * rename.
407
+ */
408
+ export function compactShards(store) {
409
+ const stale = store.staleSet();
410
+ if (!stale.size) return { kept: 0, dropped: 0, shards: 0 };
411
+ let kept = 0;
412
+ let dropped = 0;
413
+ let shards = 0;
414
+ for (const shard of store.listShards()) {
415
+ const src = path.join(store.p.records, shard);
416
+ const tmp = src + '.compact';
417
+ let buf = '';
418
+ const out = fs.openSync(tmp, 'w');
419
+ try {
420
+ readLines(src, (line) => {
421
+ let o;
422
+ try { o = JSON.parse(line); } catch { return; }
423
+ if (stale.has(`${o.f}:${o.gn}`)) { dropped++; return; }
424
+ kept++;
425
+ buf += line + '\n';
426
+ if (buf.length > 1 << 20) { fs.writeSync(out, buf); buf = ''; }
427
+ });
428
+ if (buf) fs.writeSync(out, buf);
429
+ } finally {
430
+ fs.closeSync(out);
431
+ }
432
+ try {
433
+ fs.renameSync(tmp, src);
434
+ } catch (err) {
435
+ if (!['EPERM', 'EXDEV', 'EACCES', 'ENOTSUP'].includes(err.code)) throw err;
436
+ fs.writeFileSync(src, fs.readFileSync(tmp));
437
+ try { fs.rmSync(tmp, { force: true }); } catch { /* leave the temp file */ }
438
+ }
439
+ shards++;
440
+ }
441
+ store.state.stale = [];
442
+ return { kept, dropped, shards };
443
+ }
444
+
445
+ export function fileId(sourceId, key) {
446
+ return hashId(sourceId, key);
447
+ }
448
+
449
+ export function readJson(f, fallback) {
450
+ try {
451
+ return JSON.parse(fs.readFileSync(f, 'utf8'));
452
+ } catch {
453
+ return fallback;
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Atomic-ish write: temp file + rename, which is the right thing on a local
459
+ * disk. Some mounts (network shares, FUSE bridges, sandboxed volumes) refuse
460
+ * rename or unlink, so fall back to writing in place rather than failing the
461
+ * whole refresh.
462
+ */
463
+ export function writeJson(f, v) {
464
+ fs.mkdirSync(path.dirname(f), { recursive: true });
465
+ const body = JSON.stringify(v);
466
+ const tmp = f + '.tmp';
467
+ try {
468
+ fs.writeFileSync(tmp, body);
469
+ fs.renameSync(tmp, f);
470
+ return;
471
+ } catch (err) {
472
+ try { fs.rmSync(tmp, { force: true }); } catch { /* leave the temp file */ }
473
+ if (!['EPERM', 'EXDEV', 'EACCES', 'ENOTSUP', 'EBUSY'].includes(err.code)) throw err;
474
+ }
475
+ fs.writeFileSync(f, body);
476
+ }
477
+
478
+ /** Empty a file without unlinking it — mounts that block unlink allow this. */
479
+ export function truncateFile(f) {
480
+ try {
481
+ fs.rmSync(f, { force: true });
482
+ return;
483
+ } catch (err) {
484
+ if (!['EPERM', 'EACCES', 'ENOTSUP', 'EBUSY'].includes(err.code)) throw err;
485
+ }
486
+ try {
487
+ fs.truncateSync(f, 0);
488
+ } catch { /* nothing we can do; the shard will be rewritten in place */ }
489
+ }
490
+
491
+ function round6(n) {
492
+ return Math.round(n * 1e6) / 1e6;
493
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Multi-machine aggregation — optional, OFF by default, file-based.
3
+ *
4
+ * Philosophy: instead of a cloud SaaS endpoint, TokenFlow syncs through a
5
+ * folder the user already trusts (iCloud Drive, Dropbox, Syncthing mount,
6
+ * a git repo — anything that syncs files between their machines). The data
7
+ * that leaves this machine is exactly what the user can open and read:
8
+ * one JSONL file of daily rollups per machine. No raw prompts, no content,
9
+ * per-day granularity only.
10
+ *
11
+ * sync:
12
+ * enabled: false # ← default; nothing leaves the machine
13
+ * dir: ~/Sync/TokenFlow # shared folder both machines can see
14
+ * machineName: MacBook Pro # friendly label shown in aggregated views
15
+ *
16
+ * What is transmitted (per day, per provider/model):
17
+ * date, tokens in/out/cache, requests, estimated cost, machineId
18
+ * What is NEVER transmitted: prompts, code, file paths beyond the machine
19
+ * label you chose, credentials.
20
+ *
21
+ * Conflict resolution: each machine writes ONLY its own file
22
+ * (<machineId>.jsonl) — append-only, last-write-wins per line. Reads merge
23
+ * all sibling files by summing per-date buckets. Offline is the natural
24
+ * state: files just sync whenever the folder does.
25
+ */
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import os from 'node:os';
29
+ import crypto from 'node:crypto';
30
+ import { loadConfig, paths } from './config.js';
31
+
32
+ /** Stable, anonymous machine id: random UUID persisted locally on first use. */
33
+ export function machineId(cfgHome = null) {
34
+ const base = cfgHome || process.env.TOKENFLOW_HOME || path.join(os.homedir(), '.tokenflow');
35
+ const file = path.join(base, 'machine-id');
36
+ try { return fs.readFileSync(file, 'utf8').trim(); } catch { /* first run */ }
37
+ const id = 'm-' + crypto.randomUUID().slice(0, 8);
38
+ fs.mkdirSync(path.dirname(file), { recursive: true });
39
+ fs.writeFileSync(file, id);
40
+ return id;
41
+ }
42
+
43
+ export function isEnabled(cfg) {
44
+ return !!(cfg?.sync?.enabled && cfg.sync.dir);
45
+ }
46
+
47
+ export function syncDir(cfg) {
48
+ const d = cfg?.sync?.dir;
49
+ if (!d) throw new Error('sync.dir not configured');
50
+ return d.replace(/^~(?=$|\/)/, os.homedir());
51
+ }
52
+
53
+ /**
54
+ * Export this machine's daily rollups to the shared folder.
55
+ * @param {{config?: object}} opt
56
+ * @returns {{file:string, days:number}}
57
+ */
58
+ export function push(opt = {}) {
59
+ const cfg = opt.config || loadConfig();
60
+ if (!isEnabled(cfg)) throw new Error('sync is disabled (sync.enabled: false)');
61
+ const dir = ensureDir(cfg);
62
+
63
+ // Read the local daily cube rollup written by the dashboard/watch pipeline.
64
+ const cubeFile = `${paths().data}/cube.json`;
65
+ if (!fs.existsSync(cubeFile)) return { file: null, days: 0 };
66
+
67
+ const cube = JSON.parse(fs.readFileSync(cubeFile, 'utf8'));
68
+ const dims = cube.dims;
69
+ const di = dims.indexOf('d'); // date
70
+ const pi = dims.indexOf('p'); // provider
71
+ const off = dims.length;
72
+ const mIn = off + cube.measures.indexOf('in');
73
+ const mOut = off + cube.measures.indexOf('out');
74
+ const mReq = off + cube.measures.indexOf('req');
75
+ const mCost = off + cube.measures.indexOf('cost');
76
+
77
+ // Aggregate rows → one record per (date): totals across providers/models.
78
+ // Provider/model detail stays LOCAL; the synced file is deliberately coarse
79
+ // so the shared folder leaks minimum information.
80
+ const byDay = new Map();
81
+ for (const r of cube.rows) {
82
+ const day = r[di];
83
+ let acc = byDay.get(day);
84
+ if (!acc) { acc = { date: day, input: 0, output: 0, requests: 0, estCost: 0 }; byDay.set(day, acc); }
85
+ acc.input += r[mIn] || 0;
86
+ acc.output += r[mOut] || 0;
87
+ acc.requests += r[mReq] || 0;
88
+ acc.estCost += r[mCost] || 0;
89
+ }
90
+
91
+ const id = machineId();
92
+ const name = sanitizeName(cfg.sync.machineName || os.hostname().split('.')[0]);
93
+ const lines = [...byDay.values()]
94
+ .sort((a, b) => a.date.localeCompare(b.date))
95
+ .map((d) => JSON.stringify({
96
+ machineId: id, machineName: name, date: d.date,
97
+ inputTokens: d.input, outputTokens: d.output,
98
+ requests: d.requests, estCostUsd: Math.round(d.estCost * 10000) / 10000,
99
+ exportedAt: new Date().toISOString(),
100
+ }));
101
+
102
+ const file = path.join(dir, `${id}.jsonl`);
103
+ fs.writeFileSync(file, lines.join('\n') + (lines.length ? '\n' : ''));
104
+ return { file, days: lines.length };
105
+ }
106
+
107
+ /**
108
+ * Merge every sibling machine's file into combined daily totals.
109
+ * @returns {{machines: string[], days: Array}}
110
+ */
111
+ export function pull(opt = {}) {
112
+ const cfg = opt.config || loadConfig();
113
+ if (!isEnabled(cfg)) throw new Error('sync is disabled (sync.enabled: false)');
114
+ const dir = ensureDir(cfg);
115
+
116
+ const machines = [];
117
+ const byDate = new Map();
118
+
119
+ for (const f of fs.readdirSync(dir)) {
120
+ if (!f.endsWith('.jsonl')) continue;
121
+ const id = f.replace(/\.jsonl$/, '');
122
+ machines.push(id);
123
+ for (const line of fs.readFileSync(path.join(dir, f), 'utf8').split('\n')) {
124
+ if (!line.trim()) continue;
125
+ let rec;
126
+ try { rec = JSON.parse(line); } catch { continue; } // tolerate partial syncs
127
+ let bucket = byDate.get(rec.date);
128
+ if (!bucket) { bucket = { date: rec.date, machines: new Set(), input: 0, output: 0, requests: 0, estCost: 0 }; byDate.set(rec.date, bucket); }
129
+ bucket.input += rec.inputTokens || 0;
130
+ bucket.output += rec.outputTokens || 0;
131
+ bucket.requests += rec.requests || 0;
132
+ bucket.estCost += rec.estCostUsd || 0;
133
+ bucket.machines.add(rec.machineName || id);
134
+ }
135
+ }
136
+
137
+ const days = [...byDate.values()]
138
+ .map((b) => ({ ...b, machineCount: b.machines.size }))
139
+ .sort((a, b) => a.date.localeCompare(b.date));
140
+ return { machines, days };
141
+ }
142
+
143
+ function ensureDir(cfg) {
144
+ const d = syncDir(cfg);
145
+ fs.mkdirSync(d, { recursive: true });
146
+ return d;
147
+ }
148
+
149
+ function sanitizeName(n) {
150
+ return String(n).replace(/[^\w .-]/g, '').slice(0, 40) || 'machine';
151
+ }