@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,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local dashboard server.
|
|
3
|
+
*
|
|
4
|
+
* Binds to 127.0.0.1 by default and serves the UI plus a small JSON API. No
|
|
5
|
+
* outbound network calls are ever made; the "server" exists only so the
|
|
6
|
+
* browser can read local files and trigger a refresh.
|
|
7
|
+
*
|
|
8
|
+
* ES modules are served straight from src/, so the browser imports the very
|
|
9
|
+
* same analytics code the CLI runs. There is no build step and no bundler in
|
|
10
|
+
* the development path.
|
|
11
|
+
*/
|
|
12
|
+
import http from 'node:http';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { randomBytes } from 'node:crypto';
|
|
16
|
+
import { buildBundle, queryRecords, rootDir } from '../core/bundle.js';
|
|
17
|
+
import { refresh } from '../core/ingest.js';
|
|
18
|
+
import { loadProviders, listProviders } from '../core/registry.js';
|
|
19
|
+
import { loadConfig, saveConfig, paths, savePrefs, loadPrefs, DEFAULT_CONFIG, merge } from '../core/config.js';
|
|
20
|
+
import { currentStatus } from '../core/live-status.js';
|
|
21
|
+
import { PROVIDER_REGIONS, resolveMyLocation } from '../core/geo.js';
|
|
22
|
+
import { normalizeLimits } from '../analytics/capacity.js';
|
|
23
|
+
import { streamRecordsCsv, exportFilename } from '../export/csv.js';
|
|
24
|
+
import { writeJson, readJson } from '../core/store.js';
|
|
25
|
+
|
|
26
|
+
const MIME = {
|
|
27
|
+
'.html': 'text/html; charset=utf-8',
|
|
28
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
29
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
30
|
+
'.css': 'text/css; charset=utf-8',
|
|
31
|
+
'.json': 'application/json; charset=utf-8',
|
|
32
|
+
'.svg': 'image/svg+xml',
|
|
33
|
+
'.map': 'application/json',
|
|
34
|
+
'.ico': 'image/x-icon',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export async function startServer({ port = 7799, host = '127.0.0.1', open = false, token = null } = {}) {
|
|
38
|
+
const ROOT = rootDir();
|
|
39
|
+
const UI = path.join(ROOT, 'src', 'ui');
|
|
40
|
+
await loadProviders();
|
|
41
|
+
const authToken = token === false ? null : token || randomBytes(12).toString('base64url');
|
|
42
|
+
|
|
43
|
+
let refreshing = false;
|
|
44
|
+
|
|
45
|
+
const server = http.createServer(async (req, res) => {
|
|
46
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
47
|
+
const p = url.pathname;
|
|
48
|
+
try {
|
|
49
|
+
// Loopback-only + a token on state-changing calls: a page on another
|
|
50
|
+
// origin in the same browser must not be able to poke this API.
|
|
51
|
+
if (req.method !== 'GET' && authToken) {
|
|
52
|
+
const given = url.searchParams.get('token') || req.headers['x-tokenflow-token'];
|
|
53
|
+
const sameSite = !req.headers.origin || req.headers.origin === `http://${host}:${port}` || req.headers.origin === `http://localhost:${port}`;
|
|
54
|
+
if (!sameSite && given !== authToken) return send(res, 403, 'text/plain', 'forbidden');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (p === '/' || p === '/index.html') {
|
|
58
|
+
return sendFile(res, path.join(UI, 'index.html'));
|
|
59
|
+
}
|
|
60
|
+
if (p.startsWith('/src/')) {
|
|
61
|
+
const target = path.normalize(path.join(ROOT, p));
|
|
62
|
+
if (!target.startsWith(path.join(ROOT, 'src'))) return send(res, 403, 'text/plain', 'forbidden');
|
|
63
|
+
return sendFile(res, target);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (p === '/api/bundle') {
|
|
67
|
+
return json(res, buildBundle());
|
|
68
|
+
}
|
|
69
|
+
if (p === '/api/health') {
|
|
70
|
+
const b = buildBundle();
|
|
71
|
+
return json(res, { health: b.health, meta: b.meta });
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A deliberately tiny, read-only, CORS-open liveness probe.
|
|
75
|
+
*
|
|
76
|
+
* It exists so a saved HTML snapshot opened from file:// can tell whether
|
|
77
|
+
* a live dashboard is running on this machine and offer to hand over to
|
|
78
|
+
* it. It answers only "yes, and this is how fresh I am" — no usage data,
|
|
79
|
+
* no config, no paths — and it is GET-only, so no cross-origin page can
|
|
80
|
+
* use it to change anything. The snapshot never POSTs across origins:
|
|
81
|
+
* it navigates to the live UI with ?refresh=1 and lets that same-origin
|
|
82
|
+
* page do the refresh with its own token.
|
|
83
|
+
*/
|
|
84
|
+
if (p === '/api/ping') {
|
|
85
|
+
const st = readJson(paths().state, {});
|
|
86
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
87
|
+
return json(res, {
|
|
88
|
+
ok: true,
|
|
89
|
+
app: 'tokenflow',
|
|
90
|
+
port,
|
|
91
|
+
records: st.counters?.records ?? null,
|
|
92
|
+
lastRefresh: st.lastRefresh || null,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (p === '/api/providers') {
|
|
96
|
+
const cfg = loadConfig();
|
|
97
|
+
const ctx = { config: cfg, home: process.env.HOME };
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const pr of listProviders()) {
|
|
100
|
+
let det = { available: false, detail: 'detect() failed' };
|
|
101
|
+
try { det = await pr.detect(ctx); } catch (e) { det = { available: false, detail: e.message }; }
|
|
102
|
+
out.push({ ...pr.getMetadata(), ...det, enabled: !cfg.providers.length || cfg.providers.includes(pr.id) });
|
|
103
|
+
}
|
|
104
|
+
return json(res, { providers: out, configured: cfg.providers });
|
|
105
|
+
}
|
|
106
|
+
if (p === '/api/records') {
|
|
107
|
+
return json(res, queryRecords(Object.fromEntries(url.searchParams)));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The live snapshot: the watcher's last write when it is fresh, else a
|
|
111
|
+
* fresh computation. Same shape the menu bar and `tokenflow usage`
|
|
112
|
+
* consume, so the dashboard's Live tab cannot disagree with either.
|
|
113
|
+
*/
|
|
114
|
+
if (p === '/api/live') {
|
|
115
|
+
const { status, fromWatch } = currentStatus({ maxAgeMs: 30000 });
|
|
116
|
+
return json(res, { ...status, fromWatch });
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Map geography, for the Global activity section. Two layers:
|
|
120
|
+
* - providerRegions: static vendor datacenter regions per measured
|
|
121
|
+
* provider (offline public knowledge — never per-request claims)
|
|
122
|
+
* - myLocation: ONLY when `map.showMyLocation: true` is set in config
|
|
123
|
+
* (one cached IP lookup of this machine; the IP itself is never
|
|
124
|
+
* stored). Disabled ⇒ null, and nothing is sent anywhere.
|
|
125
|
+
*/
|
|
126
|
+
if (p === '/api/geo') {
|
|
127
|
+
const cfg = loadConfig();
|
|
128
|
+
return json(res, {
|
|
129
|
+
providerRegions: PROVIDER_REGIONS,
|
|
130
|
+
myLocation: await resolveMyLocation({ config: cfg }),
|
|
131
|
+
note: 'regions are vendor-published possibilities, not per-request facts',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Limited config editing from the dashboard: only the limits list and
|
|
136
|
+
* watch preferences may change, both validated before saving. Everything
|
|
137
|
+
* else (providers, sources, identity) stays CLI-managed on purpose.
|
|
138
|
+
*/
|
|
139
|
+
if (p === '/api/config' && req.method === 'POST') {
|
|
140
|
+
const body = JSON.parse(await readBody(req) || '{}');
|
|
141
|
+
const cfg = loadConfig();
|
|
142
|
+
const out = { updated: [] };
|
|
143
|
+
if (body.limits !== undefined) {
|
|
144
|
+
const { limits, invalid } = normalizeLimits(body.limits);
|
|
145
|
+
if (invalid.length) return json(res, { ok: false, invalid }, 400);
|
|
146
|
+
cfg.limits = limits;
|
|
147
|
+
out.updated.push('limits');
|
|
148
|
+
out.limits = limits;
|
|
149
|
+
}
|
|
150
|
+
if (body.watch !== undefined) {
|
|
151
|
+
const w = body.watch || {};
|
|
152
|
+
cfg.watch = {
|
|
153
|
+
intervalSeconds: w.intervalSeconds !== undefined
|
|
154
|
+
? Math.max(10, Number(w.intervalSeconds) || 120)
|
|
155
|
+
: (cfg.watch?.intervalSeconds ?? 120),
|
|
156
|
+
notifications: w.notifications !== undefined
|
|
157
|
+
? !!w.notifications
|
|
158
|
+
: !!cfg.watch?.notifications,
|
|
159
|
+
staleAfterSeconds: w.staleAfterSeconds !== undefined
|
|
160
|
+
? Math.max(60, Number(w.staleAfterSeconds) || 600)
|
|
161
|
+
: (cfg.watch?.staleAfterSeconds ?? 600),
|
|
162
|
+
};
|
|
163
|
+
out.updated.push('watch');
|
|
164
|
+
out.watch = cfg.watch;
|
|
165
|
+
}
|
|
166
|
+
saveConfig(cfg);
|
|
167
|
+
return json(res, { ok: true, ...out });
|
|
168
|
+
}
|
|
169
|
+
if (p === '/api/export.csv') {
|
|
170
|
+
const q = Object.fromEntries(url.searchParams);
|
|
171
|
+
res.writeHead(200, {
|
|
172
|
+
'content-type': 'text/csv; charset=utf-8',
|
|
173
|
+
'content-disposition': `attachment; filename="${exportFilename()}"`,
|
|
174
|
+
'cache-control': 'no-store',
|
|
175
|
+
});
|
|
176
|
+
const filter = q.scope === 'all' ? {} : q;
|
|
177
|
+
streamRecordsCsv((chunk) => res.write(chunk), filter);
|
|
178
|
+
return res.end();
|
|
179
|
+
}
|
|
180
|
+
if (p === '/api/refresh' && req.method === 'POST') {
|
|
181
|
+
if (refreshing) return send(res, 409, 'text/plain', 'a refresh is already running');
|
|
182
|
+
refreshing = true;
|
|
183
|
+
res.writeHead(200, { 'content-type': 'application/x-ndjson', 'cache-control': 'no-store', 'x-accel-buffering': 'no' });
|
|
184
|
+
try {
|
|
185
|
+
const budget = Number(url.searchParams.get('budgetMs')) || 0;
|
|
186
|
+
await refresh({
|
|
187
|
+
registry: listProviders(),
|
|
188
|
+
deadlineMs: budget || undefined,
|
|
189
|
+
onProgress: (ev) => {
|
|
190
|
+
try { res.write(JSON.stringify(ev) + '\n'); } catch { /* client gone */ }
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
} catch (err) {
|
|
194
|
+
res.write(JSON.stringify({ type: 'error', message: err.message }) + '\n');
|
|
195
|
+
} finally {
|
|
196
|
+
refreshing = false;
|
|
197
|
+
res.end();
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
if (p === '/api/pricing') {
|
|
202
|
+
if (req.method === 'GET') return json(res, readJson(paths().pricing, {}));
|
|
203
|
+
const body = await readBody(req);
|
|
204
|
+
const parsed = JSON.parse(body || '{}');
|
|
205
|
+
writeJson(paths().pricing, { updatedAt: new Date().toISOString(), ...parsed });
|
|
206
|
+
return json(res, { ok: true });
|
|
207
|
+
}
|
|
208
|
+
if (p === '/api/prefs') {
|
|
209
|
+
if (req.method === 'GET') return json(res, loadPrefs());
|
|
210
|
+
const body = await readBody(req);
|
|
211
|
+
savePrefs(JSON.parse(body || '{}'));
|
|
212
|
+
return json(res, { ok: true });
|
|
213
|
+
}
|
|
214
|
+
if (p === '/api/config') {
|
|
215
|
+
return json(res, { config: loadConfig(), paths: paths() });
|
|
216
|
+
}
|
|
217
|
+
return send(res, 404, 'text/plain', 'not found');
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error(err);
|
|
220
|
+
return send(res, 500, 'application/json', JSON.stringify({ error: err.message }));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await new Promise((resolve, reject) => {
|
|
225
|
+
server.on('error', reject);
|
|
226
|
+
server.listen(port, host, () => resolve(undefined));
|
|
227
|
+
});
|
|
228
|
+
// address() is `string` for a pipe/socket and AddressInfo for TCP.
|
|
229
|
+
const bound = server.address();
|
|
230
|
+
const boundPort = typeof bound === 'object' && bound !== null ? bound.port : port;
|
|
231
|
+
const addr = `http://${host}:${boundPort}`;
|
|
232
|
+
return { server, url: addr, token: authToken, close: () => new Promise((r) => server.close(r)) };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function send(res, code, type, body) {
|
|
236
|
+
res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' });
|
|
237
|
+
res.end(body);
|
|
238
|
+
}
|
|
239
|
+
function json(res, obj, code = 200) {
|
|
240
|
+
const s = JSON.stringify(obj);
|
|
241
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
242
|
+
res.end(s);
|
|
243
|
+
}
|
|
244
|
+
function sendFile(res, file) {
|
|
245
|
+
fs.readFile(file, (err, buf) => {
|
|
246
|
+
if (err) return send(res, 404, 'text/plain', 'not found');
|
|
247
|
+
res.writeHead(200, {
|
|
248
|
+
'content-type': MIME[path.extname(file)] || 'application/octet-stream',
|
|
249
|
+
'cache-control': 'no-store',
|
|
250
|
+
});
|
|
251
|
+
res.end(buf);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function readBody(req) {
|
|
255
|
+
return new Promise((resolve, reject) => {
|
|
256
|
+
let b = '';
|
|
257
|
+
req.on('data', (c) => {
|
|
258
|
+
b += c;
|
|
259
|
+
if (b.length > 4 << 20) reject(new Error('body too large'));
|
|
260
|
+
});
|
|
261
|
+
req.on('end', () => resolve(b));
|
|
262
|
+
req.on('error', reject);
|
|
263
|
+
});
|
|
264
|
+
}
|