@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,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
|
+
}
|
package/src/core/sync.js
ADDED
|
@@ -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
|
+
}
|