@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,316 @@
1
+ /**
2
+ * The unified usage schema.
3
+ *
4
+ * Every provider adapter emits objects of this shape. Nothing downstream —
5
+ * analytics, UI, export — knows anything about a specific vendor.
6
+ *
7
+ * ## Missing-value contract (this is load-bearing)
8
+ *
9
+ * null the source does not report this field -> "not available"
10
+ * undefined normalised to null on construction
11
+ * 0 the source reported zero -> a real, measured zero
12
+ *
13
+ * Analytics NEVER coerce null to 0. Sums skip nulls and carry a parallel
14
+ * `na` (not-available) counter so the UI can say "cache tokens unreported by
15
+ * 22% of records in this slice" instead of silently drawing a zero.
16
+ *
17
+ * ## Token accounting (this is the part everyone gets wrong)
18
+ *
19
+ * BILLABLE_TOKEN_FIELDS are mutually exclusive and sum to total_tokens:
20
+ * input_tokens fresh, uncached prompt tokens
21
+ * cache_read_tokens prompt tokens served from a prompt cache (cheap)
22
+ * cache_write_tokens prompt tokens written into a prompt cache (premium)
23
+ * output_tokens generated tokens
24
+ *
25
+ * BREAKDOWN_TOKEN_FIELDS are SUBSETS of the above and must never be added
26
+ * into a total:
27
+ * cache_refresh_tokens subset of cache_write_tokens (long-TTL / refreshed
28
+ * cache writes, e.g. Anthropic's ephemeral_1h)
29
+ * reasoning_tokens subset of output_tokens (thinking / reasoning)
30
+ *
31
+ * Providers differ on whether `input_tokens` is inclusive of cache reads.
32
+ * Anthropic reports them separately; OpenAI/Codex reports a single
33
+ * `input_tokens` that INCLUDES `cached_input_tokens`. Each adapter is
34
+ * responsible for converting to the exclusive convention above, and the
35
+ * adapter tests assert it.
36
+ */
37
+
38
+ export const BILLABLE_TOKEN_FIELDS = /** @type {const} */ ([
39
+ 'input_tokens',
40
+ 'cache_read_tokens',
41
+ 'cache_write_tokens',
42
+ 'output_tokens',
43
+ ]);
44
+
45
+ export const BREAKDOWN_TOKEN_FIELDS = /** @type {const} */ ([
46
+ 'cache_refresh_tokens',
47
+ 'reasoning_tokens',
48
+ ]);
49
+
50
+ export const TOKEN_FIELDS = [...BILLABLE_TOKEN_FIELDS, ...BREAKDOWN_TOKEN_FIELDS];
51
+
52
+ /** How much a record can be trusted to contribute to token totals. */
53
+ export const MEASUREMENT = /** @type {const} */ ({
54
+ /** Authoritative per-request usage reported by the model API. Counted in totals. */
55
+ PRIMARY: 'primary',
56
+ /** A second view of traffic already counted elsewhere (gateway/proxy logs).
57
+ * Excluded from totals by default to avoid double counting; used for
58
+ * independent cost cross-checks. */
59
+ OVERLAY: 'overlay',
60
+ /** AI activity with no token accounting at all (IDE edits, sessions with no
61
+ * usage block, git commits). Contributes to activity metrics only. */
62
+ ACTIVITY: 'activity',
63
+ });
64
+
65
+ /** Interface / surface the request came from. Never inferred from the model. */
66
+ export const INTERFACE = /** @type {const} */ ({
67
+ CLI: 'CLI',
68
+ IDE: 'IDE',
69
+ DESKTOP: 'Desktop App',
70
+ WEB: 'Web',
71
+ API: 'API',
72
+ SDK: 'SDK',
73
+ EXTENSION: 'Extension',
74
+ UNKNOWN: 'Unknown',
75
+ });
76
+
77
+ export const INTERFACE_ORDER = [
78
+ INTERFACE.CLI,
79
+ INTERFACE.SDK,
80
+ INTERFACE.IDE,
81
+ INTERFACE.DESKTOP,
82
+ INTERFACE.WEB,
83
+ INTERFACE.API,
84
+ INTERFACE.EXTENSION,
85
+ INTERFACE.UNKNOWN,
86
+ ];
87
+
88
+ /** Coarse grouping used by the "CLI vs GUI" comparison. */
89
+ export function interfaceClass(iface) {
90
+ if (iface === INTERFACE.CLI || iface === INTERFACE.SDK) return 'CLI / headless';
91
+ if (iface === INTERFACE.IDE || iface === INTERFACE.EXTENSION) return 'IDE';
92
+ if (iface === INTERFACE.DESKTOP || iface === INTERFACE.WEB) return 'Desktop / Web';
93
+ if (iface === INTERFACE.API) return 'API';
94
+ return 'Unknown';
95
+ }
96
+
97
+ export const UNKNOWN = 'unknown';
98
+ export const NOT_AVAILABLE = null;
99
+
100
+ const NUM = (v) => (v === undefined || v === null || Number.isNaN(v) ? null : Number(v));
101
+ const STR = (v) => (v === undefined || v === null || v === '' ? null : String(v));
102
+
103
+ /**
104
+ * @typedef {Object} UsageRecord
105
+ * @property {string} id stable dedup key
106
+ * @property {string} timestamp ISO-8601 UTC
107
+ * @property {string} date YYYY-MM-DD in the capture timezone
108
+ * @property {number} hour 0-23 in the capture timezone
109
+ * @property {number} dow 0=Mon .. 6=Sun in the capture timezone
110
+ * @property {number} tz_offset capture tz offset in minutes
111
+ * @property {string} provider canonical vendor slug
112
+ * @property {string} provider_label
113
+ * @property {string|null} gateway routing layer (proxy/router), if any
114
+ * @property {string} model raw model identifier from the source
115
+ * @property {string} model_family
116
+ * @property {string} client tool that made the call
117
+ * @property {string} application human label for the client
118
+ * @property {string} interface one of INTERFACE
119
+ * @property {number|null} input_tokens
120
+ * @property {number|null} output_tokens
121
+ * @property {number|null} cache_read_tokens
122
+ * @property {number|null} cache_write_tokens
123
+ * @property {number|null} cache_refresh_tokens
124
+ * @property {number|null} reasoning_tokens
125
+ * @property {number|null} total_tokens
126
+ * @property {boolean} total_is_partial true when some billable field was N/A
127
+ * @property {string|null} session_id
128
+ * @property {string|null} conversation_id
129
+ * @property {string|null} request_id
130
+ * @property {string|null} project
131
+ * @property {string|null} repository
132
+ * @property {string|null} git_branch
133
+ * @property {string|null} category
134
+ * @property {string|null} service_tier billing tier (standard / priority / batch / ...)
135
+ * @property {number|null} estimated_cost
136
+ * @property {'measured'|'estimated'|null} cost_basis
137
+ * @property {string} source adapter id
138
+ * @property {string} measurement one of MEASUREMENT
139
+ * @property {string|null} user
140
+ * @property {string|null} machine
141
+ * @property {number|null} duration_ms
142
+ * @property {Object} metadata
143
+ * @property {string} [_fileId] internal: which source file this came from
144
+ * @property {number} [_gen] internal: that file's generation, for supersede
145
+ */
146
+
147
+ /**
148
+ * Build a fully-populated record from a partial one. Missing fields become
149
+ * `null` (not available) rather than 0, and totals are derived, never trusted
150
+ * from the source unless the source is the only thing that has them.
151
+ * @param {Partial<UsageRecord>} p
152
+ * @returns {UsageRecord}
153
+ */
154
+ export function createRecord(p) {
155
+ const r = /** @type {UsageRecord} */ ({
156
+ id: p.id ?? '',
157
+ timestamp: p.timestamp ?? null,
158
+ date: p.date ?? null,
159
+ hour: p.hour ?? null,
160
+ dow: p.dow ?? null,
161
+ tz_offset: p.tz_offset ?? null,
162
+
163
+ provider: p.provider ?? UNKNOWN,
164
+ provider_label: p.provider_label ?? 'Unknown',
165
+ gateway: STR(p.gateway),
166
+ model: p.model ?? UNKNOWN,
167
+ model_family: p.model_family ?? 'Unknown',
168
+ client: p.client ?? UNKNOWN,
169
+ application: p.application ?? 'Unknown',
170
+ interface: p.interface ?? INTERFACE.UNKNOWN,
171
+
172
+ input_tokens: NUM(p.input_tokens),
173
+ output_tokens: NUM(p.output_tokens),
174
+ cache_read_tokens: NUM(p.cache_read_tokens),
175
+ cache_write_tokens: NUM(p.cache_write_tokens),
176
+ cache_refresh_tokens: NUM(p.cache_refresh_tokens),
177
+ reasoning_tokens: NUM(p.reasoning_tokens),
178
+ total_tokens: null,
179
+ total_is_partial: false,
180
+
181
+ session_id: STR(p.session_id),
182
+ conversation_id: STR(p.conversation_id),
183
+ request_id: STR(p.request_id),
184
+ project: STR(p.project),
185
+ repository: STR(p.repository),
186
+ git_branch: STR(p.git_branch),
187
+ category: STR(p.category),
188
+ // A billing tier, not incidental metadata: OpenAI's Fast mode is 4x the
189
+ // standard rate, so this changes the cost of an otherwise identical request.
190
+ service_tier: STR(p.service_tier),
191
+
192
+ estimated_cost: NUM(p.estimated_cost),
193
+ cost_basis: p.cost_basis ?? null,
194
+
195
+ source: p.source ?? UNKNOWN,
196
+ measurement: p.measurement ?? MEASUREMENT.PRIMARY,
197
+ user: STR(p.user),
198
+ machine: STR(p.machine),
199
+ duration_ms: NUM(p.duration_ms),
200
+ metadata: p.metadata ?? {},
201
+ });
202
+
203
+ const t = computeTotal(r);
204
+ r.total_tokens = t.total;
205
+ r.total_is_partial = t.partial;
206
+ return r;
207
+ }
208
+
209
+ /**
210
+ * Total = sum of the four mutually-exclusive billable buckets.
211
+ * Returns `{total: null}` only when EVERY billable field is unavailable —
212
+ * a record that reports some fields still gets a total, flagged partial.
213
+ * @param {Partial<UsageRecord>} r
214
+ */
215
+ export function computeTotal(r) {
216
+ let total = 0;
217
+ let seen = 0;
218
+ let partial = false;
219
+ for (const f of BILLABLE_TOKEN_FIELDS) {
220
+ const v = r[f];
221
+ if (v === null || v === undefined) partial = true;
222
+ else {
223
+ total += v;
224
+ seen++;
225
+ }
226
+ }
227
+ if (seen === 0) return { total: null, partial: true };
228
+ return { total, partial };
229
+ }
230
+
231
+ /** Timezone-aware date parts, computed once at ingest so the UI never guesses. */
232
+ export function dateParts(timestamp, tz) {
233
+ const d = new Date(timestamp);
234
+ if (Number.isNaN(d.getTime())) return null;
235
+ // Use Intl to resolve the wall-clock in the capture timezone. `tz`
236
+ // undefined means "this machine's zone", which is what a local-first
237
+ // ingest wants.
238
+ const fmt = partsFormatter(tz);
239
+ const parts = {};
240
+ for (const p of fmt.formatToParts(d)) parts[p.type] = p.value;
241
+ const date = `${parts.year}-${parts.month}-${parts.day}`;
242
+ const hour = Number(parts.hour) % 24;
243
+ // 0=Mon..6=Sun
244
+ const jsDow = new Date(`${date}T00:00:00Z`).getUTCDay();
245
+ const dow = (jsDow + 6) % 7;
246
+ const offset = tzOffsetMinutes(d, tz);
247
+ return { date, hour, dow, tz_offset: offset, iso: d.toISOString() };
248
+ }
249
+
250
+ const _fmtCache = new Map();
251
+ function partsFormatter(tz) {
252
+ const key = tz || 'local';
253
+ let f = _fmtCache.get(key);
254
+ if (!f) {
255
+ f = new Intl.DateTimeFormat('en-CA', {
256
+ timeZone: tz || undefined,
257
+ year: 'numeric',
258
+ month: '2-digit',
259
+ day: '2-digit',
260
+ hour: '2-digit',
261
+ hour12: false,
262
+ });
263
+ _fmtCache.set(key, f);
264
+ }
265
+ return f;
266
+ }
267
+
268
+ /** Offset of `tz` east of UTC in minutes at instant `d` (fallback: host zone). */
269
+ export function tzOffsetMinutes(d, tz) {
270
+ if (!tz) return -d.getTimezoneOffset();
271
+ try {
272
+ const dtf = new Intl.DateTimeFormat('en-US', {
273
+ timeZone: tz,
274
+ hour12: false,
275
+ year: 'numeric', month: '2-digit', day: '2-digit',
276
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
277
+ });
278
+ const p = {};
279
+ for (const x of dtf.formatToParts(d)) p[x.type] = x.value;
280
+ const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour % 24, +p.minute, +p.second);
281
+ return Math.round((asUTC - d.getTime()) / 60000);
282
+ } catch {
283
+ return -d.getTimezoneOffset();
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Fast, stable, non-cryptographic 64-bit id. Used for dedup across refreshes.
289
+ * Two independent 32-bit FNV-1a passes; ~1e-10 collision risk at 10^6 keys.
290
+ */
291
+ export function hashId(...parts) {
292
+ const s = parts.join('\x00');
293
+ let h1 = 0x811c9dc5;
294
+ let h2 = 0x01000193;
295
+ for (let i = 0; i < s.length; i++) {
296
+ const c = s.charCodeAt(i);
297
+ h1 ^= c;
298
+ h1 = Math.imul(h1, 0x01000193) >>> 0;
299
+ h2 = Math.imul(h2 ^ c, 0x85ebca6b) >>> 0;
300
+ h2 = (h2 ^ (h2 >>> 13)) >>> 0;
301
+ }
302
+ return h1.toString(36).padStart(7, '0') + h2.toString(36).padStart(7, '0');
303
+ }
304
+
305
+ /** Dimensions the cube and the filter bar share. Order matters for the UI. */
306
+ export const DIMENSIONS = [
307
+ { key: 'provider', label: 'Provider', cube: 'p' },
308
+ { key: 'model', label: 'Model', cube: 'm' },
309
+ { key: 'model_family', label: 'Model family', cube: 'mf' },
310
+ { key: 'client', label: 'Client', cube: 'c' },
311
+ { key: 'interface', label: 'Interface', cube: 'i' },
312
+ { key: 'gateway', label: 'Gateway', cube: 'g' },
313
+ { key: 'project', label: 'Project', cube: 'pj' },
314
+ { key: 'repository', label: 'Repository', cube: 'rp' },
315
+ { key: 'service_tier', label: 'Service tier', cube: 'st' },
316
+ ];
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Read-only SQLite access via Node's built-in `node:sqlite` (Node >= 22.5).
3
+ * Zero dependencies, and never writes to the user's database.
4
+ *
5
+ * Live databases are often mid-transaction with a hot -wal file. Opening the
6
+ * original can fail or read a stale page, so we snapshot the db plus its
7
+ * sidecars into the cache dir and read the copy. Slower by a few milliseconds;
8
+ * correct, and it cannot corrupt someone's editor state.
9
+ */
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { createRequire } from 'node:module';
14
+
15
+ const require_ = createRequire(import.meta.url);
16
+
17
+ let _sqlite = null;
18
+ function sqlite() {
19
+ if (_sqlite) return _sqlite;
20
+ // Silence just the one experimental warning, without muting the channel.
21
+ const orig = process.emitWarning;
22
+ process.emitWarning = (w, ...rest) => {
23
+ const s = typeof w === 'string' ? w : w?.message || '';
24
+ if (/SQLite is an experimental feature/i.test(s)) return;
25
+ return orig.call(process, w, ...rest);
26
+ };
27
+ try {
28
+ _sqlite = require_('node:sqlite');
29
+ } catch (err) {
30
+ const e = new Error('node:sqlite is unavailable — Node 22.5 or newer is required for SQLite sources');
31
+ e.cause = err;
32
+ throw e;
33
+ } finally {
34
+ process.emitWarning = orig;
35
+ }
36
+ return _sqlite;
37
+ }
38
+
39
+ export function sqliteAvailable() {
40
+ try {
41
+ return !!sqlite().DatabaseSync;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ export function openReadOnly(file, { snapshot = true } = {}) {
48
+ const { DatabaseSync } = sqlite();
49
+ let target = file;
50
+ let tmpDir = null;
51
+ if (snapshot) {
52
+ // Deliberately the OS temp dir, not the config dir: the config dir may sit
53
+ // on a network share or sandboxed mount that refuses unlink, and a snapshot
54
+ // we cannot delete is worse than a slightly less tidy temp path.
55
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tokenflow-sqlite-'));
56
+ target = path.join(tmpDir, path.basename(file));
57
+ fs.copyFileSync(file, target);
58
+ for (const ext of ['-wal', '-shm']) {
59
+ if (fs.existsSync(file + ext)) fs.copyFileSync(file + ext, target + ext);
60
+ }
61
+ }
62
+ const db = new DatabaseSync(target);
63
+ const origClose = db.close.bind(db);
64
+ db.close = () => {
65
+ try {
66
+ origClose();
67
+ } finally {
68
+ // Never let cleanup failure sink an otherwise successful ingest.
69
+ if (tmpDir) {
70
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* temp dir left behind */ }
71
+ }
72
+ }
73
+ };
74
+ return db;
75
+ }
76
+
77
+ /** List the tables in a database — used by the generic SQLite importer. */
78
+ export function tables(file) {
79
+ const db = openReadOnly(file);
80
+ try {
81
+ return db.prepare("SELECT name, sql FROM sqlite_master WHERE type='table' ORDER BY name").all();
82
+ } finally {
83
+ db.close();
84
+ }
85
+ }
86
+
87
+ /** Column names of a table, for interactive field mapping. */
88
+ export function columns(file, table) {
89
+ const db = openReadOnly(file);
90
+ try {
91
+ const row = db.prepare(`SELECT * FROM "${String(table).replace(/"/g, '""')}" LIMIT 1`).get();
92
+ return row ? Object.keys(row) : [];
93
+ } finally {
94
+ db.close();
95
+ }
96
+ }