@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,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `npm run validate` — a self-check an agent or a new contributor can run to
|
|
4
|
+
* confirm the install is sound before blaming the data.
|
|
5
|
+
*
|
|
6
|
+
* It verifies the runtime, the config directory, every adapter's detect(), the
|
|
7
|
+
* store's internal consistency, and that the analytics layer agrees with the
|
|
8
|
+
* stored facts. It never writes anything.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import { loadProviders, listProviders } from '../src/core/registry.js';
|
|
13
|
+
import { loadConfig, paths } from '../src/core/config.js';
|
|
14
|
+
import { Store } from '../src/core/store.js';
|
|
15
|
+
import { buildBundle } from '../src/core/bundle.js';
|
|
16
|
+
import { computeView, indexCube, filterCube, sumRows } from '../src/analytics/index.js';
|
|
17
|
+
import { validateUsage } from '../src/core/validate.js';
|
|
18
|
+
import { decodeRecord } from '../src/core/store.js';
|
|
19
|
+
|
|
20
|
+
const checks = [];
|
|
21
|
+
const add = (name, ok, detail = '') => checks.push({ name, ok, detail });
|
|
22
|
+
|
|
23
|
+
// ---- runtime ---------------------------------------------------------------
|
|
24
|
+
const [maj, min] = process.version.slice(1).split('.').map(Number);
|
|
25
|
+
add('Node >= 22.5 (node:sqlite, built-in test runner)', maj > 22 || (maj === 22 && min >= 5), process.version);
|
|
26
|
+
let sqliteOk = false;
|
|
27
|
+
try {
|
|
28
|
+
const { sqliteAvailable } = await import('../src/core/sqlite.js');
|
|
29
|
+
sqliteOk = sqliteAvailable();
|
|
30
|
+
} catch { /* module unavailable on this runtime */ }
|
|
31
|
+
add('node:sqlite available (SQLite-backed sources)', sqliteOk, sqliteOk ? '' : 'SQLite adapters will report as unavailable');
|
|
32
|
+
|
|
33
|
+
// ---- config ----------------------------------------------------------------
|
|
34
|
+
const p = paths();
|
|
35
|
+
let cfg = null;
|
|
36
|
+
try {
|
|
37
|
+
cfg = loadConfig();
|
|
38
|
+
add('config parses', true, p.configYaml);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
add('config parses', false, err.message);
|
|
41
|
+
}
|
|
42
|
+
add('data home writable', canWrite(p.root), p.root);
|
|
43
|
+
|
|
44
|
+
// ---- adapters --------------------------------------------------------------
|
|
45
|
+
await loadProviders();
|
|
46
|
+
const provs = listProviders();
|
|
47
|
+
add('providers load', provs.length > 0, `${provs.length} registered: ${provs.map((x) => x.id).join(', ')}`);
|
|
48
|
+
let detected = 0;
|
|
49
|
+
for (const pr of provs) {
|
|
50
|
+
let det;
|
|
51
|
+
try {
|
|
52
|
+
det = await pr.detect({ config: cfg || {}, home: os.homedir() });
|
|
53
|
+
add(` detect(${pr.id})`, true, det?.available ? `available — ${det.detail || ''}` : `not available — ${det?.detail || ''}`);
|
|
54
|
+
if (det?.available && pr.id !== 'mock') detected++;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
add(` detect(${pr.id})`, false, err.message);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
add('at least one real source detected', detected > 0 || !!process.env.TOKENFLOW_DEMO,
|
|
60
|
+
detected ? `${detected} source(s)` : 'none — run `tokenflow demo` or `tokenflow import <file>`');
|
|
61
|
+
|
|
62
|
+
// ---- store consistency -----------------------------------------------------
|
|
63
|
+
const store = new Store();
|
|
64
|
+
const cube = store.cube();
|
|
65
|
+
const bundle = buildBundle();
|
|
66
|
+
add('cube loads', Array.isArray(cube.rows), `${cube.rows.length} rows`);
|
|
67
|
+
|
|
68
|
+
// ---- live layer ------------------------------------------------------------
|
|
69
|
+
try {
|
|
70
|
+
const { normalizeLimits } = await import('../src/analytics/capacity.js');
|
|
71
|
+
const { limits, invalid } = normalizeLimits(cfg?.limits || []);
|
|
72
|
+
add('limits config valid', invalid.length === 0,
|
|
73
|
+
invalid.length ? invalid.map((x) => `${x.id ?? `#${x.index}`}: ${x.errors.join('; ')}`).join(' | ')
|
|
74
|
+
: `${limits.length} limit(s) declared`);
|
|
75
|
+
const statusFile = p.status;
|
|
76
|
+
const st = fs.existsSync(statusFile)
|
|
77
|
+
? JSON.parse(fs.readFileSync(statusFile, 'utf8'))
|
|
78
|
+
: null;
|
|
79
|
+
add('live status file readable', st === null || (st && typeof st === 'object' && !!st.schema),
|
|
80
|
+
st === null ? 'not present — run `tokenflow watch` or `watch --once` to create it'
|
|
81
|
+
: `schema ${st.schema}, generatedAt ${st.generatedAt}`);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
add('live layer checks', false, err.message);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (cube.rows.length) {
|
|
87
|
+
// Sum the request-level facts and compare with the cube. If these disagree,
|
|
88
|
+
// the aggregates are stale and `tokenflow compact` should be run.
|
|
89
|
+
let recTotal = 0;
|
|
90
|
+
let recCount = 0;
|
|
91
|
+
let invalid = 0;
|
|
92
|
+
store.scanRecords((o) => {
|
|
93
|
+
recCount++;
|
|
94
|
+
const r = decodeRecord(o);
|
|
95
|
+
if (recCount <= 5000 && !validateUsage(r).ok) invalid++;
|
|
96
|
+
for (const k of ['in', 'ou', 'cr', 'cw']) recTotal += o[k] || 0;
|
|
97
|
+
});
|
|
98
|
+
const ix = indexCube(cube);
|
|
99
|
+
const cubeTotal = sumRows(filterCube(ix, { includeOverlay: true }), ix).total;
|
|
100
|
+
const drift = recTotal ? Math.abs(cubeTotal - recTotal) / recTotal : 0;
|
|
101
|
+
add('cube agrees with stored records', drift < 1e-9,
|
|
102
|
+
recCount ? `records ${recTotal.toLocaleString()} vs cube ${cubeTotal.toLocaleString()}${drift ? ` (drift ${(drift * 100).toFixed(4)}% — run "tokenflow compact")` : ''}` : 'no raw records kept');
|
|
103
|
+
add('stored records validate', invalid === 0, invalid ? `${invalid} invalid in the first 5000` : `${Math.min(recCount, 5000)} checked`);
|
|
104
|
+
|
|
105
|
+
const v = computeView(bundle, {});
|
|
106
|
+
const seriesTotal = v.daily.reduce((a, d) => a + d.total, 0);
|
|
107
|
+
add('daily series sums to the headline total', seriesTotal === v.totals.total,
|
|
108
|
+
`${seriesTotal.toLocaleString()} vs ${v.totals.total.toLocaleString()}`);
|
|
109
|
+
const dimTotal = v.dimensions.providers.reduce((a, x) => a + x.total, 0);
|
|
110
|
+
add('provider breakdown sums to the headline total', dimTotal === v.totals.total,
|
|
111
|
+
`${dimTotal.toLocaleString()} vs ${v.totals.total.toLocaleString()}`);
|
|
112
|
+
const comp = v.composition.input + v.composition.output + v.composition.cacheRead + v.composition.cacheWrite;
|
|
113
|
+
add('composition is exhaustive and non-overlapping', comp === v.totals.total,
|
|
114
|
+
`${comp.toLocaleString()} vs ${v.totals.total.toLocaleString()}`);
|
|
115
|
+
add('no superseded records pending compaction', (store.state.stale || []).length === 0,
|
|
116
|
+
(store.state.stale || []).length ? `${store.state.stale.length} stale generation(s) — run "tokenflow compact"` : '');
|
|
117
|
+
} else {
|
|
118
|
+
add('data present', false, 'no usage ingested yet — run `tokenflow refresh`');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---- report ----------------------------------------------------------------
|
|
122
|
+
const pad = (s, n) => (String(s).length >= n ? String(s) : String(s) + ' '.repeat(n - String(s).length));
|
|
123
|
+
console.log('');
|
|
124
|
+
let failed = 0;
|
|
125
|
+
for (const c of checks) {
|
|
126
|
+
if (!c.ok) failed++;
|
|
127
|
+
console.log(` ${c.ok ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m'} ${pad(c.name, 48)} \x1b[2m${c.detail}\x1b[0m`);
|
|
128
|
+
}
|
|
129
|
+
console.log(`\n ${failed ? `\x1b[31m${failed} check(s) failed\x1b[0m` : '\x1b[32mall checks passed\x1b[0m'}\n`);
|
|
130
|
+
process.exit(failed ? 1 : 0);
|
|
131
|
+
|
|
132
|
+
function canWrite(dir) {
|
|
133
|
+
try {
|
|
134
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
135
|
+
fs.accessSync(dir, fs.constants.W_OK);
|
|
136
|
+
return true;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tokenflow
|
|
3
|
+
description: Install, configure, extend or debug the Tokenflow — a local-first analytics platform for AI token usage across Claude Code, Codex, Cline, Cursor, gateways and generic exports. Use when the user wants to see their own AI token usage, connect a new usage source, add a provider adapter, import a usage export, configure pricing, or when usage numbers look wrong.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Tokenflow
|
|
7
|
+
|
|
8
|
+
A local-first, provider-agnostic analytics platform for AI token usage. Zero dependencies, Node
|
|
9
|
+
22.5+. Your job is to get it running against whatever AI tools this machine actually has, and to
|
|
10
|
+
be honest about what the data can and cannot support.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
Clone → Configure → Connect sources → Refresh → Dashboard
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Read this first: the six invariants
|
|
19
|
+
|
|
20
|
+
Every mistake worth making here is a violation of one of these. They are stated with the failure
|
|
21
|
+
each one prevents, because you will meet cases the rules don't enumerate.
|
|
22
|
+
|
|
23
|
+
**1. Missing is not zero.** A token field is `number | null`. `null` = "this source does not
|
|
24
|
+
report it"; `0` = "it reported zero". Never write `|| 0` on a token field (the linter fails the
|
|
25
|
+
build). *Prevents:* Cline logs sessions with no token counts at all — zero-filling them would drag
|
|
26
|
+
every average toward zero and invent thousands of free requests.
|
|
27
|
+
|
|
28
|
+
**2. Cache tokens are not input tokens.** Four mutually exclusive billable buckets sum to the
|
|
29
|
+
total: fresh `input`, `cache_read`, `cache_write`, `output`. `cache_refresh` ⊆ `cache_write` and
|
|
30
|
+
`reasoning` ⊆ `output` are *subsets* and are never added again. *Prevents:* double counting every
|
|
31
|
+
cached prompt token — which on cache-heavy agent usage is >90% of all traffic.
|
|
32
|
+
|
|
33
|
+
**3. Vendors disagree about `input_tokens`.** Anthropic reports it *excluding* cache; OpenAI
|
|
34
|
+
reports it *including* `cached_input_tokens`. An adapter for an OpenAI-convention source must
|
|
35
|
+
subtract. *Prevents:* the same double count as (2), arriving through a different door.
|
|
36
|
+
|
|
37
|
+
**4. Streaming logs re-report the same usage.** A Claude Code transcript writes one assistant
|
|
38
|
+
message several times with a growing `output_tokens`; a Codex rollout re-reports a turn's context
|
|
39
|
+
as it grows. Take the **maximum of each monotonic run**, never the sum. *Prevents:* a measured
|
|
40
|
+
**45x** inflation — one real day read as 82.8 B tokens instead of 1.8 B, and one 2.5-minute
|
|
41
|
+
subagent session claiming 7.5 B tokens across 49,739 events for 266 actual turns.
|
|
42
|
+
|
|
43
|
+
**5. A gateway is not a vendor, and its log is not extra usage.** A proxy (`model_provider:
|
|
44
|
+
"headroom"`) is recorded as `gateway`; the vendor comes from the model name. Proxy records are
|
|
45
|
+
`measurement: overlay` and excluded from token totals. *Prevents:* counting the same request
|
|
46
|
+
twice — once from the client, once from the proxy — while still keeping the one thing only the
|
|
47
|
+
gateway knows: a *measured* dollar cost.
|
|
48
|
+
|
|
49
|
+
**6. Interface is never inferred from the model.** It comes only from an explicit surface field
|
|
50
|
+
(`entrypoint`, `originator`, `source`, an IDE marker). No signal → `Unknown`. *Prevents:*
|
|
51
|
+
"it's a Claude model so it must be Claude Desktop".
|
|
52
|
+
|
|
53
|
+
**7. A price needs a source, and a tier is a multiplier.** Every built-in rate names where it was
|
|
54
|
+
fetched from and when. Service tier is a *price multiplier* applied per request — OpenAI's Fast
|
|
55
|
+
mode (renamed from "priority" on 2026-07-30) is **4x** standard, Anthropic's Batch is 0.5x — and
|
|
56
|
+
the long-TTL cache write tier is billed above the 5-minute one. *Prevents:* under-reporting a
|
|
57
|
+
Fast-mode-heavy workload by up to 4x, and quoting a confident total built on rates nobody can
|
|
58
|
+
check. Long-context premium tiers are knowingly **not** applied, so the estimate is a documented
|
|
59
|
+
under-count rather than an unexplained one.
|
|
60
|
+
|
|
61
|
+
And one framing rule: **token usage is not productivity.** The Productivity section reports
|
|
62
|
+
activity *proxies*, and correlations with an independent work signal, with `n` stated. Never
|
|
63
|
+
present a token count as an achievement.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Task: install and configure on this machine
|
|
68
|
+
|
|
69
|
+
### 1. Check the runtime
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
node -v # must be >= 22.5 (node:sqlite, node:test)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
There are no dependencies to install. `npm install` is a no-op.
|
|
76
|
+
|
|
77
|
+
### 2. Find out what this machine actually has
|
|
78
|
+
|
|
79
|
+
Don't guess — probe. See `providers/detection-matrix.md` for locations per tool and OS. The
|
|
80
|
+
high-value checks:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
ls -d ~/.claude ~/.claude-* ~/.config/claude 2>/dev/null # Claude Code (several homes are common)
|
|
84
|
+
ls -d ~/.codex/sessions 2>/dev/null # Codex CLI/IDE/Desktop
|
|
85
|
+
ls -d ~/.cline/data/sessions 2>/dev/null # Cline
|
|
86
|
+
ls ~/.cursor/ai-tracking/ai-code-tracking.db 2>/dev/null # Cursor activity
|
|
87
|
+
ls ~/.headroom/savings_events.jsonl 2>/dev/null # local gateway
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Then let the tool confirm it:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
node bin/tokenflow.js setup
|
|
94
|
+
node bin/tokenflow.js providers
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Two things to notice, and to report to the user:
|
|
98
|
+
|
|
99
|
+
- **A user may have several Claude Code homes** (`~/.claude-work`, `~/.claude-personal`). The
|
|
100
|
+
adapter auto-discovers any `~/.claude*` containing `projects/`. If they keep one elsewhere, add
|
|
101
|
+
it to `sources.anthropic.paths`.
|
|
102
|
+
- **A tool being absent is a normal outcome**, not an error. Say which were found and which
|
|
103
|
+
weren't, and why.
|
|
104
|
+
|
|
105
|
+
### 3. Handle non-standard locations
|
|
106
|
+
|
|
107
|
+
```yaml
|
|
108
|
+
# ~/.tokenflow/config.yaml
|
|
109
|
+
providers: [anthropic, openai, cline, cursor, headroom]
|
|
110
|
+
sources:
|
|
111
|
+
anthropic:
|
|
112
|
+
paths: ["/custom/claude-home", "~/.claude-work"]
|
|
113
|
+
openai:
|
|
114
|
+
paths: ["/custom/codex-home"]
|
|
115
|
+
cursor:
|
|
116
|
+
db: "~/.cursor/ai-tracking/ai-code-tracking.db"
|
|
117
|
+
timezone: "Asia/Kolkata" # dates and hours are resolved in this zone at ingest
|
|
118
|
+
ui:
|
|
119
|
+
defaultRange: all
|
|
120
|
+
defaultFrom: "2026-03-14" # optional floor for the default view
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
A full worked example is in `examples/config.yaml`. `config.json` is also accepted.
|
|
124
|
+
|
|
125
|
+
### 4. Ingest — in chunks if your shell is short-lived
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
node bin/tokenflow.js refresh --budget 30 # stops cleanly, saves state
|
|
129
|
+
node bin/tokenflow.js refresh --budget 30 # continues at the byte offset
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Repeat until the report no longer says "time budget reached". This is the right pattern in a
|
|
133
|
+
sandbox, a CI step, or any 45-second shell: a multi-gigabyte first ingest completes across several
|
|
134
|
+
calls with no partial writes. A first pass on ~5,000 session files typically reads several GB in
|
|
135
|
+
under a minute; the second refresh is ~2 seconds because unchanged files are skipped without
|
|
136
|
+
being read.
|
|
137
|
+
|
|
138
|
+
### 5. Verify before you report
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
npm run validate # runtime, adapters, and cube↔records agreement
|
|
142
|
+
node bin/tokenflow.js status
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`validate` is the one that matters: it re-sums the stored facts and compares them with the
|
|
146
|
+
aggregates. If they've drifted it tells you to run `tokenflow compact`.
|
|
147
|
+
|
|
148
|
+
**Then sanity-check the shape of the result.** If one day is 10–100x every other day, treat it as
|
|
149
|
+
suspect and investigate before presenting it (see *Task: numbers look wrong*). Reporting a
|
|
150
|
+
plausible-looking wrong number is worse than reporting that you're unsure.
|
|
151
|
+
|
|
152
|
+
### 6. Serve it
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
node bin/tokenflow.js up # refresh + rebuild the offline file + serve (one command)
|
|
156
|
+
node bin/tokenflow.js dashboard # http://127.0.0.1:7799, loopback only
|
|
157
|
+
node bin/tokenflow.js export --html # one self-contained offline file
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Use `--html` when the user can't reach a localhost port (remote shell, container, sandbox) — it
|
|
161
|
+
inlines the CSS, the analytics and chart code, and the data, and opens from `file://`.
|
|
162
|
+
|
|
163
|
+
Tell the user how to come back to it, because this is the question they always ask next:
|
|
164
|
+
|
|
165
|
+
- **`npm start`** (or double-click `Refresh & Open Dashboard.command` on macOS) refreshes,
|
|
166
|
+
rebuilds `tokenflow-dashboard.html`, and opens the live dashboard.
|
|
167
|
+
- The offline file cannot re-read logs — it is a file. It reports its own age and probes loopback
|
|
168
|
+
for a live dashboard; if one is running it offers to hand over and refresh there.
|
|
169
|
+
- For hands-off freshness, schedule `tokenflow up --no-serve` (launchd/systemd/cron — the plist
|
|
170
|
+
and cron line are in `docs/cli.md`).
|
|
171
|
+
|
|
172
|
+
If the user wants a particular look, set it in config rather than editing CSS:
|
|
173
|
+
|
|
174
|
+
```yaml
|
|
175
|
+
ui:
|
|
176
|
+
skin: aurora # aurora | terminal | editorial
|
|
177
|
+
mode: dark # dark | light
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Never hand-edit the series colours to match a skin. They belong to the mode, they were validated
|
|
181
|
+
for colour-blind separation against every skin's surface, and a "nicer" hue that fails that gate
|
|
182
|
+
makes two adjacent stack segments indistinguishable for ~8% of male readers.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Task: connect a source with no adapter
|
|
187
|
+
|
|
188
|
+
Two routes. Prefer **import** if the tool can export; write an **adapter** if it writes a log
|
|
189
|
+
continuously.
|
|
190
|
+
|
|
191
|
+
### Route A — generic import
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
node bin/tokenflow.js import ~/Downloads/usage.csv --dry-run
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
With no `--field` flags it prints the columns, infers a mapping, and previews five normalized
|
|
198
|
+
rows. Check the preview: `n/a` means that field is unmapped and will be stored as not-available,
|
|
199
|
+
which is correct if the source truly lacks it and wrong if you just didn't map it.
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
node bin/tokenflow.js import ~/Downloads/usage.csv \
|
|
203
|
+
--field timestamp=created_at \
|
|
204
|
+
--field model=model \
|
|
205
|
+
--field input_tokens=prompt_tokens \
|
|
206
|
+
--field output_tokens=completion_tokens \
|
|
207
|
+
--field cache_read_tokens=cached_tokens \
|
|
208
|
+
--field estimated_cost=cost_usd \
|
|
209
|
+
--default client=openrouter --default interface=API
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The mapping is saved and reused on every later refresh. Supports CSV/TSV/JSON/JSONL/SQLite; see
|
|
213
|
+
`examples/generic-mapping.json`.
|
|
214
|
+
|
|
215
|
+
**Before mapping, determine whether the source's input column includes cached tokens.** If it
|
|
216
|
+
does, you cannot fix that with a mapping — write an adapter (Route B) so the subtraction happens.
|
|
217
|
+
|
|
218
|
+
### Route B — write an adapter
|
|
219
|
+
|
|
220
|
+
Start from `providers/adapter-template.js`. Then, in order:
|
|
221
|
+
|
|
222
|
+
1. **Read a real sample first.** `head -3 <logfile> | python3 -m json.tool`. Identify: the
|
|
223
|
+
timestamp, the model, every token field, the session id, and any *surface* field.
|
|
224
|
+
2. **Work out the token convention.** Does the input field include cached tokens? Is there a
|
|
225
|
+
separate cache-write? Is `reasoning` inside `output`? Write what you concluded in the adapter's
|
|
226
|
+
header comment — that header is the most valuable part of an adapter.
|
|
227
|
+
3. **Check for re-reported usage.** Group the events of one logical call and look at the series.
|
|
228
|
+
Monotonically growing → snapshots, take the max. Genuinely disjoint → sum. Getting this
|
|
229
|
+
backwards is invariant 4, and it is the expensive mistake.
|
|
230
|
+
4. **Always resume from `ref.start` and return `offset`.** Otherwise every refresh re-emits the
|
|
231
|
+
whole file.
|
|
232
|
+
5. **Emit `interfaceSignals: [...]`**, never a guessed `interface`.
|
|
233
|
+
|
|
234
|
+
Install to `$TOKENFLOW_HOME/providers/<id>.js` (loaded automatically, shadows a built-in with the
|
|
235
|
+
same id) or to `src/providers/<id>/index.js` with a fixture and tests.
|
|
236
|
+
|
|
237
|
+
**Write these two tests.** The first proves the semantics; the second catches the expensive bug:
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
test('token semantics', async () => {
|
|
241
|
+
const { records } = await ingestFixtureAsync(mine, 'mine.jsonl');
|
|
242
|
+
assert.equal(records[0].input_tokens, 6000); // fresh, excludes cache
|
|
243
|
+
assert.equal(records[0].cache_write_tokens, null); // unreported stays null
|
|
244
|
+
for (const r of records) assert.ok(validateUsage(r).ok);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('re-reading the same bytes emits nothing new', async () => {
|
|
248
|
+
const first = await ingestFixtureAsync(mine, 'mine.jsonl');
|
|
249
|
+
const again = await ingestFixtureAsync(mine, 'mine.jsonl', {
|
|
250
|
+
start: first.result.offset, state: first.state,
|
|
251
|
+
});
|
|
252
|
+
assert.equal(again.records.length, 0);
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`schemas/normalized-record.json` is the authoritative field list.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Task: numbers look wrong
|
|
261
|
+
|
|
262
|
+
Work down this list; the causes are ordered by how often they're the answer.
|
|
263
|
+
|
|
264
|
+
### Too high
|
|
265
|
+
|
|
266
|
+
1. **Re-reported usage in an adapter** (invariant 4). Symptom: one day or session dwarfs the rest
|
|
267
|
+
by 10–100x. Check `metadata.token_count_events` / `metadata.usage_segments` in the Data
|
|
268
|
+
Explorer — a record reconstructed from hundreds of events is suspect if your adapter summed
|
|
269
|
+
them. Reproduce from the raw source with a throwaway script that computes both the naive sum
|
|
270
|
+
and the sum-of-run-maxima; the gap is the inflation.
|
|
271
|
+
2. **Overlay double counting** (invariant 5). Is "Include gateway overlay" on? It adds proxy
|
|
272
|
+
records to client records for the same traffic. Off by default.
|
|
273
|
+
3. **Cache read counted as input** (invariants 2, 3). `input + cache_read` should be the prompt
|
|
274
|
+
side with no overlap.
|
|
275
|
+
|
|
276
|
+
**Cross-check against something independent** — a gateway log, a billing page, a second tool.
|
|
277
|
+
Two sources agreeing is worth far more than one source being plausible. When the Codex
|
|
278
|
+
reconstruction in this repo was validated, the corrected figure (1.80 B input for a day) matched a
|
|
279
|
+
gateway's own billing log (~2.1 B lifetime) while the naive reading (82.8 B) did not.
|
|
280
|
+
|
|
281
|
+
### Too low / missing
|
|
282
|
+
|
|
283
|
+
1. **The date filter.** `ui.defaultFrom` sets a floor for the *default view*; the data is still
|
|
284
|
+
in the store and in `--all` exports.
|
|
285
|
+
2. **"Active days" means token-active days.** Days where only a no-token source (Cline, Cursor,
|
|
286
|
+
git) was active are counted separately as "activity-only".
|
|
287
|
+
3. **Per-adapter coverage.** Data health → Sources lists each adapter's real window. A source that
|
|
288
|
+
started logging in July does not cover March.
|
|
289
|
+
4. **The source pruned its own logs.** Claude Code defaults to ~30 days. Nothing can recover data
|
|
290
|
+
the source deleted. Say so plainly.
|
|
291
|
+
|
|
292
|
+
### Cost is blank
|
|
293
|
+
|
|
294
|
+
By design — no rate, no number, never a `$0`. `tokenflow pricing` lists models by volume with
|
|
295
|
+
their pricing status. Add rates in USD per million tokens:
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
node bin/tokenflow.js pricing --sources # what is already known, and from where
|
|
299
|
+
node bin/tokenflow.js pricing --set "some-model=5,25,0.5,6.25" # in,out,cacheRead,cacheWrite
|
|
300
|
+
node bin/tokenflow.js refresh --full
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Check `--sources` first: the built-in table already covers the current Anthropic, OpenAI,
|
|
304
|
+
DeepSeek, Z.ai and Google line-ups from their own published pages. If a model is genuinely
|
|
305
|
+
missing, fetch the vendor's own pricing page — not an aggregator — and add the rate. If you cannot
|
|
306
|
+
source it, leave it unpriced and tell the user which models are missing: a made-up rate silently
|
|
307
|
+
corrupts every cost figure on the page, and unlike a blank it cannot be spotted.
|
|
308
|
+
|
|
309
|
+
Do **not** bake a service-tier multiplier into a rate. The tier is recorded per request and the
|
|
310
|
+
multiplier is applied automatically.
|
|
311
|
+
|
|
312
|
+
### Aggregates disagree with the records
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
npm run validate
|
|
316
|
+
node bin/tokenflow.js compact # rewrites shards, rebuilds aggregates; always safe
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## Task: explain a number
|
|
322
|
+
|
|
323
|
+
Be precise about the denominator, and about what the number does not claim.
|
|
324
|
+
|
|
325
|
+
| Metric | Formula | What it does NOT mean |
|
|
326
|
+
|---|---|---|
|
|
327
|
+
| Output / input | `out / in` (in = **fresh** input) | not "efficiency" — with a cache-heavy agent `in` is tiny, so this is large and near-meaningless alone |
|
|
328
|
+
| Output / prompt sent | `out / (in + cr + cw)` | the honest prompt-heavy vs output-heavy measure |
|
|
329
|
+
| Cache hit rate | `cr / (in + cr)` | not cache *savings* — that needs prices |
|
|
330
|
+
| Cache / total | `(cr + cw) / total` | share of token activity, not of cost |
|
|
331
|
+
| Avg / active day | total ÷ days with **measured tokens** | not ÷ calendar days, and not ÷ activity-only days |
|
|
332
|
+
| Peak day | argmax over the filtered slice | may be an artefact — check it against the raw source before celebrating it |
|
|
333
|
+
| Estimated cost | Σ tokens × configured rate | an estimate from a price table, covering only priced requests; the coverage % is always shown |
|
|
334
|
+
| Measured cost | a gateway's own `cost_usd` | covers only gateway-routed traffic, so it is not comparable to a total |
|
|
335
|
+
| Correlation r | Pearson over overlapping days | a correlation, never causation, and never a productivity claim |
|
|
336
|
+
|
|
337
|
+
If a metric is unavailable, explain *why* rather than showing zero. The dashboard is built to do
|
|
338
|
+
this; match its tone.
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
## Command reference
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
node bin/tokenflow.js setup # detect and write config
|
|
346
|
+
node bin/tokenflow.js providers # what's connected, and why not
|
|
347
|
+
node bin/tokenflow.js provider add|remove <id>
|
|
348
|
+
node bin/tokenflow.js refresh [--full] [--provider x] [--budget 30] [--strict]
|
|
349
|
+
node bin/tokenflow.js status [--json]
|
|
350
|
+
node bin/tokenflow.js dashboard [--port n] [--no-open]
|
|
351
|
+
node bin/tokenflow.js up [--budget 60] [--no-serve] [--no-snapshot]
|
|
352
|
+
node bin/tokenflow.js restore <full-export.csv> --yes # rebuild a store from an export
|
|
353
|
+
node bin/tokenflow.js export --csv [--all] | --html
|
|
354
|
+
node bin/tokenflow.js pricing [--set "model=in,out[,cr[,cw]]"]
|
|
355
|
+
node bin/tokenflow.js import <file> [--field a=b] [--dry-run]
|
|
356
|
+
node bin/tokenflow.js config show|path|export|import
|
|
357
|
+
node bin/tokenflow.js doctor | validate | compact | reset --yes
|
|
358
|
+
node bin/tokenflow.js demo
|
|
359
|
+
npm test && npm run lint && npm run validate
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
`--json` on `refresh`, `status` and `providers` for scripting.
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
## Working style for this project
|
|
367
|
+
|
|
368
|
+
- **Verify, don't assert.** Run `npm run validate` and read the shape of the data before
|
|
369
|
+
reporting. If a number surprises you, it is more likely a bug than a discovery.
|
|
370
|
+
- **Say what you couldn't determine.** "Cline reports no token counts, so its 22 sessions appear
|
|
371
|
+
in activity metrics only" is a better answer than a total that quietly includes zeros.
|
|
372
|
+
- **Never invent a rate, a vendor, or an interface.** Unknown is a valid, supported value
|
|
373
|
+
throughout the schema, and the UI is designed to display it.
|
|
374
|
+
- **Prefer fixing the adapter over patching the analytics.** If a number is wrong, it is almost
|
|
375
|
+
always wrong at ingest. The analytics layer is unit-tested against its formulas.
|
|
376
|
+
- **Nothing leaves the machine.** There is no HTTP client in this codebase. Don't add one, and
|
|
377
|
+
don't paste session contents anywhere.
|
|
378
|
+
|
|
379
|
+
## Reference files
|
|
380
|
+
|
|
381
|
+
| File | Use |
|
|
382
|
+
|---|---|
|
|
383
|
+
| `providers/detection-matrix.md` | where each tool stores usage, per OS |
|
|
384
|
+
| `providers/adapter-template.js` | commented starting point for a new adapter |
|
|
385
|
+
| `schemas/normalized-record.json` | authoritative field list + missing-value contract |
|
|
386
|
+
| `schemas/config.schema.json` | every config option |
|
|
387
|
+
| `examples/config.yaml` | a fully worked multi-source configuration |
|
|
388
|
+
| `examples/generic-mapping.json` | a saved import mapping |
|
|
389
|
+
| `examples/session-transcript.md` | the awkward real-world log shapes, annotated |
|
|
390
|
+
|
|
391
|
+
Project docs: `docs/data-model.md`, `docs/providers.md`, `docs/creating-provider.md`,
|
|
392
|
+
`docs/architecture.md`, `docs/troubleshooting.md`.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# tokenflow — a fully worked configuration
|
|
2
|
+
# Lives at ~/.tokenflow/config.yaml (or $TOKENFLOW_HOME/config.yaml).
|
|
3
|
+
# config.json is also accepted if you prefer JSON.
|
|
4
|
+
|
|
5
|
+
version: 1
|
|
6
|
+
|
|
7
|
+
# Dates and hours are resolved in this zone AT INGEST and then stored, so "my
|
|
8
|
+
# peak hour" means your local peak hour. null = this machine's zone.
|
|
9
|
+
timezone: Asia/Kolkata
|
|
10
|
+
|
|
11
|
+
# Stamped onto every record. Present so a future team deployment can aggregate
|
|
12
|
+
# across machines without a schema change; unused in single-user mode.
|
|
13
|
+
identity:
|
|
14
|
+
user: vshah
|
|
15
|
+
machine: my-laptop
|
|
16
|
+
team: null
|
|
17
|
+
|
|
18
|
+
# Enabled adapters. An empty list means "everything detected".
|
|
19
|
+
providers:
|
|
20
|
+
- anthropic
|
|
21
|
+
- openai
|
|
22
|
+
- cline
|
|
23
|
+
- cursor
|
|
24
|
+
- headroom
|
|
25
|
+
- git
|
|
26
|
+
|
|
27
|
+
sources:
|
|
28
|
+
# Several Claude Code homes is normal — one per account. Listing them here
|
|
29
|
+
# skips auto-discovery entirely.
|
|
30
|
+
anthropic:
|
|
31
|
+
paths:
|
|
32
|
+
- "~/.claude"
|
|
33
|
+
- "~/.claude-work"
|
|
34
|
+
|
|
35
|
+
# $CODEX_HOME is respected; this overrides it.
|
|
36
|
+
openai:
|
|
37
|
+
paths:
|
|
38
|
+
- "~/.codex"
|
|
39
|
+
|
|
40
|
+
# Reports sessions but NO token counts, so every token field is null and the
|
|
41
|
+
# records are measurement:activity.
|
|
42
|
+
cline:
|
|
43
|
+
path: "~/.cline/data/sessions"
|
|
44
|
+
|
|
45
|
+
# SQLite; needs Node >= 22.5. Read from a temp snapshot so a live editor is
|
|
46
|
+
# never disturbed.
|
|
47
|
+
cursor:
|
|
48
|
+
db: "~/.cursor/ai-tracking/ai-code-tracking.db"
|
|
49
|
+
|
|
50
|
+
# A local gateway. measurement:overlay — excluded from token totals by
|
|
51
|
+
# default (it would double count the client adapter) but it carries the only
|
|
52
|
+
# MEASURED cost available anywhere.
|
|
53
|
+
headroom:
|
|
54
|
+
path: "~/.headroom"
|
|
55
|
+
|
|
56
|
+
# The independent work signal for the Productivity correlations.
|
|
57
|
+
git:
|
|
58
|
+
scanRoots:
|
|
59
|
+
- "~/code"
|
|
60
|
+
- "~/work"
|
|
61
|
+
autoFromUsage: true # also use cwds seen in ingested usage records
|
|
62
|
+
author: "you@example.com" # optional: only your own commits
|
|
63
|
+
since: "2026-01-01" # optional
|
|
64
|
+
|
|
65
|
+
store:
|
|
66
|
+
keepRaw: true # false still builds the cube; only the Data
|
|
67
|
+
rawRetentionDays: null # Explorer and full CSV export need the shards
|
|
68
|
+
|
|
69
|
+
analytics:
|
|
70
|
+
includeOverlaySources: false # leave false; see the headroom note above
|
|
71
|
+
minSessionGapMinutes: 30 # only used for sources with no session id
|
|
72
|
+
|
|
73
|
+
# Prepended to the built-in model -> vendor ruleset, so an internal alias maps
|
|
74
|
+
# to the right vendor instead of falling through to "unknown".
|
|
75
|
+
modelMappings:
|
|
76
|
+
- match: "^acme-llm-"
|
|
77
|
+
provider: acme
|
|
78
|
+
label: "Acme AI"
|
|
79
|
+
- match: "^internal-glm"
|
|
80
|
+
provider: zai
|
|
81
|
+
label: "Z.ai / GLM"
|
|
82
|
+
|
|
83
|
+
# Interface is never inferred from the model. If you know a client's surface and
|
|
84
|
+
# the source doesn't record one, say so here.
|
|
85
|
+
interfaceOverrides:
|
|
86
|
+
my-batch-runner: "API"
|
|
87
|
+
team-slackbot: "Web"
|
|
88
|
+
|
|
89
|
+
ui:
|
|
90
|
+
theme: dark
|
|
91
|
+
defaultRange: all
|
|
92
|
+
defaultFrom: "2026-03-14" # floor for the DEFAULT VIEW only
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openrouter-export",
|
|
3
|
+
"format": "csv",
|
|
4
|
+
"files": ["~/Downloads/openrouter-activity-*.csv"],
|
|
5
|
+
"timestampFormat": "iso",
|
|
6
|
+
"measurement": "primary",
|
|
7
|
+
"idField": "generation_id",
|
|
8
|
+
"defaults": {
|
|
9
|
+
"client": "openrouter",
|
|
10
|
+
"application": "OpenRouter",
|
|
11
|
+
"interface": "API"
|
|
12
|
+
},
|
|
13
|
+
"fields": {
|
|
14
|
+
"timestamp": "created_at",
|
|
15
|
+
"model": "model",
|
|
16
|
+
"input_tokens": "tokens_prompt",
|
|
17
|
+
"output_tokens": "tokens_completion",
|
|
18
|
+
"cache_read_tokens": "cached_tokens",
|
|
19
|
+
"estimated_cost": "cost_usd",
|
|
20
|
+
"session_id": "generation_id",
|
|
21
|
+
"project": "app_name"
|
|
22
|
+
},
|
|
23
|
+
"metadata": {
|
|
24
|
+
"note": "Saved to $TOKENFLOW_HOME/mappings/openrouter-export.json and reused on every refresh. Unmapped token fields (cache_write, reasoning) stay null, not 0. `estimated_cost` from the source is recorded as cost_basis:measured, so it is never overwritten by a price-table estimate. If this export's input column INCLUDED cached tokens, a mapping could not fix that — an adapter would be needed to subtract."
|
|
25
|
+
}
|
|
26
|
+
}
|