@tokentop/ttop 0.2.2 → 0.4.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 (160) hide show
  1. package/README.md +2 -5
  2. package/package.json +8 -5
  3. package/src/agents/aggregator.ts +39 -19
  4. package/src/agents/costing.ts +11 -9
  5. package/src/agents/index.ts +7 -9
  6. package/src/agents/types.ts +2 -2
  7. package/src/cli.ts +28 -23
  8. package/src/config/schema.test.ts +42 -40
  9. package/src/config/schema.ts +26 -26
  10. package/src/demo/simulator.ts +161 -122
  11. package/src/plugins/agents/index.ts +2 -2
  12. package/src/plugins/auth-sources.ts +42 -38
  13. package/src/plugins/lifecycle.ts +42 -38
  14. package/src/plugins/loader.test.ts +49 -49
  15. package/src/plugins/loader.ts +67 -63
  16. package/src/plugins/notification-bus.ts +21 -16
  17. package/src/plugins/notifications/index.ts +2 -2
  18. package/src/plugins/notifications/terminal-bell.ts +36 -23
  19. package/src/plugins/notifications/visual-flash.ts +31 -19
  20. package/src/plugins/npm-installer.ts +23 -25
  21. package/src/plugins/plugin-context-factory.ts +10 -10
  22. package/src/plugins/plugin-host.ts +12 -5
  23. package/src/plugins/providers/anthropic.ts +64 -57
  24. package/src/plugins/providers/antigravity.ts +81 -69
  25. package/src/plugins/providers/codex.ts +42 -42
  26. package/src/plugins/providers/gemini.ts +106 -87
  27. package/src/plugins/providers/github-copilot.ts +49 -45
  28. package/src/plugins/providers/index.ts +10 -12
  29. package/src/plugins/providers/minimax.ts +50 -44
  30. package/src/plugins/providers/openai-api.ts +82 -62
  31. package/src/plugins/providers/opencode-zen.ts +46 -39
  32. package/src/plugins/providers/perplexity.ts +55 -46
  33. package/src/plugins/providers/zai.ts +68 -57
  34. package/src/plugins/registry.ts +80 -71
  35. package/src/plugins/sandbox-guard.test.ts +45 -48
  36. package/src/plugins/sandbox-guard.ts +20 -25
  37. package/src/plugins/sandbox.ts +26 -48
  38. package/src/plugins/themes/catppuccin-latte.ts +29 -29
  39. package/src/plugins/themes/catppuccin-mocha.ts +29 -29
  40. package/src/plugins/themes/claude-code.ts +29 -29
  41. package/src/plugins/themes/dracula.ts +29 -29
  42. package/src/plugins/themes/github-light.ts +29 -29
  43. package/src/plugins/themes/gruvbox-dark.ts +29 -29
  44. package/src/plugins/themes/gruvbox-light.ts +29 -29
  45. package/src/plugins/themes/index.ts +15 -15
  46. package/src/plugins/themes/kanagawa.ts +29 -29
  47. package/src/plugins/themes/nord.ts +29 -29
  48. package/src/plugins/themes/one-dark.ts +29 -29
  49. package/src/plugins/themes/opencode.ts +31 -31
  50. package/src/plugins/themes/rose-pine-dawn.ts +29 -29
  51. package/src/plugins/themes/rose-pine.ts +29 -29
  52. package/src/plugins/themes/solarized-light.ts +29 -29
  53. package/src/plugins/themes/tokyo-night.ts +29 -29
  54. package/src/plugins/types/agent.ts +5 -5
  55. package/src/plugins/types/base.ts +35 -24
  56. package/src/plugins/types/index.ts +9 -9
  57. package/src/plugins/types/notification.ts +12 -12
  58. package/src/plugins/types/provider.ts +12 -8
  59. package/src/plugins/types/theme.ts +31 -23
  60. package/src/plugins/update-checker.ts +6 -6
  61. package/src/pricing/estimator.test.ts +39 -44
  62. package/src/pricing/estimator.ts +7 -13
  63. package/src/pricing/fallback.test.ts +30 -34
  64. package/src/pricing/fallback.ts +54 -27
  65. package/src/pricing/index.ts +18 -12
  66. package/src/pricing/models-dev.ts +6 -6
  67. package/src/storage/database.ts +56 -46
  68. package/src/storage/db.ts +12 -12
  69. package/src/storage/index.ts +44 -45
  70. package/src/storage/migrations/index.ts +2 -2
  71. package/src/storage/paths.ts +13 -13
  72. package/src/storage/persistence-service.ts +206 -0
  73. package/src/storage/repos/agentSessions.ts +33 -23
  74. package/src/storage/repos/providerSnapshots.ts +5 -5
  75. package/src/storage/repos/usageEvents.ts +40 -28
  76. package/src/storage/retention.ts +146 -0
  77. package/src/storage/types.ts +5 -5
  78. package/src/tui/App.tsx +180 -137
  79. package/src/tui/components/CommandPalette.tsx +46 -34
  80. package/src/tui/components/DebugConsole.tsx +39 -33
  81. package/src/tui/components/DebugPanel.tsx +314 -183
  82. package/src/tui/components/FooterHints.tsx +2 -2
  83. package/src/tui/components/GhostProviderCard.tsx +37 -35
  84. package/src/tui/components/Header.tsx +68 -35
  85. package/src/tui/components/HelpOverlay.tsx +99 -29
  86. package/src/tui/components/InlineGauge.tsx +10 -7
  87. package/src/tui/components/InlineSparkline.tsx +17 -11
  88. package/src/tui/components/KpiStrip.tsx +128 -98
  89. package/src/tui/components/LimitGauge.tsx +96 -59
  90. package/src/tui/components/ModalBackdrop.tsx +4 -4
  91. package/src/tui/components/ProviderAggregateStrip.tsx +69 -38
  92. package/src/tui/components/ProviderCard.tsx +232 -226
  93. package/src/tui/components/ProviderDetailPanel.tsx +133 -58
  94. package/src/tui/components/ProviderLimitsPanel.tsx +126 -89
  95. package/src/tui/components/ProvidersList.tsx +215 -126
  96. package/src/tui/components/SessionDetailsDrawer.tsx +191 -94
  97. package/src/tui/components/SessionsTable.tsx +320 -169
  98. package/src/tui/components/SettingsModal.tsx +593 -269
  99. package/src/tui/components/SidebarBreakdown.tsx +30 -16
  100. package/src/tui/components/Skeleton.tsx +24 -17
  101. package/src/tui/components/SmartSidebar.tsx +360 -258
  102. package/src/tui/components/Sparkline.tsx +64 -52
  103. package/src/tui/components/Spinner.tsx +3 -3
  104. package/src/tui/components/StatusBar.tsx +27 -27
  105. package/src/tui/components/ThemePicker.tsx +67 -66
  106. package/src/tui/components/Toast.tsx +9 -6
  107. package/src/tui/components/UsageGauge.tsx +10 -13
  108. package/src/tui/components/index.ts +5 -5
  109. package/src/tui/contexts/AgentSessionContext.tsx +164 -151
  110. package/src/tui/contexts/ConfigContext.tsx +15 -13
  111. package/src/tui/contexts/DashboardRuntimeContext.tsx +26 -26
  112. package/src/tui/contexts/DemoModeContext.tsx +22 -15
  113. package/src/tui/contexts/DrawerContext.tsx +11 -9
  114. package/src/tui/contexts/InputContext.tsx +2 -2
  115. package/src/tui/contexts/LogContext.tsx +62 -47
  116. package/src/tui/contexts/PluginContext.tsx +238 -183
  117. package/src/tui/contexts/RealTimeActivityContext.tsx +70 -36
  118. package/src/tui/contexts/StorageContext.tsx +95 -149
  119. package/src/tui/contexts/ThemeContext.tsx +21 -16
  120. package/src/tui/contexts/TimeWindowContext.tsx +72 -61
  121. package/src/tui/contexts/ToastContext.tsx +10 -7
  122. package/src/tui/contexts/index.ts +9 -5
  123. package/src/tui/createApp.tsx +8 -12
  124. package/src/tui/debug/captureFrame.ts +25 -28
  125. package/src/tui/debug/snapshot.tsx +179 -162
  126. package/src/tui/driver/assertions.ts +30 -22
  127. package/src/tui/driver/cli.ts +231 -221
  128. package/src/tui/driver/coverage.ts +27 -44
  129. package/src/tui/driver/demo-snapshot.ts +33 -33
  130. package/src/tui/driver/diff.ts +26 -32
  131. package/src/tui/driver/driver.ts +43 -48
  132. package/src/tui/driver/index.ts +36 -40
  133. package/src/tui/driver/recorder.ts +37 -48
  134. package/src/tui/driver/test-workflow.ts +51 -51
  135. package/src/tui/driver/test.ts +25 -25
  136. package/src/tui/hooks/index.ts +24 -20
  137. package/src/tui/hooks/useAnimatedValue.ts +13 -18
  138. package/src/tui/hooks/useDashboardKeyboard.ts +118 -98
  139. package/src/tui/hooks/useDashboardState.ts +26 -24
  140. package/src/tui/hooks/useEmaActivity.ts +82 -74
  141. package/src/tui/hooks/useEntranceAnimation.ts +11 -11
  142. package/src/tui/hooks/useExitAnimation.ts +7 -3
  143. package/src/tui/hooks/usePulse.ts +4 -4
  144. package/src/tui/hooks/useSafeRenderer.ts +5 -5
  145. package/src/tui/hooks/useSessionEnrichedProviders.ts +150 -0
  146. package/src/tui/hooks/useValueFlash.ts +10 -14
  147. package/src/tui/index.tsx +29 -6
  148. package/src/tui/shutdown.ts +19 -0
  149. package/src/tui/utils/providerColor.ts +1 -1
  150. package/src/tui/views/Dashboard.tsx +164 -102
  151. package/src/tui/views/HistoricalTrendsView.tsx +579 -250
  152. package/src/tui/views/ProjectsView.tsx +495 -243
  153. package/src/tui/views/RealTimeDashboard.tsx +176 -116
  154. package/src/tui/views/SettingsView.tsx +225 -189
  155. package/src/tui/views/index.ts +2 -2
  156. package/src/types/opentui-test-utils.d.ts +7 -7
  157. package/src/utils/clipboard.ts +29 -29
  158. package/src/utils/currency.test.ts +53 -53
  159. package/src/utils/currency.ts +35 -32
  160. package/src/version.ts +1 -1
package/src/storage/db.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { Database } from 'bun:sqlite';
2
- import * as fs from 'fs/promises';
3
- import { PATHS } from './paths.ts';
4
- import { applyMigrations } from './migrations/index.ts';
1
+ import { Database } from "bun:sqlite";
2
+ import * as fs from "fs/promises";
3
+ import { applyMigrations } from "./migrations/index.ts";
4
+ import { PATHS } from "./paths.ts";
5
5
 
6
6
  let db: Database | null = null;
7
7
  let currentAppRunId: number | null = null;
@@ -13,10 +13,10 @@ export async function initDatabase(): Promise<Database> {
13
13
 
14
14
  db = new Database(PATHS.data.database, { create: true });
15
15
 
16
- db.exec('PRAGMA journal_mode = WAL');
17
- db.exec('PRAGMA synchronous = NORMAL');
18
- db.exec('PRAGMA foreign_keys = ON');
19
- db.exec('PRAGMA busy_timeout = 2000');
16
+ db.exec("PRAGMA journal_mode = WAL");
17
+ db.exec("PRAGMA synchronous = NORMAL");
18
+ db.exec("PRAGMA foreign_keys = ON");
19
+ db.exec("PRAGMA busy_timeout = 2000");
20
20
 
21
21
  applyMigrations(db);
22
22
 
@@ -27,9 +27,9 @@ export async function initDatabase(): Promise<Database> {
27
27
 
28
28
  const result = stmt.run(
29
29
  Date.now(),
30
- process.env.npm_package_version ?? '0.0.0',
30
+ process.env.npm_package_version ?? "0.0.0",
31
31
  process.pid,
32
- require('os').hostname()
32
+ require("os").hostname(),
33
33
  );
34
34
 
35
35
  currentAppRunId = Number(result.lastInsertRowid);
@@ -39,7 +39,7 @@ export async function initDatabase(): Promise<Database> {
39
39
 
40
40
  export function getDatabase(): Database {
41
41
  if (!db) {
42
- throw new Error('Database not initialized. Call initDatabase() first.');
42
+ throw new Error("Database not initialized. Call initDatabase() first.");
43
43
  }
44
44
  return db;
45
45
  }
@@ -50,7 +50,7 @@ export function getAppRunId(): number | null {
50
50
 
51
51
  export function closeDatabase(): void {
52
52
  if (db && currentAppRunId) {
53
- db.prepare('UPDATE app_runs SET ended_at = ? WHERE id = ?').run(Date.now(), currentAppRunId);
53
+ db.prepare("UPDATE app_runs SET ended_at = ? WHERE id = ?").run(Date.now(), currentAppRunId);
54
54
  }
55
55
  if (db) {
56
56
  db.close();
@@ -1,66 +1,65 @@
1
- export { PATHS } from './paths.ts';
2
-
3
1
  export {
4
- initDatabase,
5
- getDatabase,
6
2
  closeDatabase,
7
- isDatabaseInitialized,
8
3
  getAppRunId,
9
- } from './db.ts';
10
-
4
+ getDatabase,
5
+ initDatabase,
6
+ isDatabaseInitialized,
7
+ } from "./db.ts";
8
+ export { PATHS } from "./paths.ts";
9
+ export type { LatestStreamTotals } from "./repos/agentSessions.ts";
11
10
  export {
11
+ getAgentSession,
12
+ getLatestStreamTotalsForAllSessions,
13
+ getRecentSessions,
14
+ getSessionsByProject,
15
+ insertAgentSessionSnapshot,
16
+ upsertAgentSession,
17
+ } from "./repos/agentSessions.ts";
18
+ export {
19
+ getLatestProviderSnapshot,
12
20
  insertProviderSnapshot,
13
21
  insertProviderSnapshotBatch,
14
22
  queryProviderSnapshots,
15
- getLatestProviderSnapshot,
16
- } from './repos/providerSnapshots.ts';
17
-
23
+ } from "./repos/providerSnapshots.ts";
24
+ export type {
25
+ ModelDailyCost,
26
+ ProjectDailyCost,
27
+ ProviderDailyCost,
28
+ SessionActivityPoint,
29
+ } from "./repos/usageEvents.ts";
18
30
  export {
31
+ calculateBurnRate,
32
+ getSessionActivityTimeline,
33
+ getTotalUsageInWindow,
19
34
  insertUsageEvent,
20
35
  insertUsageEventBatch,
21
- queryUsageTimeSeries,
22
- queryProviderDailyCosts,
23
36
  queryModelDailyCosts,
24
37
  queryProjectDailyCosts,
25
- calculateBurnRate,
26
- getTotalUsageInWindow,
27
- getSessionActivityTimeline,
28
- } from './repos/usageEvents.ts';
29
-
30
- export type { SessionActivityPoint, ProviderDailyCost, ModelDailyCost, ProjectDailyCost } from './repos/usageEvents.ts';
31
-
32
- export {
33
- upsertAgentSession,
34
- insertAgentSessionSnapshot,
35
- getAgentSession,
36
- getRecentSessions,
37
- getSessionsByProject,
38
- getLatestStreamTotalsForAllSessions,
39
- } from './repos/agentSessions.ts';
40
-
41
- export type { LatestStreamTotals } from './repos/agentSessions.ts';
38
+ queryProviderDailyCosts,
39
+ queryUsageTimeSeries,
40
+ } from "./repos/usageEvents.ts";
42
41
 
43
42
  export type {
44
- UsageEventSource,
45
- SessionStatus,
46
- CostSource,
47
- PricingSource,
48
- AppRunRow,
49
- ProviderSnapshotRow,
50
- ProviderSnapshotInsert,
51
43
  AgentSessionDim,
52
- AgentSessionUpsert,
53
- AgentSessionSnapshotRow,
54
44
  AgentSessionSnapshotInsert,
45
+ AgentSessionSnapshotRow,
55
46
  AgentSessionStreamSnapshotRow,
56
- UsageEventRow,
57
- UsageEventInsert,
58
- HourlyAggregateRow,
47
+ AgentSessionUpsert,
48
+ AppRunRow,
49
+ CostSource,
59
50
  DailyAggregateRow,
60
- TimeSeriesPoint,
51
+ HourlyAggregateRow,
52
+ PricingSource,
53
+ ProviderSnapshotInsert,
54
+ ProviderSnapshotRow,
55
+ SessionStatus,
56
+ StreamTotals,
61
57
  TimeSeriesFilters,
58
+ TimeSeriesPoint,
59
+ UsageEventInsert,
60
+ UsageEventRow,
61
+ UsageEventSource,
62
62
  UsageQueryOptions,
63
- StreamTotals,
64
- } from './types.ts';
63
+ } from "./types.ts";
65
64
 
66
- export { computeStreamDelta } from './types.ts';
65
+ export { computeStreamDelta } from "./types.ts";
@@ -1,4 +1,4 @@
1
- import type { Database } from 'bun:sqlite';
1
+ import type { Database } from "bun:sqlite";
2
2
 
3
3
  export interface Migration {
4
4
  version: number;
@@ -167,7 +167,7 @@ export const migrations: Migration[] = [
167
167
  ];
168
168
 
169
169
  export function getCurrentVersion(db: Database): number {
170
- const result = db.prepare('PRAGMA user_version').get() as { user_version: number } | null;
170
+ const result = db.prepare("PRAGMA user_version").get() as { user_version: number } | null;
171
171
  return result?.user_version ?? 0;
172
172
  }
173
173
 
@@ -1,24 +1,24 @@
1
- import * as path from 'path';
2
- import * as os from 'os';
1
+ import * as os from "os";
2
+ import * as path from "path";
3
3
 
4
4
  const HOME = os.homedir();
5
5
 
6
6
  export const PATHS = {
7
7
  config: {
8
- dir: path.join(HOME, '.config/tokentop'),
9
- file: path.join(HOME, '.config/tokentop/config.json'),
10
- fileJsonc: path.join(HOME, '.config/tokentop/config.jsonc'),
11
- plugins: path.join(HOME, '.config/tokentop/plugins'),
8
+ dir: path.join(HOME, ".config/tokentop"),
9
+ file: path.join(HOME, ".config/tokentop/config.json"),
10
+ fileJsonc: path.join(HOME, ".config/tokentop/config.jsonc"),
11
+ plugins: path.join(HOME, ".config/tokentop/plugins"),
12
12
  },
13
13
  data: {
14
- dir: path.join(HOME, '.local/share/tokentop'),
15
- database: path.join(HOME, '.local/share/tokentop/usage.db'),
16
- sessions: path.join(HOME, '.local/share/tokentop/sessions'),
17
- cache: path.join(HOME, '.local/share/tokentop/cache'),
18
- logs: path.join(HOME, '.local/share/tokentop/logs'),
14
+ dir: path.join(HOME, ".local/share/tokentop"),
15
+ database: path.join(HOME, ".local/share/tokentop/usage.db"),
16
+ sessions: path.join(HOME, ".local/share/tokentop/sessions"),
17
+ cache: path.join(HOME, ".local/share/tokentop/cache"),
18
+ logs: path.join(HOME, ".local/share/tokentop/logs"),
19
19
  },
20
20
  cache: {
21
- dir: path.join(HOME, '.cache/tokentop'),
22
- nodeModules: path.join(HOME, '.cache/tokentop/node_modules'),
21
+ dir: path.join(HOME, ".cache/tokentop"),
22
+ nodeModules: path.join(HOME, ".cache/tokentop/node_modules"),
23
23
  },
24
24
  } as const;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Module-level session persistence service.
3
+ *
4
+ * All mutable state is stored on globalThis to survive bun --hot HMR
5
+ * reloads and React component remounts. This decouples persistence
6
+ * from React's lifecycle entirely.
7
+ *
8
+ * Call persistSessions() on every poll tick (1s). The service internally:
9
+ * Gate 1: Skips sessions whose usage data hasn't changed (fingerprint)
10
+ * Gate 2: Throttles writes to at most once per SNAPSHOT_INTERVAL_MS
11
+ *
12
+ * Both gates update their state only on successful writes, ensuring
13
+ * deferred writes when the throttle expires.
14
+ */
15
+
16
+ import { isDatabaseInitialized } from "./db.ts";
17
+ import {
18
+ getLatestStreamTotalsForAllSessions,
19
+ insertAgentSessionSnapshot,
20
+ upsertAgentSession,
21
+ } from "./repos/agentSessions.ts";
22
+ import { insertUsageEventBatch } from "./repos/usageEvents.ts";
23
+ import type {
24
+ AgentSessionSnapshotInsert,
25
+ AgentSessionStreamSnapshotRow,
26
+ AgentSessionUpsert,
27
+ StreamTotals,
28
+ UsageEventInsert,
29
+ } from "./types.ts";
30
+ import { computeStreamDelta } from "./types.ts";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Configuration
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /** Minimum interval between DB writes for the same session */
37
+ const SNAPSHOT_INTERVAL_MS = 300_000; // 5 minutes
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Global state (survives bun --hot HMR and React remounts)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ interface PersistenceState {
44
+ /** Timestamp of last successful DB write per session key */
45
+ lastWriteTimestamps: Map<string, number>;
46
+ /** Usage-based fingerprint per session key (updated only on successful write) */
47
+ sessionFingerprints: Map<string, string>;
48
+ /** Previous stream totals for delta computation */
49
+ previousStreamTotals: Map<string, StreamTotals>;
50
+ /** Whether startup seeding has completed */
51
+ seeded: boolean;
52
+ }
53
+
54
+ declare global {
55
+ // eslint-disable-next-line no-var
56
+ var __tokentopPersistence: PersistenceState | undefined;
57
+ }
58
+
59
+ function getState(): PersistenceState {
60
+ if (!globalThis.__tokentopPersistence) {
61
+ globalThis.__tokentopPersistence = {
62
+ lastWriteTimestamps: new Map(),
63
+ sessionFingerprints: new Map(),
64
+ previousStreamTotals: new Map(),
65
+ seeded: false,
66
+ };
67
+ }
68
+ return globalThis.__tokentopPersistence;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Fingerprinting
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Compute a fingerprint based on usage data only (not lastActivityAt).
77
+ * This ensures inactive sessions with stable usage don't trigger writes,
78
+ * while active sessions that consume tokens do.
79
+ */
80
+ function computeFingerprint(snapshot: Omit<AgentSessionSnapshotInsert, "agentSessionId">): string {
81
+ return `${snapshot.totalCostUsd}:${snapshot.requestCount}:${snapshot.totalInputTokens}:${snapshot.totalOutputTokens}`;
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Public API
86
+ // ---------------------------------------------------------------------------
87
+
88
+ export interface SessionPersistData {
89
+ session: AgentSessionUpsert;
90
+ snapshot: Omit<AgentSessionSnapshotInsert, "agentSessionId">;
91
+ streams: Omit<AgentSessionStreamSnapshotRow, "agentSessionSnapshotId">[];
92
+ }
93
+
94
+ /**
95
+ * Persist session data to the database with per-session throttling.
96
+ *
97
+ * Safe to call on every poll tick (every 1s). The service internally:
98
+ * 1. Skips sessions whose usage data hasn't changed (fingerprint gate)
99
+ * 2. Throttles writes to at most once per SNAPSHOT_INTERVAL_MS per session
100
+ *
101
+ * @returns Number of sessions actually written to DB
102
+ */
103
+ export function persistSessions(sessions: SessionPersistData[]): number {
104
+ if (!isDatabaseInitialized()) return 0;
105
+
106
+ const state = getState();
107
+ const now = Date.now();
108
+ let persistedCount = 0;
109
+
110
+ for (const { session, snapshot, streams } of sessions) {
111
+ const sessionKey = `${session.agentId}:${session.sessionId}`;
112
+
113
+ // Gate 1: Skip sessions whose usage data hasn't changed since last write
114
+ const fp = computeFingerprint(snapshot);
115
+ if (state.sessionFingerprints.get(sessionKey) === fp) continue;
116
+
117
+ // Gate 2: Throttle writes to once per interval per session
118
+ const lastTs = state.lastWriteTimestamps.get(sessionKey) ?? 0;
119
+ if (now - lastTs < SNAPSHOT_INTERVAL_MS) continue;
120
+
121
+ // Both gates passed — write to DB
122
+ try {
123
+ const agentSessionId = upsertAgentSession(session);
124
+ insertAgentSessionSnapshot({ ...snapshot, agentSessionId }, streams);
125
+
126
+ // Compute and emit usage event deltas
127
+ const usageEvents: UsageEventInsert[] = [];
128
+ for (const stream of streams) {
129
+ const streamKey = `${sessionKey}:${stream.provider}:${stream.model}`;
130
+ const current: StreamTotals = {
131
+ inputTokens: stream.inputTokens,
132
+ outputTokens: stream.outputTokens,
133
+ cacheReadTokens: stream.cacheReadTokens,
134
+ cacheWriteTokens: stream.cacheWriteTokens,
135
+ costUsd: stream.costUsd,
136
+ requestCount: stream.requestCount,
137
+ };
138
+
139
+ const previous = state.previousStreamTotals.get(streamKey);
140
+ const delta = computeStreamDelta(current, previous);
141
+
142
+ if (delta) {
143
+ usageEvents.push({
144
+ timestamp: now,
145
+ source: "agent",
146
+ provider: stream.provider,
147
+ model: stream.model,
148
+ agentId: session.agentId,
149
+ sessionId: session.sessionId,
150
+ projectPath: session.projectPath ?? null,
151
+ inputTokens: delta.inputTokens,
152
+ outputTokens: delta.outputTokens,
153
+ cacheReadTokens: delta.cacheReadTokens,
154
+ cacheWriteTokens: delta.cacheWriteTokens,
155
+ costUsd: delta.costUsd,
156
+ requestCount: delta.requestCount,
157
+ pricingSource: stream.pricingSource ?? null,
158
+ });
159
+ }
160
+
161
+ state.previousStreamTotals.set(streamKey, current);
162
+ }
163
+
164
+ if (usageEvents.length > 0) {
165
+ insertUsageEventBatch(usageEvents);
166
+ }
167
+
168
+ // Update tracking state ONLY on successful write
169
+ state.lastWriteTimestamps.set(sessionKey, now);
170
+ state.sessionFingerprints.set(sessionKey, fp);
171
+ persistedCount++;
172
+ } catch (err) {
173
+ console.error("Failed to persist session:", err);
174
+ }
175
+ }
176
+
177
+ return persistedCount;
178
+ }
179
+
180
+ /**
181
+ * Seed previous stream totals from the database.
182
+ * Called once at startup after DB initialization to ensure
183
+ * accurate delta computation from the first persistence call.
184
+ */
185
+ export function seedPreviousTotals(): void {
186
+ const state = getState();
187
+ if (state.seeded) return;
188
+
189
+ try {
190
+ const latestTotals = getLatestStreamTotalsForAllSessions();
191
+ for (const row of latestTotals) {
192
+ const streamKey = `${row.agentId}:${row.sessionId}:${row.provider}:${row.model}`;
193
+ state.previousStreamTotals.set(streamKey, {
194
+ inputTokens: row.inputTokens,
195
+ outputTokens: row.outputTokens,
196
+ cacheReadTokens: row.cacheReadTokens,
197
+ cacheWriteTokens: row.cacheWriteTokens,
198
+ costUsd: row.costUsd,
199
+ requestCount: row.requestCount,
200
+ });
201
+ }
202
+ state.seeded = true;
203
+ } catch (err) {
204
+ console.error("Failed to seed previous totals from DB:", err);
205
+ }
206
+ }
@@ -1,10 +1,10 @@
1
- import { getDatabase } from '../db.ts';
1
+ import { getDatabase } from "../db.ts";
2
2
  import type {
3
3
  AgentSessionDim,
4
- AgentSessionUpsert,
5
4
  AgentSessionSnapshotInsert,
6
5
  AgentSessionStreamSnapshotRow,
7
- } from '../types.ts';
6
+ AgentSessionUpsert,
7
+ } from "../types.ts";
8
8
 
9
9
  const UPSERT_SESSION_SQL = `
10
10
  INSERT INTO agent_sessions (agent_id, session_id, project_path, started_at, first_seen_at, last_seen_at)
@@ -36,21 +36,23 @@ export function upsertAgentSession(session: AgentSessionUpsert): number {
36
36
  const db = getDatabase();
37
37
  const now = Date.now();
38
38
 
39
- const row = db.prepare(UPSERT_SESSION_SQL).get(
40
- session.agentId,
41
- session.sessionId,
42
- session.projectPath ?? null,
43
- session.startedAt ?? null,
44
- session.firstSeenAt ?? now,
45
- session.lastSeenAt
46
- ) as { id: number };
39
+ const row = db
40
+ .prepare(UPSERT_SESSION_SQL)
41
+ .get(
42
+ session.agentId,
43
+ session.sessionId,
44
+ session.projectPath ?? null,
45
+ session.startedAt ?? null,
46
+ session.firstSeenAt ?? now,
47
+ session.lastSeenAt,
48
+ ) as { id: number };
47
49
 
48
50
  return row.id;
49
51
  }
50
52
 
51
53
  export function insertAgentSessionSnapshot(
52
54
  snapshot: AgentSessionSnapshotInsert,
53
- streams: Omit<AgentSessionStreamSnapshotRow, 'agentSessionSnapshotId'>[]
55
+ streams: Omit<AgentSessionStreamSnapshotRow, "agentSessionSnapshotId">[],
54
56
  ): number {
55
57
  const db = getDatabase();
56
58
 
@@ -70,7 +72,7 @@ export function insertAgentSessionSnapshot(
70
72
  snapshot.totalCacheReadTokens,
71
73
  snapshot.totalCacheWriteTokens,
72
74
  snapshot.totalCostUsd,
73
- snapshot.requestCount
75
+ snapshot.requestCount,
74
76
  );
75
77
 
76
78
  snapshotId = Number(result.lastInsertRowid);
@@ -86,7 +88,7 @@ export function insertAgentSessionSnapshot(
86
88
  stream.cacheWriteTokens,
87
89
  stream.costUsd,
88
90
  stream.requestCount,
89
- stream.pricingSource ?? null
91
+ stream.pricingSource ?? null,
90
92
  );
91
93
  }
92
94
  })();
@@ -97,9 +99,11 @@ export function insertAgentSessionSnapshot(
97
99
  export function getAgentSession(agentId: string, sessionId: string): AgentSessionDim | null {
98
100
  const db = getDatabase();
99
101
 
100
- const row = db.prepare(`
102
+ const row = db
103
+ .prepare(`
101
104
  SELECT * FROM agent_sessions WHERE agent_id = ? AND session_id = ?
102
- `).get(agentId, sessionId) as DbSessionRow | null;
105
+ `)
106
+ .get(agentId, sessionId) as DbSessionRow | null;
103
107
 
104
108
  return row ? mapSessionRow(row) : null;
105
109
  }
@@ -107,11 +111,13 @@ export function getAgentSession(agentId: string, sessionId: string): AgentSessio
107
111
  export function getRecentSessions(limit = 50): AgentSessionDim[] {
108
112
  const db = getDatabase();
109
113
 
110
- const rows = db.prepare(`
114
+ const rows = db
115
+ .prepare(`
111
116
  SELECT * FROM agent_sessions
112
117
  ORDER BY last_seen_at DESC
113
118
  LIMIT ?
114
- `).all(limit) as DbSessionRow[];
119
+ `)
120
+ .all(limit) as DbSessionRow[];
115
121
 
116
122
  return rows.map(mapSessionRow);
117
123
  }
@@ -119,12 +125,14 @@ export function getRecentSessions(limit = 50): AgentSessionDim[] {
119
125
  export function getSessionsByProject(projectPath: string, limit = 50): AgentSessionDim[] {
120
126
  const db = getDatabase();
121
127
 
122
- const rows = db.prepare(`
128
+ const rows = db
129
+ .prepare(`
123
130
  SELECT * FROM agent_sessions
124
131
  WHERE project_path = ?
125
132
  ORDER BY last_seen_at DESC
126
133
  LIMIT ?
127
- `).all(projectPath, limit) as DbSessionRow[];
134
+ `)
135
+ .all(projectPath, limit) as DbSessionRow[];
128
136
 
129
137
  return rows.map(mapSessionRow);
130
138
  }
@@ -167,7 +175,8 @@ export interface LatestStreamTotals {
167
175
  export function getLatestStreamTotalsForAllSessions(): LatestStreamTotals[] {
168
176
  const db = getDatabase();
169
177
 
170
- const rows = db.prepare(`
178
+ const rows = db
179
+ .prepare(`
171
180
  SELECT
172
181
  s.agent_id, s.session_id,
173
182
  ss.provider, ss.model,
@@ -182,7 +191,8 @@ export function getLatestStreamTotalsForAllSessions(): LatestStreamTotals[] {
182
191
  FROM agent_session_snapshots snap2
183
192
  GROUP BY snap2.agent_session_id
184
193
  )
185
- `).all() as Array<{
194
+ `)
195
+ .all() as Array<{
186
196
  agent_id: string;
187
197
  session_id: string;
188
198
  provider: string;
@@ -195,7 +205,7 @@ export function getLatestStreamTotalsForAllSessions(): LatestStreamTotals[] {
195
205
  request_count: number;
196
206
  }>;
197
207
 
198
- return rows.map(r => ({
208
+ return rows.map((r) => ({
199
209
  agentId: r.agent_id,
200
210
  sessionId: r.session_id,
201
211
  provider: r.provider,
@@ -1,5 +1,5 @@
1
- import { getDatabase } from '../db.ts';
2
- import type { ProviderSnapshotInsert, ProviderSnapshotRow, CostSource } from '../types.ts';
1
+ import { getDatabase } from "../db.ts";
2
+ import type { CostSource, ProviderSnapshotInsert, ProviderSnapshotRow } from "../types.ts";
3
3
 
4
4
  const INSERT_SQL = `
5
5
  INSERT INTO provider_snapshots (
@@ -36,7 +36,7 @@ export function insertProviderSnapshot(snapshot: ProviderSnapshotInsert): number
36
36
  snapshot.tokensOutput ?? null,
37
37
  snapshot.costUsd ?? null,
38
38
  snapshot.costSource ?? null,
39
- snapshot.rawJson ?? null
39
+ snapshot.rawJson ?? null,
40
40
  );
41
41
 
42
42
  return Number(result.lastInsertRowid);
@@ -60,7 +60,7 @@ export function insertProviderSnapshotBatch(snapshots: ProviderSnapshotInsert[])
60
60
  s.tokensOutput ?? null,
61
61
  s.costUsd ?? null,
62
62
  s.costSource ?? null,
63
- s.rawJson ?? null
63
+ s.rawJson ?? null,
64
64
  );
65
65
  }
66
66
  });
@@ -72,7 +72,7 @@ export function queryProviderSnapshots(
72
72
  provider: string,
73
73
  startMs: number,
74
74
  endMs: number,
75
- limit = 100
75
+ limit = 100,
76
76
  ): ProviderSnapshotRow[] {
77
77
  const db = getDatabase();
78
78
  const rows = db.prepare(QUERY_BY_PROVIDER_SQL).all(provider, startMs, endMs, limit) as DbRow[];