@tokentop/ttop 0.6.1 → 0.7.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 (33) hide show
  1. package/package.json +4 -4
  2. package/src/agents/aggregator.test.ts +269 -1
  3. package/src/agents/aggregator.ts +196 -8
  4. package/src/agents/costing.test.ts +584 -0
  5. package/src/agents/costing.ts +144 -30
  6. package/src/agents/types.ts +7 -0
  7. package/src/demo/simulator.test.ts +92 -0
  8. package/src/demo/simulator.ts +579 -211
  9. package/src/plugins/plugin-context-factory.ts +9 -1
  10. package/src/plugins/providers/anthropic.test.ts +145 -0
  11. package/src/plugins/providers/anthropic.ts +18 -12
  12. package/src/plugins/providers/chutes.ts +2 -0
  13. package/src/plugins/providers/github-copilot.ts +3 -0
  14. package/src/plugins/providers/minimax.ts +2 -0
  15. package/src/plugins/providers/openai-api.ts +4 -0
  16. package/src/plugins/providers/zai.ts +2 -0
  17. package/src/pricing/estimator.ts +7 -0
  18. package/src/pricing/index.ts +5 -0
  19. package/src/pricing/models-dev.test.ts +117 -0
  20. package/src/pricing/models-dev.ts +22 -0
  21. package/src/storage/persistence-service.test.ts +201 -0
  22. package/src/storage/persistence-service.ts +54 -5
  23. package/src/storage/repos/agentSessions.ts +49 -0
  24. package/src/tui/App.tsx +4 -4
  25. package/src/tui/components/SessionDetailsDrawer.tsx +29 -1
  26. package/src/tui/components/SessionsTable.tsx +25 -7
  27. package/src/tui/contexts/AgentSessionContext.tsx +59 -21
  28. package/src/tui/contexts/PluginContext.tsx +31 -10
  29. package/src/tui/utils/scrollFollow.test.ts +71 -0
  30. package/src/tui/utils/scrollFollow.ts +12 -0
  31. package/src/tui/views/Dashboard.tsx +0 -6
  32. package/src/tui/views/RealTimeDashboard.tsx +105 -38
  33. package/src/version.ts +1 -1
@@ -1,12 +1,21 @@
1
1
  import type { ProviderUsageData } from "@tokentop/plugin-sdk";
2
- import type { AgentSessionAggregate, AgentSessionStream } from "@/agents/types.ts";
2
+ import type {
3
+ AgentSessionAggregate,
4
+ AgentSessionStream,
5
+ StreamCostBreakdown,
6
+ StreamWindowedTokens,
7
+ TokenCounts,
8
+ } from "@/agents/types.ts";
3
9
  import type { UsageEventInsert } from "@/storage/types.ts";
4
10
 
5
11
  /**
6
12
  * Demo presets control the intensity of simulated activity.
7
- * - light: Low activity, fewer sessions, slower token accumulation
8
- * - normal: Moderate activity, balanced simulation (default)
9
- * - heavy: High activity, more sessions, faster token accumulation
13
+ * - light: ~50 sessions, low variety, sparse activity
14
+ * - normal: ~500 sessions, moderate variety, balanced simulation (default)
15
+ * - heavy: ~4000 sessions, wide model/project variety, bursty activity
16
+ *
17
+ * All generation is deterministic on (seed, preset): identical session ids,
18
+ * counts, tokens, costs, project paths for every run at the same seed.
10
19
  */
11
20
  export type DemoPreset = "light" | "normal" | "heavy";
12
21
 
@@ -18,126 +27,314 @@ export interface DemoPresetConfig {
18
27
  idleProbability: number;
19
28
  burstProbability: number;
20
29
  burstMultiplier: number;
30
+ tokenScale: number;
31
+ historicalDailyCostBase: number;
21
32
  }
22
33
 
23
34
  export const DEMO_PRESETS: Record<DemoPreset, DemoPresetConfig> = {
24
35
  light: {
25
- sessionCount: 2,
36
+ sessionCount: 50,
26
37
  activityMultiplier: 0.5,
27
38
  extraProviderCount: 1,
28
39
  usageRange: [5, 35],
29
40
  idleProbability: 0.6,
30
41
  burstProbability: 0.05,
31
42
  burstMultiplier: 3,
43
+ tokenScale: 150,
44
+ historicalDailyCostBase: 2,
32
45
  },
33
46
  normal: {
34
- sessionCount: 4,
47
+ sessionCount: 500,
35
48
  activityMultiplier: 1.0,
36
49
  extraProviderCount: 3,
37
50
  usageRange: [8, 72],
38
51
  idleProbability: 0.35,
39
52
  burstProbability: 0.1,
40
53
  burstMultiplier: 4,
54
+ tokenScale: 250,
55
+ historicalDailyCostBase: 15,
41
56
  },
42
57
  heavy: {
43
- sessionCount: 6,
58
+ sessionCount: 4000,
44
59
  activityMultiplier: 2.0,
45
60
  extraProviderCount: 10,
46
61
  usageRange: [25, 95],
47
62
  idleProbability: 0.15,
48
63
  burstProbability: 0.2,
49
64
  burstMultiplier: 5,
65
+ tokenScale: 400,
66
+ historicalDailyCostBase: 55,
50
67
  },
51
68
  };
52
69
 
53
- export interface DemoSessionSeed {
54
- sessionId: string;
55
- sessionName?: string;
56
- agentId: "opencode" | "claude-code" | "cursor";
57
- agentName: "OpenCode" | "Claude Code" | "Cursor";
58
- projectPath: string;
59
- modelId: string;
70
+ type AgentKey = "opencode" | "claude-code" | "cursor" | "copilot-cli" | "gemini-cli";
71
+ type AgentName = "OpenCode" | "Claude Code" | "Cursor" | "Copilot CLI" | "Gemini CLI";
72
+
73
+ interface ModelConfig {
60
74
  providerId: string;
61
- baseTokens: number;
62
- baseCost: number;
63
- inactive?: boolean;
75
+ modelId: string;
76
+ inputRate: number;
77
+ outputRate: number;
78
+ cacheReadRate?: number;
79
+ cacheWriteRate?: number;
80
+ weight: number;
64
81
  }
65
82
 
66
- const DEFAULT_SESSIONS: DemoSessionSeed[] = [
83
+ const MODEL_POOL: ModelConfig[] = [
67
84
  {
68
- sessionId: "demo-opencode-1",
69
- sessionName: "Implement dashboard view with real-time updates",
70
- agentId: "opencode",
71
- agentName: "OpenCode",
72
- projectPath: "/Users/demo/workspace/tokentop",
73
- modelId: "claude-3-5-sonnet",
74
85
  providerId: "anthropic",
75
- baseTokens: 3200,
76
- baseCost: 1.24,
86
+ modelId: "claude-opus-4-7",
87
+ inputRate: 15,
88
+ outputRate: 75,
89
+ cacheReadRate: 1.5,
90
+ cacheWriteRate: 18.75,
91
+ weight: 90,
92
+ },
93
+ {
94
+ providerId: "anthropic",
95
+ modelId: "claude-sonnet-4-20250514",
96
+ inputRate: 3,
97
+ outputRate: 15,
98
+ cacheReadRate: 0.3,
99
+ cacheWriteRate: 3.75,
100
+ weight: 45,
101
+ },
102
+ {
103
+ providerId: "xai",
104
+ modelId: "grok-code-fast-1",
105
+ inputRate: 0.2,
106
+ outputRate: 1.5,
107
+ weight: 38,
108
+ },
109
+ {
110
+ providerId: "anthropic",
111
+ modelId: "claude-3-5-sonnet-20241022",
112
+ inputRate: 3,
113
+ outputRate: 15,
114
+ cacheReadRate: 0.3,
115
+ cacheWriteRate: 3.75,
116
+ weight: 30,
77
117
  },
78
118
  {
79
- sessionId: "demo-opencode-2",
80
- sessionName: "Fix Kubernetes deployment configuration",
81
- agentId: "opencode",
82
- agentName: "OpenCode",
83
- projectPath: "/Users/demo/workspace/infra",
84
- modelId: "gpt-4.1",
85
119
  providerId: "openai",
86
- baseTokens: 2100,
87
- baseCost: 0.92,
120
+ modelId: "gpt-4.1",
121
+ inputRate: 2,
122
+ outputRate: 8,
123
+ cacheReadRate: 0.5,
124
+ weight: 22,
125
+ },
126
+ {
127
+ providerId: "google-gemini",
128
+ modelId: "gemini-2.5-pro",
129
+ inputRate: 1.25,
130
+ outputRate: 10,
131
+ weight: 18,
132
+ },
133
+ {
134
+ providerId: "anthropic",
135
+ modelId: "claude-3-5-haiku-20241022",
136
+ inputRate: 0.8,
137
+ outputRate: 4,
138
+ cacheReadRate: 0.08,
139
+ cacheWriteRate: 1,
140
+ weight: 15,
88
141
  },
142
+ { providerId: "minimax", modelId: "minimax-m2.5-free", inputRate: 0, outputRate: 0, weight: 14 },
89
143
  {
90
- sessionId: "demo-claude-1",
91
- sessionName: "Add authentication flow to mobile app",
92
- agentId: "claude-code",
93
- agentName: "Claude Code",
94
- projectPath: "/Users/demo/workspace/mobile",
95
- modelId: "claude-3-opus",
96
144
  providerId: "anthropic",
97
- baseTokens: 1800,
98
- baseCost: 0.78,
145
+ modelId: "claude-3-7-sonnet-20250219",
146
+ inputRate: 3,
147
+ outputRate: 15,
148
+ cacheReadRate: 0.3,
149
+ cacheWriteRate: 3.75,
150
+ weight: 12,
151
+ },
152
+ {
153
+ providerId: "openai",
154
+ modelId: "gpt-4.1-mini",
155
+ inputRate: 0.4,
156
+ outputRate: 1.6,
157
+ cacheReadRate: 0.1,
158
+ weight: 11,
99
159
  },
100
160
  {
101
- sessionId: "demo-cursor-1",
102
- sessionName: "Refactor API endpoints for better performance",
103
- agentId: "cursor",
104
- agentName: "Cursor",
105
- projectPath: "/Users/demo/workspace/webapp",
106
- modelId: "gemini-2.0-pro",
107
161
  providerId: "google-gemini",
108
- baseTokens: 2600,
109
- baseCost: 0.64,
162
+ modelId: "gemini-3-pro-preview",
163
+ inputRate: 2,
164
+ outputRate: 12,
165
+ cacheReadRate: 0.2,
166
+ weight: 10,
110
167
  },
111
168
  {
112
- sessionId: "demo-opencode-old-1",
113
- sessionName: "Migrate legacy API to REST v2",
114
- agentId: "opencode",
115
- agentName: "OpenCode",
116
- projectPath: "/Users/demo/workspace/legacy-api",
117
- modelId: "claude-3-5-sonnet",
169
+ providerId: "openai",
170
+ modelId: "gpt-4o",
171
+ inputRate: 2.5,
172
+ outputRate: 10,
173
+ cacheReadRate: 1.25,
174
+ weight: 9,
175
+ },
176
+ {
177
+ providerId: "google-gemini",
178
+ modelId: "gemini-2.5-flash",
179
+ inputRate: 0.15,
180
+ outputRate: 0.6,
181
+ weight: 8,
182
+ },
183
+ { providerId: "openai", modelId: "o3", inputRate: 10, outputRate: 40, weight: 7 },
184
+ { providerId: "openai", modelId: "o4-mini", inputRate: 1.1, outputRate: 4.4, weight: 6 },
185
+ {
118
186
  providerId: "anthropic",
119
- baseTokens: 5400,
120
- baseCost: 2.18,
121
- inactive: true,
187
+ modelId: "claude-3-opus-20240229",
188
+ inputRate: 15,
189
+ outputRate: 75,
190
+ cacheReadRate: 1.5,
191
+ cacheWriteRate: 18.75,
192
+ weight: 5,
193
+ },
194
+ {
195
+ providerId: "google-gemini",
196
+ modelId: "gemini-3-flash-preview",
197
+ inputRate: 0.5,
198
+ outputRate: 3,
199
+ cacheReadRate: 0.05,
200
+ weight: 5,
122
201
  },
123
202
  {
124
- sessionId: "demo-claude-old-1",
125
- sessionName: "Update documentation with new API examples",
126
- agentId: "claude-code",
127
- agentName: "Claude Code",
128
- projectPath: "/Users/demo/workspace/docs-site",
129
- modelId: "gpt-4.1",
130
203
  providerId: "openai",
131
- baseTokens: 3800,
132
- baseCost: 1.56,
133
- inactive: true,
204
+ modelId: "gpt-4o-mini",
205
+ inputRate: 0.15,
206
+ outputRate: 0.6,
207
+ cacheReadRate: 0.075,
208
+ weight: 4,
209
+ },
210
+ {
211
+ providerId: "google-gemini",
212
+ modelId: "gemini-3.1-pro-preview",
213
+ inputRate: 2,
214
+ outputRate: 12,
215
+ cacheReadRate: 0.2,
216
+ weight: 3,
217
+ },
218
+ {
219
+ providerId: "github-copilot",
220
+ modelId: "claude-sonnet-4-copilot",
221
+ inputRate: 0,
222
+ outputRate: 0,
223
+ weight: 3,
224
+ },
225
+ { providerId: "openai", modelId: "o3-mini", inputRate: 1.1, outputRate: 4.4, weight: 2 },
226
+ ];
227
+
228
+ interface AgentConfig {
229
+ id: AgentKey;
230
+ name: AgentName;
231
+ weight: number;
232
+ allowedProviders?: Set<string>;
233
+ }
234
+
235
+ const AGENT_POOL: AgentConfig[] = [
236
+ { id: "opencode", name: "OpenCode", weight: 55 },
237
+ {
238
+ id: "claude-code",
239
+ name: "Claude Code",
240
+ weight: 20,
241
+ allowedProviders: new Set(["anthropic"]),
242
+ },
243
+ { id: "cursor", name: "Cursor", weight: 10 },
244
+ {
245
+ id: "copilot-cli",
246
+ name: "Copilot CLI",
247
+ weight: 8,
248
+ allowedProviders: new Set(["github-copilot", "anthropic", "openai"]),
134
249
  },
250
+ {
251
+ id: "gemini-cli",
252
+ name: "Gemini CLI",
253
+ weight: 7,
254
+ allowedProviders: new Set(["google-gemini"]),
255
+ },
256
+ ];
257
+
258
+ interface ProjectConfig {
259
+ path: string;
260
+ weight: number;
261
+ }
262
+
263
+ const PROJECT_POOL: ProjectConfig[] = [
264
+ { path: "/Users/demo/workspace/tokentop", weight: 35 },
265
+ { path: "/Users/demo/workspace/webapp", weight: 20 },
266
+ { path: "/Users/demo/workspace/infra", weight: 14 },
267
+ { path: "/Users/demo/workspace/mobile", weight: 11 },
268
+ { path: "/Users/demo/workspace/docs-site", weight: 8 },
269
+ { path: "/Users/demo/workspace/ml-pipeline", weight: 7 },
270
+ { path: "/Users/demo/workspace/auth-service", weight: 6 },
271
+ { path: "/Users/demo/workspace/design-system", weight: 5 },
272
+ { path: "/Users/demo/workspace/notifications-service", weight: 5 },
273
+ { path: "/Users/demo/workspace/data-platform", weight: 4 },
274
+ { path: "/Users/demo/workspace/legacy-api", weight: 3 },
275
+ { path: "/Users/demo/workspace/playground", weight: 2 },
276
+ ];
277
+
278
+ const SESSION_NAME_VERBS = [
279
+ "Implement",
280
+ "Fix",
281
+ "Refactor",
282
+ "Add",
283
+ "Remove",
284
+ "Update",
285
+ "Migrate",
286
+ "Optimize",
287
+ "Debug",
288
+ "Review",
289
+ "Investigate",
290
+ "Design",
291
+ "Prototype",
292
+ "Test",
293
+ "Ship",
294
+ "Rewrite",
295
+ "Document",
296
+ ];
297
+ const SESSION_NAME_SUBJECTS = [
298
+ "authentication flow",
299
+ "user dashboard",
300
+ "caching layer",
301
+ "API pagination",
302
+ "rate limiting",
303
+ "error handling",
304
+ "database schema",
305
+ "CI pipeline",
306
+ "deployment config",
307
+ "observability stack",
308
+ "feature flags",
309
+ "type definitions",
310
+ "onboarding wizard",
311
+ "settings page",
312
+ "webhook handler",
313
+ "billing integration",
314
+ "search index",
315
+ "notification system",
316
+ "audit log",
317
+ "data migration script",
318
+ "component library",
319
+ "theme tokens",
320
+ "analytics tracker",
321
+ "session store",
322
+ "oauth callback",
323
+ "permissions model",
324
+ "export pipeline",
325
+ "rollout telemetry",
326
+ "plugin loader",
327
+ "credential discovery",
328
+ "snapshot regression",
135
329
  ];
136
330
 
137
331
  const PROVIDER_LIMITS: Record<string, { label: string; windowMinutes: number }> = {
138
332
  anthropic: { label: "Daily Tokens", windowMinutes: 1440 },
139
333
  openai: { label: "Daily Tokens", windowMinutes: 1440 },
140
334
  "google-gemini": { label: "Daily Tokens", windowMinutes: 1440 },
335
+ xai: { label: "Daily Tokens", windowMinutes: 1440 },
336
+ minimax: { label: "Daily Tokens", windowMinutes: 1440 },
337
+ "github-copilot": { label: "Monthly Tokens", windowMinutes: 43200 },
141
338
  };
142
339
 
143
340
  const EXTRA_PROVIDERS: Array<{ id: string; label: string; balance?: string }> = [
@@ -153,6 +350,9 @@ const EXTRA_PROVIDERS: Array<{ id: string; label: string; balance?: string }> =
153
350
  { id: "fireworks", label: "Fireworks AI", balance: "$12.00" },
154
351
  ];
155
352
 
353
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
354
+ const HISTORICAL_WINDOW_MS = 30 * MS_PER_DAY;
355
+
156
356
  function clamp(value: number, min: number, max: number): number {
157
357
  return Math.max(min, Math.min(max, value));
158
358
  }
@@ -161,6 +361,14 @@ function hashCombine(a: number, b: number): number {
161
361
  return ((a * 2654435761) ^ (b * 1597334677)) >>> 0;
162
362
  }
163
363
 
364
+ function roundCost(value: number): number {
365
+ return Math.round(value * 1_000_000) / 1_000_000;
366
+ }
367
+
368
+ function totalTokens(tokens: TokenCounts): number {
369
+ return tokens.input + (tokens.cacheRead ?? 0) + (tokens.cacheWrite ?? 0) + tokens.output;
370
+ }
371
+
164
372
  class DemoRng {
165
373
  private seed: number;
166
374
  private initialSeed: number;
@@ -188,6 +396,219 @@ class DemoRng {
188
396
  }
189
397
  }
190
398
 
399
+ function pickWeighted<T extends { weight: number }>(rng: DemoRng, items: T[]): T {
400
+ let total = 0;
401
+ for (const item of items) total += item.weight;
402
+ let r = rng.next() * total;
403
+ for (const item of items) {
404
+ r -= item.weight;
405
+ if (r <= 0) return item;
406
+ }
407
+ return items[items.length - 1]!;
408
+ }
409
+
410
+ function pickAgent(rng: DemoRng): AgentConfig {
411
+ return pickWeighted(rng, AGENT_POOL);
412
+ }
413
+
414
+ function pickModelForAgent(rng: DemoRng, agent: AgentConfig): ModelConfig {
415
+ const allowed = agent.allowedProviders
416
+ ? MODEL_POOL.filter((m) => agent.allowedProviders!.has(m.providerId))
417
+ : MODEL_POOL;
418
+ return pickWeighted(rng, allowed);
419
+ }
420
+
421
+ function pickProject(rng: DemoRng): ProjectConfig {
422
+ return pickWeighted(rng, PROJECT_POOL);
423
+ }
424
+
425
+ function pickSessionName(rng: DemoRng): string {
426
+ const verbIndex = Math.floor(rng.next() * SESSION_NAME_VERBS.length);
427
+ const subjectIndex = Math.floor(rng.next() * SESSION_NAME_SUBJECTS.length);
428
+ return `${SESSION_NAME_VERBS[verbIndex]} ${SESSION_NAME_SUBJECTS[subjectIndex]}`;
429
+ }
430
+
431
+ function paretoBounded(rng: DemoRng, scale: number, alpha: number, maxValue: number): number {
432
+ const u = clamp(rng.next(), 1e-6, 1 - 1e-6);
433
+ const draw = scale * (1 - u) ** (-1 / alpha);
434
+ return Math.min(maxValue, draw);
435
+ }
436
+
437
+ interface GeneratedSession {
438
+ aggregate: AgentSessionAggregate;
439
+ isActive: boolean;
440
+ }
441
+
442
+ function buildSession(
443
+ index: number,
444
+ parentRng: DemoRng,
445
+ preset: DemoPreset,
446
+ presetConfig: DemoPresetConfig,
447
+ now: number,
448
+ isActive: boolean,
449
+ anchors: {
450
+ startOfDay: number;
451
+ startOfWeek: number;
452
+ startOfMonth: number;
453
+ },
454
+ ): GeneratedSession {
455
+ const rng = parentRng.fork(index);
456
+
457
+ const agent = pickAgent(rng);
458
+ const model = pickModelForAgent(rng, agent);
459
+ const project = pickProject(rng);
460
+ const sessionName = pickSessionName(rng);
461
+
462
+ let startedAt: number;
463
+ let lastActivityAt: number;
464
+ let endedAt: number | undefined;
465
+
466
+ if (isActive) {
467
+ startedAt = now - rng.range(5, 120) * 60 * 1000;
468
+ lastActivityAt = now - Math.floor(rng.range(0, 55) * 1000);
469
+ } else {
470
+ // Recency-biased spread over 30d: u^1.8 concentrates near 0 (recent), tail to 30d.
471
+ const recencyBias = rng.next() ** 1.8;
472
+ const minAgeMs = 2 * 60 * 1000;
473
+ const ageMs = minAgeMs + recencyBias * (HISTORICAL_WINDOW_MS - minAgeMs);
474
+ const durationMs = rng.range(30 * 1000, 45 * 60 * 1000);
475
+ lastActivityAt = now - ageMs;
476
+ startedAt = lastActivityAt - durationMs;
477
+ endedAt = lastActivityAt;
478
+ }
479
+
480
+ const baseTokens = Math.floor(paretoBounded(rng, presetConfig.tokenScale, 1.35, 600_000));
481
+ const inputTokens = Math.max(1, Math.floor(baseTokens * rng.range(0.55, 0.8)));
482
+ const outputTokens = Math.max(1, baseTokens - inputTokens);
483
+
484
+ const longContextRoll = rng.next();
485
+ const hasLongContext = preset !== "light" && baseTokens > 4_000 && longContextRoll < 0.18;
486
+ const longContextTokens: TokenCounts | undefined = hasLongContext
487
+ ? {
488
+ input: 180_000 + Math.floor(rng.range(0, 120_000)),
489
+ output: 2_500 + Math.floor(rng.range(0, 9_500)),
490
+ }
491
+ : undefined;
492
+
493
+ const cacheReadTokens = Math.floor(rng.next() < 0.6 ? baseTokens * rng.range(0, 3) : 0);
494
+ const cacheWriteTokens = Math.floor(cacheReadTokens * rng.range(0.08, 0.25));
495
+
496
+ const inputCost = (inputTokens / 1_000_000) * model.inputRate;
497
+ const outputCost = (outputTokens / 1_000_000) * model.outputRate;
498
+ const cacheReadCost = (cacheReadTokens / 1_000_000) * (model.cacheReadRate ?? 0);
499
+ const cacheWriteCost = (cacheWriteTokens / 1_000_000) * (model.cacheWriteRate ?? 0);
500
+ const longCtxInputCost = longContextTokens
501
+ ? (longContextTokens.input / 1_000_000) * model.inputRate * 2
502
+ : 0;
503
+ const longCtxOutputCost = longContextTokens
504
+ ? (longContextTokens.output / 1_000_000) * model.outputRate * 2
505
+ : 0;
506
+
507
+ const totalCost = roundCost(
508
+ inputCost + outputCost + cacheReadCost + cacheWriteCost + longCtxInputCost + longCtxOutputCost,
509
+ );
510
+
511
+ const breakdown: StreamCostBreakdown = {
512
+ total: totalCost,
513
+ input: roundCost(inputCost + longCtxInputCost),
514
+ output: roundCost(outputCost + longCtxOutputCost),
515
+ };
516
+ if (cacheReadTokens > 0) breakdown.cacheRead = roundCost(cacheReadCost);
517
+ if (cacheWriteTokens > 0) breakdown.cacheWrite = roundCost(cacheWriteCost);
518
+
519
+ const requestCount = Math.max(1, Math.floor((inputTokens + outputTokens) / rng.range(650, 1400)));
520
+ const longContextRequestCount = hasLongContext ? 1 : 0;
521
+
522
+ const totalsInput = hasLongContext ? inputTokens + (longContextTokens?.input ?? 0) : inputTokens;
523
+ const totalsOutput = hasLongContext
524
+ ? outputTokens + (longContextTokens?.output ?? 0)
525
+ : outputTokens;
526
+
527
+ const totals: TokenCounts = {
528
+ input: totalsInput,
529
+ output: totalsOutput,
530
+ };
531
+ if (cacheReadTokens > 0) totals.cacheRead = cacheReadTokens;
532
+ if (cacheWriteTokens > 0) totals.cacheWrite = cacheWriteTokens;
533
+
534
+ const stream: AgentSessionStream = {
535
+ providerId: model.providerId,
536
+ modelId: model.modelId,
537
+ tokens: { input: inputTokens, output: outputTokens },
538
+ requestCount: requestCount + longContextRequestCount,
539
+ costUsd: totalCost,
540
+ costBreakdown: breakdown,
541
+ pricingSource: "fallback",
542
+ };
543
+ if (hasLongContext && longContextTokens) {
544
+ stream.longContextTokens = longContextTokens;
545
+ stream.longContextRequestCount = longContextRequestCount;
546
+ stream.hasLongContext = true;
547
+ }
548
+
549
+ const windowedBase = totalTokens({ input: inputTokens, output: outputTokens });
550
+ const longContextTotalTokens = longContextTokens ? totalTokens(longContextTokens) : 0;
551
+ const windowedTokens: StreamWindowedTokens = {
552
+ dayTokens: lastActivityAt >= anchors.startOfDay ? windowedBase : 0,
553
+ weekTokens: lastActivityAt >= anchors.startOfWeek ? windowedBase : 0,
554
+ monthTokens: lastActivityAt >= anchors.startOfMonth ? windowedBase : 0,
555
+ totalTokens: windowedBase,
556
+ };
557
+ if (longContextTotalTokens > 0) {
558
+ windowedTokens.longContextDayTokens =
559
+ lastActivityAt >= anchors.startOfDay ? longContextTotalTokens : 0;
560
+ windowedTokens.longContextWeekTokens =
561
+ lastActivityAt >= anchors.startOfWeek ? longContextTotalTokens : 0;
562
+ windowedTokens.longContextMonthTokens =
563
+ lastActivityAt >= anchors.startOfMonth ? longContextTotalTokens : 0;
564
+ windowedTokens.longContextTotalTokens = longContextTotalTokens;
565
+ }
566
+
567
+ const costInDay = lastActivityAt >= anchors.startOfDay ? totalCost : 0;
568
+ const costInWeek = lastActivityAt >= anchors.startOfWeek ? totalCost : 0;
569
+ const costInMonth = lastActivityAt >= anchors.startOfMonth ? totalCost : 0;
570
+
571
+ const status: "active" | "idle" = isActive ? (index % 3 === 0 ? "idle" : "active") : "idle";
572
+
573
+ const sessionId = `demo-${agent.id}-${String(index).padStart(5, "0")}`;
574
+
575
+ const aggregate: AgentSessionAggregate = {
576
+ sessionId,
577
+ sessionName,
578
+ agentId: agent.id,
579
+ agentName: agent.name,
580
+ projectPath: project.path,
581
+ startedAt,
582
+ lastActivityAt,
583
+ status,
584
+ totals,
585
+ totalCostUsd: totalCost,
586
+ requestCount: requestCount + longContextRequestCount,
587
+ streams: [stream],
588
+ costInDay,
589
+ costInWeek,
590
+ costInMonth,
591
+ _streamWindowedTokens: new Map([[`${model.providerId}::${model.modelId}`, windowedTokens]]),
592
+ };
593
+ if (endedAt !== undefined) {
594
+ aggregate.endedAt = endedAt;
595
+ }
596
+
597
+ return { aggregate, isActive };
598
+ }
599
+
600
+ function computeTimeAnchors(now: number): {
601
+ startOfDay: number;
602
+ startOfWeek: number;
603
+ startOfMonth: number;
604
+ } {
605
+ const d = new Date(now);
606
+ const startOfDay = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
607
+ const startOfWeek = new Date(d.getFullYear(), d.getMonth(), d.getDate() - d.getDay()).getTime();
608
+ const startOfMonth = new Date(d.getFullYear(), d.getMonth(), 1).getTime();
609
+ return { startOfDay, startOfWeek, startOfMonth };
610
+ }
611
+
191
612
  export interface DemoSimulatorSnapshot {
192
613
  sessions: AgentSessionAggregate[];
193
614
  providerUsage: Map<string, ProviderUsageData>;
@@ -201,7 +622,8 @@ export interface DemoSimulatorOptions {
201
622
 
202
623
  export class DemoSimulator {
203
624
  private rng: DemoRng;
204
- private sessions: AgentSessionAggregate[];
625
+ private activeSessions: AgentSessionAggregate[];
626
+ private readonly historicalSessions: AgentSessionAggregate[];
205
627
  private providerUsage: Map<string, ProviderUsageData>;
206
628
  private lastTick: number;
207
629
  private readonly startTime: number;
@@ -217,130 +639,87 @@ export class DemoSimulator {
217
639
  this.rng = new DemoRng(seed);
218
640
  const now = Date.now();
219
641
  this.startTime = now;
642
+ this.lastTick = now;
220
643
 
221
- const sessionCount = Math.min(this.presetConfig.sessionCount, DEFAULT_SESSIONS.length);
222
-
223
- // Select sessions: include a mix of inactive history and active sessions per preset size.
224
- const inactiveSessions = DEFAULT_SESSIONS.filter((s) => s.inactive ?? false);
225
- const activeSessions = DEFAULT_SESSIONS.filter((s) => !(s.inactive ?? false));
226
- const desiredInactive = Math.max(1, Math.round(sessionCount * 0.3));
227
- let inactiveCount = Math.min(inactiveSessions.length, desiredInactive);
228
- if (activeSessions.length > 0) {
229
- inactiveCount = Math.min(inactiveCount, sessionCount - 1);
230
- }
231
- const activeCount = Math.min(activeSessions.length, sessionCount - inactiveCount);
232
- if (activeCount + inactiveCount < sessionCount) {
233
- inactiveCount = Math.min(inactiveSessions.length, sessionCount - activeCount);
644
+ const anchors = computeTimeAnchors(now);
645
+ const sessionCount = this.presetConfig.sessionCount;
646
+ // Keep 4–8 truly active sessions regardless of preset scale so the
647
+ // burn-rate / sparkline feedback loops still animate on tick().
648
+ const activeCount = clamp(
649
+ 4 + Math.floor(this.presetConfig.activityMultiplier * 2),
650
+ 4,
651
+ Math.min(8, sessionCount),
652
+ );
653
+
654
+ const genRng = this.rng.fork(1);
655
+ const active: AgentSessionAggregate[] = [];
656
+ const historical: AgentSessionAggregate[] = [];
657
+
658
+ for (let i = 0; i < sessionCount; i++) {
659
+ const isActive = i < activeCount;
660
+ const { aggregate } = buildSession(
661
+ i,
662
+ genRng,
663
+ this.preset,
664
+ this.presetConfig,
665
+ now,
666
+ isActive,
667
+ anchors,
668
+ );
669
+ if (isActive) active.push(aggregate);
670
+ else historical.push(aggregate);
234
671
  }
235
672
 
236
- const selectedSessions = [
237
- ...activeSessions.slice(0, activeCount),
238
- ...inactiveSessions.slice(0, inactiveCount),
239
- ];
240
-
241
- this.sessions = selectedSessions.map((seedSession, index) => {
242
- const tokens = seedSession.baseTokens;
243
- const cost = seedSession.baseCost;
244
- const inputTokens = Math.floor(tokens * 0.6);
245
- const outputTokens = tokens - inputTokens;
246
- const streams: AgentSessionStream[] = [
247
- {
248
- providerId: seedSession.providerId,
249
- modelId: seedSession.modelId,
250
- tokens: { input: inputTokens, output: outputTokens },
251
- requestCount: Math.max(1, Math.floor(tokens / 800)),
252
- costUsd: cost,
253
- pricingSource: "fallback",
254
- },
255
- ];
256
-
257
- const isInactive = seedSession.inactive ?? false;
258
- const startedAt = isInactive
259
- ? now - this.rng.range(3, 7) * 24 * 60 * 60 * 1000
260
- : now - (index + 1) * 45 * 60 * 1000;
261
- const lastActivityAt = isInactive
262
- ? startedAt + this.rng.range(30, 120) * 60 * 1000
263
- : now - this.rng.range(10_000, 50_000);
264
-
265
- const nowDate = new Date(now);
266
- const startOfDay = new Date(
267
- nowDate.getFullYear(),
268
- nowDate.getMonth(),
269
- nowDate.getDate(),
270
- ).getTime();
271
- const dayOfWeek = nowDate.getDay();
272
- const startOfWeek = new Date(
273
- nowDate.getFullYear(),
274
- nowDate.getMonth(),
275
- nowDate.getDate() - dayOfWeek,
276
- ).getTime();
277
- const startOfMonth = new Date(nowDate.getFullYear(), nowDate.getMonth(), 1).getTime();
278
-
279
- const costInDay = lastActivityAt >= startOfDay ? cost : 0;
280
- const costInWeek = lastActivityAt >= startOfWeek ? cost : 0;
281
- const costInMonth = lastActivityAt >= startOfMonth ? cost : 0;
282
-
283
- const baseSession: Omit<AgentSessionAggregate, "endedAt"> = {
284
- sessionId: seedSession.sessionId,
285
- agentId: seedSession.agentId,
286
- agentName: seedSession.agentName,
287
- projectPath: seedSession.projectPath,
288
- startedAt,
289
- lastActivityAt,
290
- status: isInactive ? "idle" : index % 3 === 0 ? "idle" : "active",
291
- totals: {
292
- input: inputTokens,
293
- output: outputTokens,
294
- cacheRead: Math.floor(tokens * 0.05),
295
- cacheWrite: Math.floor(tokens * 0.02),
296
- },
297
- totalCostUsd: cost,
298
- requestCount: Math.max(1, Math.floor(tokens / 700)),
299
- streams,
300
- costInDay,
301
- costInWeek,
302
- costInMonth,
303
- };
304
- if (seedSession.sessionName) {
305
- baseSession.sessionName = seedSession.sessionName;
306
- }
673
+ this.activeSessions = active;
674
+ this.historicalSessions = historical;
307
675
 
308
- return isInactive ? { ...baseSession, endedAt: lastActivityAt } : baseSession;
309
- });
310
-
311
- const sessionProviderIds = new Set(selectedSessions.map((s) => s.providerId));
312
- this.fixedProviderIds = Array.from(sessionProviderIds);
676
+ const providerIds = new Set<string>();
677
+ for (const session of this.getAllSessions()) {
678
+ for (const stream of session.streams) providerIds.add(stream.providerId);
679
+ }
680
+ this.fixedProviderIds = Array.from(providerIds);
313
681
 
314
682
  const extraCount = Math.min(this.presetConfig.extraProviderCount, EXTRA_PROVIDERS.length);
315
- this.fixedExtraProviders = EXTRA_PROVIDERS.slice(0, extraCount);
683
+ this.fixedExtraProviders = EXTRA_PROVIDERS.slice(0, extraCount).filter(
684
+ (p) => !providerIds.has(p.id),
685
+ );
316
686
 
317
687
  this.providerUsage = new Map();
318
- this.lastTick = now;
319
688
  this.initializeProviderUsage(now);
320
689
  }
321
690
 
322
- private initializeProviderUsage(now: number) {
323
- const providerTotals = new Map<string, { tokens: number; cost: number; requests: number }>();
324
- for (const session of this.sessions) {
691
+ private getAllSessions(): AgentSessionAggregate[] {
692
+ return [...this.activeSessions, ...this.historicalSessions];
693
+ }
694
+
695
+ private aggregateProviderTotals(): Map<
696
+ string,
697
+ { tokens: number; cost: number; requests: number }
698
+ > {
699
+ const totals = new Map<string, { tokens: number; cost: number; requests: number }>();
700
+ for (const session of this.getAllSessions()) {
325
701
  for (const stream of session.streams) {
326
- const current = providerTotals.get(stream.providerId) ?? {
327
- tokens: 0,
328
- cost: 0,
329
- requests: 0,
330
- };
702
+ const current = totals.get(stream.providerId) ?? { tokens: 0, cost: 0, requests: 0 };
331
703
  const streamTokens = stream.tokens.input + stream.tokens.output;
332
- providerTotals.set(stream.providerId, {
704
+ totals.set(stream.providerId, {
333
705
  tokens: current.tokens + streamTokens,
334
706
  cost: current.cost + (stream.costUsd ?? 0),
335
707
  requests: current.requests + stream.requestCount,
336
708
  });
337
709
  }
338
710
  }
711
+ return totals;
712
+ }
713
+
714
+ private initializeProviderUsage(now: number) {
715
+ const providerTotals = this.aggregateProviderTotals();
339
716
 
340
717
  for (const providerId of this.fixedProviderIds) {
341
718
  const totals = providerTotals.get(providerId) ?? { tokens: 0, cost: 0, requests: 0 };
342
719
  const limit = PROVIDER_LIMITS[providerId] ?? { label: "Daily Tokens", windowMinutes: 1440 };
343
- const usedPercent = clamp((totals.tokens / 50_000) * 100, 5, 98);
720
+ // Scale the "used %" relative to preset so heavy looks busy but never maxed by default.
721
+ const scaleDivisor = this.presetConfig.sessionCount * 12_000;
722
+ const usedPercent = clamp((totals.tokens / scaleDivisor) * 100, 5, 98);
344
723
  const limitReached = usedPercent > 95;
345
724
 
346
725
  this.providerUsage.set(providerId, {
@@ -379,8 +758,9 @@ export class DemoSimulator {
379
758
  }
380
759
 
381
760
  const [minUsage, maxUsage] = this.presetConfig.usageRange;
761
+ const extraRng = this.rng.fork(2);
382
762
  for (const provider of this.fixedExtraProviders) {
383
- const usedPercent = clamp(this.rng.range(minUsage, maxUsage), 5, 98);
763
+ const usedPercent = clamp(extraRng.range(minUsage, maxUsage), 5, 98);
384
764
  this.providerUsage.set(provider.id, {
385
765
  planType: provider.label,
386
766
  limitReached: usedPercent > 95,
@@ -416,27 +796,13 @@ export class DemoSimulator {
416
796
  }
417
797
 
418
798
  private updateProviderUsage(now: number, tickRng: DemoRng) {
419
- const providerTotals = new Map<string, { tokens: number; cost: number; requests: number }>();
420
- for (const session of this.sessions) {
421
- for (const stream of session.streams) {
422
- const current = providerTotals.get(stream.providerId) ?? {
423
- tokens: 0,
424
- cost: 0,
425
- requests: 0,
426
- };
427
- const streamTokens = stream.tokens.input + stream.tokens.output;
428
- providerTotals.set(stream.providerId, {
429
- tokens: current.tokens + streamTokens,
430
- cost: current.cost + (stream.costUsd ?? 0),
431
- requests: current.requests + stream.requestCount,
432
- });
433
- }
434
- }
799
+ const providerTotals = this.aggregateProviderTotals();
435
800
 
436
801
  for (const providerId of this.fixedProviderIds) {
437
802
  const totals = providerTotals.get(providerId) ?? { tokens: 0, cost: 0, requests: 0 };
438
803
  const limit = PROVIDER_LIMITS[providerId] ?? { label: "Daily Tokens", windowMinutes: 1440 };
439
- const usedPercent = clamp((totals.tokens / 50_000) * 100, 5, 98);
804
+ const scaleDivisor = this.presetConfig.sessionCount * 12_000;
805
+ const usedPercent = clamp((totals.tokens / scaleDivisor) * 100, 5, 98);
440
806
  const limitReached = usedPercent > 95;
441
807
 
442
808
  const existing = this.providerUsage.get(providerId);
@@ -520,11 +886,7 @@ export class DemoSimulator {
520
886
  const usageEvents: UsageEventInsert[] = [];
521
887
  const activityMultiplier = this.presetConfig.activityMultiplier;
522
888
 
523
- this.sessions = this.sessions.map((session, sessionIndex) => {
524
- if (session.endedAt !== undefined) {
525
- return session;
526
- }
527
-
889
+ this.activeSessions = this.activeSessions.map((session, sessionIndex) => {
528
890
  const sessionRng = tickRng.fork(sessionIndex);
529
891
  const isIdle = sessionRng.next() < this.presetConfig.idleProbability;
530
892
  if (isIdle) {
@@ -593,7 +955,7 @@ export class DemoSimulator {
593
955
  this.updateProviderUsage(now, tickRng);
594
956
 
595
957
  return {
596
- sessions: this.sessions,
958
+ sessions: [...this.activeSessions, ...this.historicalSessions],
597
959
  providerUsage: new Map(this.providerUsage),
598
960
  usageEvents,
599
961
  };
@@ -623,10 +985,13 @@ export class DemoSimulator {
623
985
  requests: number;
624
986
  }> = [];
625
987
 
626
- const providerShares = [
627
- { id: "anthropic", share: 0.5 },
628
- { id: "openai", share: 0.25 },
629
- { id: "google-gemini", share: 0.15 },
988
+ const providerShares: Array<{ id: string; share: number }> = [
989
+ { id: "anthropic", share: 0.55 },
990
+ { id: "openai", share: 0.18 },
991
+ { id: "google-gemini", share: 0.12 },
992
+ { id: "xai", share: 0.06 },
993
+ { id: "github-copilot", share: 0.05 },
994
+ { id: "minimax", share: 0.04 },
630
995
  ];
631
996
 
632
997
  const providerRng = this.rng.fork(88888);
@@ -671,11 +1036,16 @@ export class DemoSimulator {
671
1036
  requests: number;
672
1037
  }> = [];
673
1038
 
674
- const modelShares = [
675
- { id: "claude-3-5-sonnet", share: 0.4 },
676
- { id: "claude-3-opus", share: 0.2 },
677
- { id: "gpt-4.1", share: 0.25 },
678
- { id: "gemini-2.0-pro", share: 0.15 },
1039
+ const modelShares: Array<{ id: string; share: number }> = [
1040
+ { id: "claude-opus-4-7", share: 0.34 },
1041
+ { id: "claude-sonnet-4-20250514", share: 0.16 },
1042
+ { id: "grok-code-fast-1", share: 0.12 },
1043
+ { id: "claude-3-5-sonnet-20241022", share: 0.1 },
1044
+ { id: "gpt-4.1", share: 0.08 },
1045
+ { id: "gemini-2.5-pro", share: 0.07 },
1046
+ { id: "claude-3-5-haiku-20241022", share: 0.05 },
1047
+ { id: "gpt-4o", share: 0.04 },
1048
+ { id: "minimax-m2.5-free", share: 0.04 },
679
1049
  ];
680
1050
 
681
1051
  const modelRng = this.rng.fork(77777);
@@ -720,11 +1090,10 @@ export class DemoSimulator {
720
1090
  requests: number;
721
1091
  }> = [];
722
1092
 
723
- const projectShares = [
724
- { path: "/Users/demo/workspace/tokentop", share: 0.45 },
725
- { path: "/Users/demo/workspace/webapp", share: 0.3 },
726
- { path: "/Users/demo/workspace/infra", share: 0.25 },
727
- ];
1093
+ const totalWeight = PROJECT_POOL.reduce((sum, p) => sum + p.weight, 0);
1094
+ const projectShares: Array<{ path: string; share: number }> = PROJECT_POOL.slice(0, 8).map(
1095
+ (p) => ({ path: p.path, share: p.weight / totalWeight }),
1096
+ );
728
1097
 
729
1098
  const projectRng = this.rng.fork(66666);
730
1099
 
@@ -759,13 +1128,12 @@ export class DemoSimulator {
759
1128
  generateHistoricalCostData(daysBack: number): Array<{ date: number; cost: number }> {
760
1129
  const historyRng = this.rng.fork(99999);
761
1130
  const now = Date.now();
762
- const msPerDay = 24 * 60 * 60 * 1000;
763
1131
  const result: Array<{ date: number; cost: number }> = [];
764
1132
 
765
- const baseCost = this.presetConfig.activityMultiplier * 2.5;
1133
+ const baseCost = this.presetConfig.historicalDailyCostBase;
766
1134
 
767
1135
  for (let i = daysBack - 1; i >= 0; i--) {
768
- const dayTimestamp = now - i * msPerDay;
1136
+ const dayTimestamp = now - i * MS_PER_DAY;
769
1137
  const dayRng = historyRng.fork(i);
770
1138
 
771
1139
  const isWeekend = new Date(dayTimestamp).getDay() % 6 === 0;