@vimoxshah/tokenflow 1.1.2 → 1.2.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 (86) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/Dockerfile.team +20 -0
  3. package/README.md +30 -11
  4. package/bin/tokenflow.js +147 -12
  5. package/design/tokens.yaml +330 -0
  6. package/docs/architecture.md +5 -4
  7. package/docs/cli.md +204 -0
  8. package/docs/configuration.md +117 -2
  9. package/docs/design-system.md +187 -0
  10. package/docs/exports-and-budgets.md +85 -0
  11. package/docs/guard-codex.md +132 -0
  12. package/docs/ledger.md +144 -0
  13. package/docs/live-mode.md +40 -0
  14. package/docs/media/overview-aurora-dark.png +0 -0
  15. package/docs/media/receipts-aurora-dark.png +0 -0
  16. package/docs/providers-otel.md +179 -0
  17. package/docs/providers.md +54 -1
  18. package/docs/receipt-schema.md +74 -0
  19. package/docs/roadmap.md +182 -0
  20. package/docs/team-server.md +170 -0
  21. package/docs/ui-views.md +322 -0
  22. package/package.json +7 -2
  23. package/schemas/receipt.v0.json +160 -0
  24. package/scripts/build-menubar-app.sh +3 -1
  25. package/scripts/design-build.js +475 -0
  26. package/src/analytics/anatomy.js +467 -0
  27. package/src/analytics/branch-compare.js +159 -0
  28. package/src/analytics/cache-health.js +141 -0
  29. package/src/analytics/live-view.js +266 -0
  30. package/src/analytics/receipt-schema.js +214 -0
  31. package/src/analytics/receipt.js +709 -0
  32. package/src/analytics/rhythm.js +184 -0
  33. package/src/analytics/whatif.js +263 -0
  34. package/src/commands/budget-scopes.js +133 -0
  35. package/src/commands/doctor-checks.js +400 -0
  36. package/src/commands/guard.js +531 -0
  37. package/src/commands/hooks.js +238 -0
  38. package/src/commands/pricing-diff.js +316 -0
  39. package/src/commands/receipt.js +226 -0
  40. package/src/commands/team-serve.js +407 -0
  41. package/src/commands/week.js +86 -0
  42. package/src/core/annotations.js +97 -0
  43. package/src/core/budget.js +33 -0
  44. package/src/core/bundle.js +45 -2
  45. package/src/core/ingest.js +33 -0
  46. package/src/core/live-status.js +227 -2
  47. package/src/core/policy.js +103 -0
  48. package/src/core/receipt-note.js +123 -0
  49. package/src/core/repo.js +64 -0
  50. package/src/core/sync.js +163 -26
  51. package/src/core/team.js +0 -0
  52. package/src/export/html-snapshot.js +28 -1
  53. package/src/export/menubar.js +21 -0
  54. package/src/export/receipt-card.js +210 -0
  55. package/src/export/week-card.js +185 -0
  56. package/src/providers/mock/index.js +383 -52
  57. package/src/providers/openai/index.js +31 -1
  58. package/src/providers/otel/index.js +656 -0
  59. package/src/server/routes/annotations.js +42 -0
  60. package/src/server/routes/cache-health.js +95 -0
  61. package/src/server/routes/index.js +54 -0
  62. package/src/server/routes/session.js +157 -0
  63. package/src/server/server.js +47 -1
  64. package/src/ui/app.js +541 -308
  65. package/src/ui/charts.js +95 -0
  66. package/src/ui/first-run.js +144 -0
  67. package/src/ui/index.html +4 -1
  68. package/src/ui/palette.js +335 -0
  69. package/src/ui/styles/anatomy.css +117 -0
  70. package/src/ui/styles/annotations.css +40 -0
  71. package/src/ui/styles/branches.css +99 -0
  72. package/src/ui/styles/cache.css +6 -0
  73. package/src/ui/styles/first-run.css +31 -0
  74. package/src/ui/styles/live.css +100 -0
  75. package/src/ui/styles/palette.css +85 -0
  76. package/src/ui/styles/rhythm.css +8 -0
  77. package/src/ui/styles/whatif.css +55 -0
  78. package/src/ui/styles.css +303 -196
  79. package/src/ui/views/anatomy.js +567 -0
  80. package/src/ui/views/annotations.js +121 -0
  81. package/src/ui/views/branches.js +304 -0
  82. package/src/ui/views/cache.js +232 -0
  83. package/src/ui/views/index.js +85 -0
  84. package/src/ui/views/live.js +683 -0
  85. package/src/ui/views/rhythm.js +206 -0
  86. package/src/ui/views/whatif.js +196 -0
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Day annotations API.
3
+ *
4
+ * GET returns the whole file; POST adds or removes one entry. Validation and
5
+ * storage live in src/core/annotations.js — this file only shapes the HTTP
6
+ * request/response and turns a validation Error into a 400.
7
+ */
8
+ import { readAnnotations, addAnnotation, removeAnnotation } from '../../core/annotations.js';
9
+
10
+ /** @type {import('./index.js').Route[]} */
11
+ export const ANNOTATIONS_ROUTES = [
12
+ {
13
+ method: 'GET',
14
+ path: '/api/annotations',
15
+ handler: (req, res, url, ctx) => ctx.json(readAnnotations()),
16
+ },
17
+ {
18
+ method: 'POST',
19
+ path: '/api/annotations',
20
+ handler: async (req, res, url, ctx) => {
21
+ let body;
22
+ try {
23
+ body = JSON.parse((await ctx.readBody()) || '{}');
24
+ } catch {
25
+ return ctx.json({ error: 'invalid JSON body' }, 400);
26
+ }
27
+ try {
28
+ if (body.op === 'add') {
29
+ const item = addAnnotation({ date: body.date, text: body.text });
30
+ return ctx.json({ ok: true, item, ...readAnnotations() });
31
+ }
32
+ if (body.op === 'remove') {
33
+ const removed = removeAnnotation(body.id);
34
+ return ctx.json({ ok: true, removed, ...readAnnotations() });
35
+ }
36
+ return ctx.json({ error: `unknown op "${body.op}"` }, 400);
37
+ } catch (err) {
38
+ return ctx.json({ error: err.message }, 400);
39
+ }
40
+ },
41
+ },
42
+ ];
@@ -0,0 +1,95 @@
1
+ /**
2
+ * GET /api/cache-health?from=YYYY-MM-DD&to=YYYY-MM-DD
3
+ *
4
+ * Churn detection: scans primary records in the given window, groups them by
5
+ * session in timestamp order, and reports every turn that invalidated and
6
+ * rewrote the prompt cache at a premium (src/analytics/cache-health.js has
7
+ * the exact rule). This needs request-level records, which never leave the
8
+ * machine and never ship in a snapshot, so it is a live-dashboard-only route.
9
+ *
10
+ * A record with no session_id cannot be placed in a turn sequence, so it is
11
+ * left out of every group rather than guessed at.
12
+ */
13
+ import { Store, decodeRecord, readJson } from '../../core/store.js';
14
+ import { buildPriceBook } from '../../core/pricing.js';
15
+ import { MEASUREMENT } from '../../core/schema.js';
16
+ import { detectChurn, summarize } from '../../analytics/cache-health.js';
17
+
18
+ /** Hard ceiling on how many records one request scans. */
19
+ const MAX_SCAN_RECORDS = 200000;
20
+
21
+ /**
22
+ * `YYYY-MM` for every month between `from` and `to`, inclusive. Returns
23
+ * `undefined` (no restriction) when either bound is missing, matching how
24
+ * `Store#scanRecords`'s `months` option treats a falsy value.
25
+ * @param {string|null} from
26
+ * @param {string|null} to
27
+ */
28
+ function monthsBetween(from, to) {
29
+ if (!from || !to) return undefined;
30
+ const out = [];
31
+ let y = Number(from.slice(0, 4));
32
+ let m = Number(from.slice(5, 7));
33
+ const ey = Number(to.slice(0, 4));
34
+ const em = Number(to.slice(5, 7));
35
+ let guard = 0;
36
+ while ((y < ey || (y === ey && m <= em)) && guard++ < 600) {
37
+ out.push(`${y}-${String(m).padStart(2, '0')}`);
38
+ m++;
39
+ if (m > 12) { m = 1; y++; }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function handleCacheHealth(req, res, url, ctx) {
45
+ const from = url.searchParams.get('from') || null;
46
+ const to = url.searchParams.get('to') || null;
47
+
48
+ const store = new Store();
49
+ const book = buildPriceBook(readJson(ctx.paths.pricing, {}));
50
+ const months = monthsBetween(from, to);
51
+
52
+ const bySession = new Map();
53
+ let scanned = 0;
54
+ let truncated = false;
55
+ store.scanRecords((o) => {
56
+ scanned++;
57
+ if (scanned > MAX_SCAN_RECORDS) { truncated = true; return false; }
58
+ if (from && o.d < from) return;
59
+ if (to && o.d > to) return;
60
+ if (o.ms !== MEASUREMENT.PRIMARY) return;
61
+ if (!o.s) return; // no session id: cannot be placed in a turn sequence
62
+ const r = decodeRecord(o);
63
+ let group = bySession.get(r.session_id);
64
+ if (!group) { group = []; bySession.set(r.session_id, group); }
65
+ group.push(r);
66
+ }, { months });
67
+
68
+ const events = [];
69
+ for (const group of bySession.values()) events.push(...detectChurn(group, book));
70
+ // Highest estimated premium first; unpriced events (premiumUsd: null) sink
71
+ // to the bottom rather than sorting arbitrarily.
72
+ events.sort((a, b) => {
73
+ if (a.premiumUsd === null && b.premiumUsd === null) return 0;
74
+ if (a.premiumUsd === null) return 1;
75
+ if (b.premiumUsd === null) return -1;
76
+ return b.premiumUsd - a.premiumUsd;
77
+ });
78
+
79
+ ctx.json({
80
+ from,
81
+ to,
82
+ scanned,
83
+ truncated,
84
+ events,
85
+ summary: summarize(events),
86
+ });
87
+ }
88
+
89
+ export const CACHE_HEALTH_ROUTES = [
90
+ {
91
+ method: 'GET',
92
+ path: '/api/cache-health',
93
+ handler: handleCacheHealth,
94
+ },
95
+ ];
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The dashboard server's route registry.
3
+ *
4
+ * A registered route is an API endpoint that lives outside server.js. The
5
+ * server dispatches to a matching entry before its own inline chain, so a new
6
+ * tab can ship its own endpoint without anyone editing server.js — the same
7
+ * reason src/ui/views/ exists for the UI side.
8
+ *
9
+ * A handler is called as `handler(req, res, url, ctx)` and owns the response
10
+ * from that point on: nothing after it runs. Registered GET routes get exactly
11
+ * the protection inline GET routes get today, which is loopback binding and
12
+ * nothing else; a route that changes state must be POST, so it is covered by
13
+ * the same-origin/token check server.js applies to every non-GET request.
14
+ *
15
+ * The full contract is in docs/ui-views.md under "Server routes".
16
+ */
17
+
18
+ /**
19
+ * The helpers a route handler is given. `config` and `paths` are lazy: they are
20
+ * only read from disk if the handler actually asks for them.
21
+ *
22
+ * @typedef {object} RouteContext
23
+ * @property {(body:any, code?:number)=>void} json respond with JSON.
24
+ * @property {(code:number, type:string, body:any)=>void} send respond with anything else.
25
+ * @property {(file:string)=>void} sendFile respond with a file from disk.
26
+ * @property {()=>Promise<string>} readBody read the request body as text.
27
+ * @property {object} config the loaded config (lazy, cached per request).
28
+ * @property {object} paths the resolved store paths (lazy, cached per request).
29
+ * @property {(opt?:{config?:object, receipts?:boolean})=>any} buildBundle build the aggregate bundle the UI loads. `receipts: false` skips the whole-store receipt scan, which costs seconds a route that only needs sessions should not pay.
30
+ * @property {(query:object)=>object} queryRecords the record query behind /api/records.
31
+ * @property {string} root the repository root.
32
+ * @property {string} host the bound host.
33
+ * @property {number} port the bound port (correct even when the server bound port 0).
34
+ */
35
+
36
+ /**
37
+ * One endpoint. `path` is matched exactly; there is no pattern syntax, because
38
+ * a query string is a better fit for everything this dashboard needs.
39
+ *
40
+ * @typedef {object} Route
41
+ * @property {string} method 'GET', 'POST', … matched case-insensitively.
42
+ * @property {string} path e.g. '/api/receipts'.
43
+ * @property {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, url: URL, ctx: RouteContext) => any} handler
44
+ */
45
+
46
+ import { ANNOTATIONS_ROUTES } from './annotations.js';
47
+ import { CACHE_HEALTH_ROUTES } from './cache-health.js';
48
+ import { SESSION_ROUTE } from './session.js';
49
+
50
+ /**
51
+ * Every registered route. Order does not matter: paths are matched exactly.
52
+ * @type {Route[]}
53
+ */
54
+ export const ROUTES = [...ANNOTATIONS_ROUTES, ...CACHE_HEALTH_ROUTES, SESSION_ROUTE];
@@ -0,0 +1,157 @@
1
+ /**
2
+ * GET /api/session?id=<sessionId>
3
+ *
4
+ * One session's primary records, in timestamp order, for the Session anatomy
5
+ * tab. The cube has no notion of a turn, so this is the only way to see where
6
+ * a single session's money went — and request-level records never ship in the
7
+ * bundle or the snapshot, which is why this is a live-dashboard-only route.
8
+ *
9
+ * Two things this route deliberately does not do:
10
+ *
11
+ * - It never returns prompt or code content. The stored records hold none,
12
+ * and the projection below is an allow-list rather than a delete-list so
13
+ * that a future metadata field cannot leak in by being added upstream.
14
+ * - It never scans the whole store. The session's own date range comes from
15
+ * the bundle's session rows, and only the months that range touches are
16
+ * read (widened by a day at each end, because a session row's date is a
17
+ * local calendar date and a record's timestamp is UTC).
18
+ *
19
+ * One field is served beyond the set this route was specified with:
20
+ * `service_tier`. The browser prices each turn itself from the price book, and
21
+ * the tier is a multiplier on that price (OpenAI priority and fast are 4x,
22
+ * batch is 0.5x). Without it the anatomy tab would quietly disagree with the
23
+ * Cost tab on exactly the sessions that cost the most. It is a billing
24
+ * dimension the cube already carries, not content.
25
+ */
26
+ import { Store } from '../../core/store.js';
27
+ import { MEASUREMENT } from '../../core/schema.js';
28
+ import { addDays } from '../../analytics/aggregate.js';
29
+
30
+ /** Most turns one response will carry. Reported in the body, never silent. */
31
+ export const RECORD_CAP = 5000;
32
+
33
+ /**
34
+ * The key `Store#upsertSession` filed a record under. A source with no session
35
+ * id gets a synthetic one, so matching on `o.s` alone would never find those
36
+ * sessions.
37
+ * @param {object} o an encoded record
38
+ */
39
+ function sessionKeyOf(o) {
40
+ return o.s || `${o.so}:${o.d}:${o.pj || 'unknown'}`;
41
+ }
42
+
43
+ /** `YYYY-MM` for every month between two dates, inclusive. */
44
+ function monthsBetween(from, to) {
45
+ const out = [];
46
+ let y = Number(from.slice(0, 4));
47
+ let m = Number(from.slice(5, 7));
48
+ const ey = Number(to.slice(0, 4));
49
+ const em = Number(to.slice(5, 7));
50
+ let guard = 0;
51
+ while ((y < ey || (y === ey && m <= em)) && guard++ < 600) {
52
+ out.push(`${y}-${String(m).padStart(2, '0')}`);
53
+ m++;
54
+ if (m > 12) { m = 1; y++; }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** The calendar date part of an ISO timestamp, or null. */
60
+ function dateOf(iso) {
61
+ return typeof iso === 'string' && iso.length >= 10 ? iso.slice(0, 10) : null;
62
+ }
63
+
64
+ /**
65
+ * The fields the anatomy view is allowed to see. Everything else — metadata,
66
+ * cwd, titles, ids that identify a machine or a person — stays on disk.
67
+ * @param {object} o an encoded record
68
+ */
69
+ function slim(o) {
70
+ const meta = o.x || {};
71
+ return {
72
+ ts: o.ts ?? null,
73
+ model: o.m ?? null,
74
+ provider: o.p ?? null,
75
+ source: o.so ?? null,
76
+ input_tokens: o.in ?? null,
77
+ cache_read_tokens: o.cr ?? null,
78
+ cache_write_tokens: o.cw ?? null,
79
+ cache_refresh_tokens: o.cf ?? null,
80
+ output_tokens: o.ou ?? null,
81
+ reasoning_tokens: o.rs ?? null,
82
+ // The billing tier changes what an identical turn costs (OpenAI priority
83
+ // is 4x), so pricing in the browser needs it to agree with the Cost tab.
84
+ service_tier: o.tr ?? null,
85
+ category: o.k ?? null,
86
+ request_id: o.rq ?? null,
87
+ // No adapter records a parent link today: Claude Code marks a sidechain and
88
+ // OpenCode/Hermes fold their parent id into `category`. The pair is carried
89
+ // anyway so the fan-out becomes a real tree the day one does.
90
+ agent: meta.agent ?? meta.agent_role ?? null,
91
+ parent: meta.parent_id ?? meta.parent_session_id ?? null,
92
+ };
93
+ }
94
+
95
+ function handleSession(req, res, url, ctx) {
96
+ const id = url.searchParams.get('id');
97
+ if (!id) return ctx.json({ error: 'id is required' }, 400);
98
+
99
+ // Session rows only; the receipt scan behind the full bundle costs seconds
100
+ // and this route never looks at it.
101
+ const sessions = ctx.buildBundle({ receipts: false }).sessions || [];
102
+ const row = sessions.find((s) => s.id === id);
103
+ if (!row) return ctx.json({ error: `no session "${id}"` }, 404);
104
+
105
+ const first = row.d || dateOf(row.start);
106
+ const last = dateOf(row.end) || first;
107
+ if (!first || !last) return ctx.json({ error: `session "${id}" has no date range` }, 404);
108
+ const from = addDays(first < last ? first : last, -1);
109
+ const to = addDays(first > last ? first : last, 1);
110
+ const months = monthsBetween(from, to);
111
+
112
+ const store = new Store();
113
+ const matched = [];
114
+ let scanned = 0;
115
+ store.scanRecords((o) => {
116
+ scanned++;
117
+ if (o.ms !== MEASUREMENT.PRIMARY) return;
118
+ if (sessionKeyOf(o) !== id) return;
119
+ matched.push(o);
120
+ }, { months });
121
+
122
+ matched.sort((a, b) => (a.ts === b.ts ? 0 : (a.ts ?? '') < (b.ts ?? '') ? -1 : 1));
123
+ const records = matched.slice(0, RECORD_CAP).map(slim);
124
+
125
+ ctx.json({
126
+ id,
127
+ session: {
128
+ id: row.id,
129
+ source: row.so ?? null,
130
+ project: row.pj ?? null,
131
+ repository: row.rp ?? null,
132
+ branch: row.br ?? null,
133
+ model: row.m ?? null,
134
+ provider: row.p ?? null,
135
+ client: row.c ?? null,
136
+ interface: row.i ?? null,
137
+ start: row.start ?? null,
138
+ end: row.end ?? null,
139
+ date: row.d ?? null,
140
+ requests: row.req ?? null,
141
+ },
142
+ months,
143
+ scanned,
144
+ total: matched.length,
145
+ returned: records.length,
146
+ cap: RECORD_CAP,
147
+ truncated: matched.length > RECORD_CAP,
148
+ records,
149
+ });
150
+ }
151
+
152
+ /** @type {import('./index.js').Route} */
153
+ export const SESSION_ROUTE = {
154
+ method: 'GET',
155
+ path: '/api/session',
156
+ handler: handleSession,
157
+ };
@@ -22,6 +22,7 @@ import { PROVIDER_REGIONS, resolveMyLocation } from '../core/geo.js';
22
22
  import { normalizeLimits } from '../analytics/capacity.js';
23
23
  import { streamRecordsCsv, exportFilename } from '../export/csv.js';
24
24
  import { writeJson, readJson } from '../core/store.js';
25
+ import { ROUTES } from './routes/index.js';
25
26
 
26
27
  const MIME = {
27
28
  '.html': 'text/html; charset=utf-8',
@@ -41,6 +42,44 @@ export async function startServer({ port = 7799, host = '127.0.0.1', open = fals
41
42
  const authToken = token === false ? null : token || randomBytes(12).toString('base64url');
42
43
 
43
44
  let refreshing = false;
45
+ // The real port is only known after listen() when the caller asked for 0.
46
+ let boundPort = port;
47
+
48
+ // Paths the inline chain below owns. A registered route may not take one:
49
+ // registered routes are dispatched first, so the shadowing would be silent
50
+ // and would look nothing like "my new route is wrong".
51
+ const RESERVED = new Set([
52
+ '/', '/index.html', '/api/bundle', '/api/health', '/api/ping', '/api/providers',
53
+ '/api/records', '/api/live', '/api/geo', '/api/config', '/api/export.csv',
54
+ '/api/refresh', '/api/pricing', '/api/prefs',
55
+ ]);
56
+ for (const r of ROUTES) {
57
+ if (r && (RESERVED.has(r.path) || String(r.path || '').startsWith('/src/'))) {
58
+ console.warn(`routes: ${r.method} ${r.path} is a built-in path and will shadow it`);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The helpers a registered route handler gets. Built per request; config and
64
+ * paths are read lazily so a handler that never asks never pays.
65
+ */
66
+ function routeContext(req, res) {
67
+ let cfg = null;
68
+ let pth = null;
69
+ return {
70
+ json: (body, code) => json(res, body, code),
71
+ send: (code, type, body) => send(res, code, type, body),
72
+ sendFile: (file) => sendFile(res, file),
73
+ readBody: () => readBody(req),
74
+ get config() { return (cfg ||= loadConfig()); },
75
+ get paths() { return (pth ||= paths()); },
76
+ buildBundle,
77
+ queryRecords,
78
+ root: ROOT,
79
+ host,
80
+ get port() { return boundPort; },
81
+ };
82
+ }
44
83
 
45
84
  const server = http.createServer(async (req, res) => {
46
85
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
@@ -54,6 +93,13 @@ export async function startServer({ port = 7799, host = '127.0.0.1', open = fals
54
93
  if (!sameSite && given !== authToken) return send(res, 403, 'text/plain', 'forbidden');
55
94
  }
56
95
 
96
+ // Registered routes, after the token check and before the inline chain,
97
+ // so a tab added under src/ui/views/ can ship its endpoint without this
98
+ // file growing a branch for it. See src/server/routes/index.js.
99
+ const route = ROUTES.find((r) => r && r.path === p
100
+ && String(r.method || 'GET').toUpperCase() === req.method);
101
+ if (route) return await route.handler(req, res, url, routeContext(req, res));
102
+
57
103
  if (p === '/' || p === '/index.html') {
58
104
  return sendFile(res, path.join(UI, 'index.html'));
59
105
  }
@@ -227,7 +273,7 @@ export async function startServer({ port = 7799, host = '127.0.0.1', open = fals
227
273
  });
228
274
  // address() is `string` for a pipe/socket and AddressInfo for TCP.
229
275
  const bound = server.address();
230
- const boundPort = typeof bound === 'object' && bound !== null ? bound.port : port;
276
+ boundPort = typeof bound === 'object' && bound !== null ? bound.port : port;
231
277
  const addr = `http://${host}:${boundPort}`;
232
278
  return { server, url: addr, token: authToken, close: () => new Promise((r) => server.close(r)) };
233
279
  }