@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,299 @@
1
+ /**
2
+ * Hermes — local agent state database.
3
+ *
4
+ * Source: <hermes-home>/state.db (SQLite; ~/.hermes by default, $HERMES_HOME
5
+ * honoured). Read-only, via node:sqlite, read from a snapshot like every
6
+ * SQLite adapter here.
7
+ *
8
+ * ## What this source is
9
+ *
10
+ * Hermes records its own LLM traffic in two tables:
11
+ *
12
+ * sessions one row per conversation: source (cli/cron/whatsapp/
13
+ * telegram), cwd, git branch/repo, parent link, timing
14
+ * session_model_usage one row per (session × model × billing_provider ×
15
+ * task): token totals, API call count, and hermes' own
16
+ * estimated_cost_usd / actual_cost_usd
17
+ *
18
+ * There is no per-request log to read — `messages.token_count` is unpopulated
19
+ * — so the finest honest granularity is one record per session×model row,
20
+ * timestamped at the first API call of that group. Sessions and requests are
21
+ * still counted correctly; within-group time shape is not knowable.
22
+ *
23
+ * ## Token semantics (verified against a live corpus)
24
+ *
25
+ * input_tokens fresh prompt tokens, EXCLUSIVE of cache reads. On a
26
+ * real corpus cache_read exceeds input many times over
27
+ * on long sessions, which is impossible under OpenAI's
28
+ * inclusive convention, so this is the Anthropic-style
29
+ * exclusive one. No subtraction needed.
30
+ * cache_read_tokens -> cache_read_tokens
31
+ * cache_write_tokens -> cache_write_tokens (0 when the backend reports none)
32
+ * reasoning_tokens -> reasoning_tokens, folded into output when a backend
33
+ * reports it additively rather than as a subset
34
+ * (same rule as the opencode adapter)
35
+ *
36
+ * ## Gateway vs vendor
37
+ *
38
+ * billing_provider ("nous", "openrouter", "openai-codex", "anthropic", ...) is
39
+ * the routing layer the call went through. When it is NOT the model's vendor
40
+ * it is recorded as `gateway`; a claude model billed by "anthropic" is a
41
+ * direct call and gets no gateway. The vendor comes from classifying the
42
+ * model string with the same rules the engine uses, and an unmapped model
43
+ * stays provider "unknown" with its gateway preserved.
44
+ *
45
+ * ## Measured cost
46
+ *
47
+ * When hermes recorded actual_cost_usd > 0 that is a MEASURED price from the
48
+ * billing provider, so it is passed as measured cost (cost_basis "measured")
49
+ * and the engine's estimate stands down. estimated_cost_usd is ignored: it is
50
+ * someone else's estimate, and mixing two estimate sources would make the
51
+ * Cost page unauditable.
52
+ *
53
+ * ## The incremental trap: rows are UPSERTED in place
54
+ *
55
+ * A session's usage rows grow every time the session makes another call —
56
+ * same primary key, bigger totals, later last_seen. A naive
57
+ * "last_seen > cursor" cursor would freeze the totals as of the first read.
58
+ * So this adapter uses the same tails mechanism as the Anthropic adapter:
59
+ * re-read a recent window ordered by last_seen, remember per row what has
60
+ * already been emitted, and emit only the DELTA when a row grew. The base
61
+ * record carries the group's first_seen; a delta carries the last_seen at
62
+ * which the new usage was observed.
63
+ */
64
+ import fs from 'node:fs';
65
+ import path from 'node:path';
66
+ import os from 'node:os';
67
+ import { createProvider } from '../../core/registry.js';
68
+ import { MEASUREMENT, hashId } from '../../core/schema.js';
69
+ import { classifyModel } from '../../core/model-map.js';
70
+ import { openReadOnly, sqliteAvailable } from '../../core/sqlite.js';
71
+
72
+ const ROW_CAP = 500000;
73
+ /** How far behind the watermark to re-read, to catch upserted rows. */
74
+ const OVERLAP_MS = 7 * 24 * 60 * 60 * 1000;
75
+ /** Emitted-tail entries older than this, relative to the watermark, are pruned. */
76
+ const TAIL_TTL_MS = 30 * 24 * 60 * 60 * 1000;
77
+
78
+ const FIELDS = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens'];
79
+
80
+ export function dbPath(ctx) {
81
+ const c = ctx?.config?.sources?.hermes;
82
+ const p = c?.db || (Array.isArray(c?.paths) ? c.paths[0] : null);
83
+ if (p) return expand(p);
84
+ const home = process.env.HERMES_HOME || path.join(ctx?.home || os.homedir(), '.hermes');
85
+ return path.join(home, 'state.db');
86
+ }
87
+
88
+ function expand(p) {
89
+ return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
90
+ }
91
+
92
+ export default createProvider({
93
+ id: 'hermes',
94
+ name: 'Hermes',
95
+ description: 'Per-session-per-model token usage from the Hermes agent state database.',
96
+ measurement: MEASUREMENT.PRIMARY,
97
+ requires: ['~/.hermes/state.db', 'Node 22.5+ (node:sqlite)'],
98
+
99
+ async detect(ctx) {
100
+ if (!sqliteAvailable()) return { available: false, detail: 'node:sqlite unavailable — needs Node 22.5+' };
101
+ const f = dbPath(ctx);
102
+ if (!fs.existsSync(f)) return { available: false, detail: 'No Hermes state.db found (looked for ~/.hermes)' };
103
+ return { available: true, detail: shortPath(f, ctx), paths: [f] };
104
+ },
105
+
106
+ async fetchUsage(ctx, emit, sourceState) {
107
+ const f = dbPath(ctx);
108
+ const db = openReadOnly(f);
109
+ let records = 0;
110
+ let skipped = 0;
111
+ const notes = [];
112
+
113
+ try {
114
+ const cursor = sourceState.cursor || { seen: 0 };
115
+ // Tails: per-row totals already emitted, keyed by the table's own
116
+ // composite key. They make re-reading the overlap window idempotent.
117
+ const tails = sourceState.tails || (sourceState.tails = {});
118
+
119
+ const rows = db.prepare(
120
+ `SELECT u.session_id AS session_id, u.model AS model, u.billing_provider AS billing_provider,
121
+ u.task AS task, u.api_call_count AS api_call_count,
122
+ u.input_tokens AS input_tokens, u.output_tokens AS output_tokens,
123
+ u.cache_read_tokens AS cache_read_tokens, u.cache_write_tokens AS cache_write_tokens,
124
+ u.reasoning_tokens AS reasoning_tokens,
125
+ u.actual_cost_usd AS actual_cost_usd, u.cost_status AS cost_status,
126
+ u.first_seen AS first_seen, u.last_seen AS last_seen,
127
+ s.source AS source, s.cwd AS cwd, s.git_branch AS git_branch,
128
+ s.git_repo_root AS git_repo_root, s.parent_session_id AS parent_session_id,
129
+ s.started_at AS started_at, s.ended_at AS ended_at, s.title AS title
130
+ FROM session_model_usage u
131
+ LEFT JOIN sessions s ON s.id = u.session_id
132
+ WHERE u.last_seen > ?
133
+ ORDER BY u.last_seen ASC
134
+ LIMIT ?`,
135
+ ).all(Math.max(0, ((cursor.seen || 0) - OVERLAP_MS) / 1000), ROW_CAP);
136
+ if (rows.length === ROW_CAP) notes.push('row cap hit — run refresh again to continue');
137
+
138
+ let watermark = cursor.seen || 0;
139
+
140
+ for (const r of rows) {
141
+ const lastSeen = secToMs(r.last_seen);
142
+ if (lastSeen !== null) watermark = Math.max(watermark, lastSeen);
143
+
144
+ const base = {
145
+ input_tokens: intOrNull(r.input_tokens),
146
+ output_tokens: intOrNull(r.output_tokens),
147
+ cache_read_tokens: intOrNull(r.cache_read_tokens),
148
+ cache_write_tokens: intOrNull(r.cache_write_tokens),
149
+ reasoning_tokens: intOrNull(r.reasoning_tokens),
150
+ };
151
+ // Additive-reasoning backends: fold so the subset invariant holds.
152
+ if (base.reasoning_tokens !== null && base.output_tokens !== null && base.reasoning_tokens > base.output_tokens) {
153
+ base.output_tokens += base.reasoning_tokens;
154
+ }
155
+
156
+ const measuredCost = r.actual_cost_usd !== null && Number(r.actual_cost_usd) > 0 ? Number(r.actual_cost_usd) : null;
157
+
158
+ // A group with no counts and no measured cost contributes nothing a
159
+ // dashboard can show; skip it rather than emit an empty request.
160
+ if (FIELDS.every((k) => !base[k]) && measuredCost === null) { skipped++; continue; }
161
+
162
+ const key = [r.session_id, r.model ?? '', r.billing_provider ?? '', r.task ?? ''].join('|');
163
+ const prev = tails[key];
164
+ const delta = {};
165
+ let any = false;
166
+ for (const k of FIELDS) {
167
+ const was = prev ? (prev.f[k] ?? 0) : 0;
168
+ const now = base[k] ?? 0;
169
+ delta[k] = Math.max(0, now - was);
170
+ if (delta[k] > 0) any = true;
171
+ }
172
+ // Nothing token-wise since the last emission: an unchanged re-read
173
+ // must stay a no-op forever.
174
+ if (prev && !any) continue;
175
+
176
+ // Measured-cost delta against what has already been EMITTED for this
177
+ // row (not against its current total), so cost never double counts.
178
+ const costDelta = prev && measuredCost !== null ? Math.max(0, round6(measuredCost - (prev.c || 0))) : measuredCost;
179
+
180
+ // Remember what this row accounts for. The emission index keeps each
181
+ // delta's record id distinct and stable across runs.
182
+ const emissionIndex = prev ? (prev.n || 0) : 0;
183
+ tails[key] = { ls: lastSeen ?? (prev?.ls || 0), f: { ...base }, c: measuredCost, n: emissionIndex + 1 };
184
+ records++;
185
+
186
+ const model = str(r.model);
187
+ const firstSeen = secToMs(r.first_seen);
188
+ if (firstSeen === null && lastSeen === null) { skipped++; records--; delete tails[key]; continue; }
189
+
190
+ // billing_provider is the routing layer — unless it IS the vendor
191
+ // ("anthropic" billing for a claude model is a direct call, not a
192
+ // gateway). Classify with the same rules the engine will use so the
193
+ // two never disagree.
194
+ const billing = str(r.billing_provider)?.toLowerCase() || null;
195
+ // 'default' means Hermes did not record which model served the call —
196
+ // but billing_provider still knows who billed it. Attribute to the
197
+ // billing layer (e.g. nous) instead of dropping it into "unknown";
198
+ // a real vendor slug would be better, but the gateway is honest data.
199
+ const modelForClassify =
200
+ model && model !== 'default' ? model
201
+ : (billing ? `@${billing}` : null);
202
+ const cls = classifyModel(modelForClassify, { rules: ctx.rules });
203
+ const vendor = cls.provider;
204
+ const gateway = billing && billing !== vendor ? billing : null;
205
+ // Pass the classified vendor explicitly: the engine re-classifies from
206
+ // the model string, but only when `provider` is absent. Sending it
207
+ // through guarantees the adapter's rules and the engine's agree even
208
+ // if rule sets drift between releases.
209
+
210
+ const project = projectOf(r.git_repo_root || r.cwd);
211
+ const startedAt = secToMs(r.started_at);
212
+ const endedAt = secToMs(r.ended_at);
213
+
214
+ emit({
215
+ id: hashId('hermes', key, '#', emissionIndex),
216
+ provider: vendor,
217
+ provider_label: cls.provider_label,
218
+ // Base emissions sit at the group's first API call; a delta lands at
219
+ // the last_seen that revealed it, which is when that usage happened.
220
+ timestamp: new Date((prev ? lastSeen : firstSeen) ?? lastSeen ?? firstSeen).toISOString(),
221
+ model: model && model !== 'default' ? model : null,
222
+ gateway,
223
+ ...pick(delta),
224
+ measured_cost: costDelta,
225
+ session_id: str(r.session_id),
226
+ conversation_id: str(r.session_id),
227
+ request_id: `${key}#${emissionIndex}`,
228
+ project,
229
+ repository: project,
230
+ git_branch: str(r.git_branch),
231
+ category: r.parent_session_id ? 'subagent' : (str(r.task) || 'main'),
232
+ client: 'hermes',
233
+ application: 'Hermes',
234
+ interfaceSignals: [str(r.source)],
235
+ duration_ms: startedAt !== null && endedAt !== null && endedAt >= startedAt ? Math.round(endedAt - startedAt) : null,
236
+ metadata: {
237
+ title: str(r.title),
238
+ source: str(r.source),
239
+ hermes_task: str(r.task),
240
+ api_calls: intOrNull(r.api_call_count),
241
+ billing_provider: str(r.billing_provider),
242
+ cost_status: str(r.cost_status),
243
+ session_open: r.ended_at === null || r.ended_at === undefined ? true : undefined,
244
+ ...(prev ? { continuation_of: key } : {}),
245
+ },
246
+ });
247
+ }
248
+
249
+ // Bound the tail state: anything this far behind the watermark cannot
250
+ // reappear inside the overlap window, so its emitted totals are final.
251
+ const floor = watermark - TAIL_TTL_MS;
252
+ for (const k of Object.keys(tails)) if ((tails[k].ls || 0) < floor) delete tails[k];
253
+
254
+ cursor.seen = watermark;
255
+ sourceState.cursor = cursor;
256
+ } finally {
257
+ db.close();
258
+ }
259
+ return { records, cursor: sourceState.cursor, notes };
260
+ },
261
+ });
262
+
263
+ function pick(delta) {
264
+ const out = {};
265
+ for (const k of FIELDS) out[k] = delta[k];
266
+ return out;
267
+ }
268
+
269
+ /** REAL seconds in, epoch milliseconds out. */
270
+ function secToMs(v) {
271
+ if (v === null || v === undefined) return null;
272
+ const ms = Number(v) * 1000;
273
+ return Number.isFinite(ms) ? ms : null;
274
+ }
275
+
276
+ function projectOf(dir) {
277
+ if (!dir) return null;
278
+ const parts = String(dir).split('/').filter(Boolean);
279
+ return parts.length ? parts[parts.length - 1] : null;
280
+ }
281
+
282
+ function shortPath(p, ctx) {
283
+ const home = ctx?.home || os.homedir();
284
+ return p.startsWith(home) ? '~' + p.slice(home.length) : p;
285
+ }
286
+
287
+ function intOrNull(v) {
288
+ if (v === null || v === undefined) return null;
289
+ const x = Number(v);
290
+ return Number.isFinite(x) ? x : null;
291
+ }
292
+
293
+ function str(v) {
294
+ return v === undefined || v === null || v === '' ? null : String(v);
295
+ }
296
+
297
+ function round6(x) {
298
+ return Math.round(x * 1e6) / 1e6;
299
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Mock provider — deterministic demo data so a new contributor can run
3
+ * `npm run demo` and see a realistic dashboard without connecting anything.
4
+ *
5
+ * Every record it produces carries `metadata.demo = true` and
6
+ * `machine: "demo-machine"`, and the dashboard shows a persistent DEMO DATA
7
+ * banner whenever any demo record is in scope. It only activates when asked
8
+ * for explicitly (TOKENFLOW_DEMO=1 or `providers: [mock]`), so it can never
9
+ * contaminate a real dataset by accident.
10
+ */
11
+ import { createProvider } from '../../core/registry.js';
12
+ import { MEASUREMENT, INTERFACE } from '../../core/schema.js';
13
+
14
+ /** mulberry32 — small, fast, seeded, identical across platforms. */
15
+ function rng(seed) {
16
+ let a = seed >>> 0;
17
+ return () => {
18
+ a = (a + 0x6d2b79f5) >>> 0;
19
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
20
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
21
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
22
+ };
23
+ }
24
+
25
+ const MODELS = [
26
+ { model: 'claude-opus-4-1-20250805', client: 'claude-code', iface: INTERFACE.CLI, weight: 34, out: 0.06, cacheHeavy: true },
27
+ { model: 'claude-sonnet-4-20250514', client: 'claude-code', iface: INTERFACE.CLI, weight: 26, out: 0.08, cacheHeavy: true },
28
+ { model: 'claude-3-5-haiku-20241022', client: 'claude-desktop', iface: INTERFACE.DESKTOP, weight: 8, out: 0.12, cacheHeavy: false },
29
+ { model: 'gpt-4o', client: 'codex', iface: INTERFACE.IDE, weight: 14, out: 0.10, cacheHeavy: false },
30
+ { model: 'o3', client: 'codex', iface: INTERFACE.CLI, weight: 9, out: 0.18, cacheHeavy: false, reasoning: 0.55 },
31
+ { model: 'deepseek-chat', client: 'cline', iface: INTERFACE.CLI, weight: 6, out: 0.14, cacheHeavy: false },
32
+ { model: 'gemini-2.0-flash', client: 'api-script', iface: INTERFACE.API, weight: 3, out: 0.20, cacheHeavy: false },
33
+ ];
34
+ const PROJECTS = ['billing-service', 'web-app', 'infra-terraform', 'data-pipeline', 'docs'];
35
+
36
+ export default createProvider({
37
+ id: 'mock',
38
+ name: 'Demo data (synthetic)',
39
+ description: 'Deterministic synthetic usage for development and screenshots. Always labelled as demo.',
40
+ measurement: MEASUREMENT.PRIMARY,
41
+ requires: ['TOKENFLOW_DEMO=1, or add "mock" to providers in config.yaml'],
42
+
43
+ async detect(ctx) {
44
+ const on = process.env.TOKENFLOW_DEMO === '1' || (ctx?.config?.providers || []).includes('mock');
45
+ return on
46
+ ? { available: true, detail: 'SYNTHETIC DEMO DATA — not real usage' }
47
+ : { available: false, detail: 'set TOKENFLOW_DEMO=1 to generate demo data' };
48
+ },
49
+
50
+ async fetchUsage(ctx, emit) {
51
+ const days = Number(ctx?.config?.sources?.mock?.days ?? 160);
52
+ const seed = Number(ctx?.config?.sources?.mock?.seed ?? 20260814);
53
+ const r = rng(seed);
54
+ const end = new Date();
55
+ end.setUTCHours(0, 0, 0, 0);
56
+ let records = 0;
57
+
58
+ const total = MODELS.reduce((a, m) => a + m.weight, 0);
59
+ for (let dayIdx = days - 1; dayIdx >= 0; dayIdx--) {
60
+ const d = new Date(end.getTime() - dayIdx * 86400000);
61
+ const dow = (d.getUTCDay() + 6) % 7;
62
+ const weekend = dow >= 5;
63
+ // A gentle upward trend plus weekday seasonality plus noise.
64
+ const trend = 0.55 + 0.9 * ((days - dayIdx) / days);
65
+ const dayFactor = (weekend ? 0.28 : 1) * trend * (0.65 + r() * 0.7);
66
+ if (r() < (weekend ? 0.45 : 0.06)) continue; // a few genuinely idle days
67
+ const requests = Math.round(40 * dayFactor);
68
+ const sessions = Math.max(1, Math.round(requests / (6 + r() * 6)));
69
+
70
+ for (let s = 0; s < sessions; s++) {
71
+ // Bimodal working hours: a morning block and an evening block.
72
+ const evening = r() < 0.38;
73
+ const baseHour = evening ? 19 + Math.floor(r() * 4) : 9 + Math.floor(r() * 5);
74
+ const sid = `demo-${d.toISOString().slice(0, 10)}-${s}`;
75
+ const project = PROJECTS[Math.floor(r() * PROJECTS.length)];
76
+ let pickV = r() * total;
77
+ let spec = MODELS[0];
78
+ for (const m of MODELS) { pickV -= m.weight; if (pickV <= 0) { spec = m; break; } }
79
+
80
+ const perSession = Math.max(1, Math.round(requests / sessions));
81
+ for (let i = 0; i < perSession; i++) {
82
+ const hour = Math.min(23, baseHour + Math.floor(r() * 2));
83
+ const ts = new Date(d.getTime() + hour * 3600000 + Math.floor(r() * 3600000));
84
+ const scale = 1 + r() * 9;
85
+ const cacheRead = spec.cacheHeavy ? Math.round(9000 * scale * (1 + r())) : Math.round(400 * scale);
86
+ const cacheWrite = spec.cacheHeavy ? Math.round(2400 * scale * r()) : 0;
87
+ const input = Math.round(180 * scale * (0.4 + r()));
88
+ const output = Math.round((input + cacheRead) * spec.out * (0.5 + r()));
89
+ emit({
90
+ id: `mock-${sid}-${i}`,
91
+ timestamp: ts.toISOString(),
92
+ model: spec.model,
93
+ client: spec.client,
94
+ application: spec.client,
95
+ interface: spec.iface,
96
+ input_tokens: input,
97
+ output_tokens: output,
98
+ cache_read_tokens: cacheRead,
99
+ cache_write_tokens: cacheWrite,
100
+ cache_refresh_tokens: spec.cacheHeavy ? Math.round(cacheWrite * 0.35) : 0,
101
+ reasoning_tokens: spec.reasoning ? Math.round(output * spec.reasoning) : null,
102
+ session_id: sid,
103
+ project,
104
+ repository: project,
105
+ git_branch: r() < 0.5 ? 'main' : `feat/${project}-${Math.floor(r() * 90)}`,
106
+ category: 'main',
107
+ machine: 'demo-machine',
108
+ user: 'demo',
109
+ metadata: { demo: true },
110
+ });
111
+ records++;
112
+ }
113
+ }
114
+ }
115
+ return { records, notes: ['synthetic demo data — clearly labelled in the UI'] };
116
+ },
117
+ });