@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
@@ -1,10 +1,18 @@
1
- import { createContext, useContext, useState, useEffect, useRef, useCallback, type ReactNode } from 'react';
2
- import { pluginRegistry } from '@/plugins/registry.ts';
3
- import type { ActivityUpdate } from '@/plugins/types/agent.ts';
4
- import { createPluginContext } from '@/plugins/plugin-context-factory.ts';
5
- import { usePlugins } from './PluginContext.tsx';
6
- import { useLogs } from './LogContext.tsx';
7
- import { useDemoMode } from './DemoModeContext.tsx';
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { createPluginContext } from "@/plugins/plugin-context-factory.ts";
11
+ import { pluginRegistry } from "@/plugins/registry.ts";
12
+ import type { ActivityUpdate } from "@/plugins/types/agent.ts";
13
+ import { useDemoMode } from "./DemoModeContext.tsx";
14
+ import { useLogs } from "./LogContext.tsx";
15
+ import { usePlugins } from "./PluginContext.tsx";
8
16
 
9
17
  type ActivityListener = (delta: number, timestamp: number) => void;
10
18
 
@@ -16,14 +24,22 @@ interface RealTimeActivityContextValue {
16
24
 
17
25
  const RealTimeActivityContext = createContext<RealTimeActivityContextValue | null>(null);
18
26
 
27
+ const LAST_SEEN_MAX_AGE_MS = 5 * 60_000;
28
+ const LAST_SEEN_MAX_ENTRIES = 10_000;
29
+
30
+ interface LastSeenEntry {
31
+ tokens: number;
32
+ updatedAt: number;
33
+ }
34
+
19
35
  export function RealTimeActivityProvider({ children }: { children: ReactNode }) {
20
36
  const [lastActivityAt, setLastActivityAt] = useState<number | null>(null);
21
37
  const [isWatching, setIsWatching] = useState(false);
22
38
  const { isInitialized: pluginsInitialized } = usePlugins();
23
39
  const { debug, info } = useLogs();
24
40
  const { demoMode } = useDemoMode();
25
-
26
- const lastSeenRef = useRef<Map<string, number>>(new Map());
41
+
42
+ const lastSeenRef = useRef<Map<string, LastSeenEntry>>(new Map());
27
43
  const listenersRef = useRef<Set<ActivityListener>>(new Set());
28
44
  const cleanupRef = useRef<(() => void) | null>(null);
29
45
 
@@ -34,35 +50,39 @@ export function RealTimeActivityProvider({ children }: { children: ReactNode })
34
50
  };
35
51
  }, []);
36
52
 
37
- const handleActivityUpdate = useCallback((update: ActivityUpdate) => {
38
- const key = `${update.sessionId}:${update.messageId}`;
39
- const prevTokens = lastSeenRef.current.get(key) ?? 0;
40
- const newTokens = update.tokens.input + update.tokens.output + (update.tokens.reasoning ?? 0);
41
- const delta = Math.max(0, newTokens - prevTokens);
42
-
43
- if (delta > 0) {
44
- lastSeenRef.current.set(key, newTokens);
45
- setLastActivityAt(update.timestamp);
46
- debug(`Real-time activity: +${delta} tokens`, { sessionId: update.sessionId }, 'realtime');
47
-
48
- for (const listener of listenersRef.current) {
49
- listener(delta, update.timestamp);
53
+ const handleActivityUpdate = useCallback(
54
+ (update: ActivityUpdate) => {
55
+ const key = `${update.sessionId}:${update.messageId}`;
56
+ const prev = lastSeenRef.current.get(key);
57
+ const prevTokens = prev?.tokens ?? 0;
58
+ const newTokens = update.tokens.input + update.tokens.output + (update.tokens.reasoning ?? 0);
59
+ const delta = Math.max(0, newTokens - prevTokens);
60
+
61
+ if (delta > 0) {
62
+ lastSeenRef.current.set(key, { tokens: newTokens, updatedAt: Date.now() });
63
+ setLastActivityAt(update.timestamp);
64
+ debug(`Real-time activity: +${delta} tokens`, { sessionId: update.sessionId }, "realtime");
65
+
66
+ for (const listener of listenersRef.current) {
67
+ listener(delta, update.timestamp);
68
+ }
50
69
  }
51
- }
52
- }, [debug]);
70
+ },
71
+ [debug],
72
+ );
53
73
 
54
74
  useEffect(() => {
55
75
  if (demoMode || !pluginsInitialized) return;
56
76
 
57
- const agentPlugins = pluginRegistry.getAll('agent');
77
+ const agentPlugins = pluginRegistry.getAll("agent");
58
78
  const cleanups: (() => void)[] = [];
59
79
 
60
80
  for (const plugin of agentPlugins) {
61
81
  if (plugin.capabilities.realTimeTracking && plugin.startActivityWatch) {
62
82
  const ctx = createPluginContext(plugin.id, plugin.permissions);
63
- info(`Starting real-time activity watch for ${plugin.id}`, undefined, 'realtime');
83
+ info(`Starting real-time activity watch for ${plugin.id}`, undefined, "realtime");
64
84
  plugin.startActivityWatch(ctx, handleActivityUpdate);
65
-
85
+
66
86
  if (plugin.stopActivityWatch) {
67
87
  const stopFn = plugin.stopActivityWatch.bind(plugin, ctx);
68
88
  cleanups.push(stopFn);
@@ -73,7 +93,9 @@ export function RealTimeActivityProvider({ children }: { children: ReactNode })
73
93
  if (cleanups.length > 0) {
74
94
  setIsWatching(true);
75
95
  cleanupRef.current = () => {
76
- cleanups.forEach(fn => fn());
96
+ cleanups.forEach((fn) => {
97
+ fn();
98
+ });
77
99
  setIsWatching(false);
78
100
  };
79
101
  }
@@ -88,14 +110,28 @@ export function RealTimeActivityProvider({ children }: { children: ReactNode })
88
110
 
89
111
  useEffect(() => {
90
112
  const interval = setInterval(() => {
113
+ const map = lastSeenRef.current;
91
114
  const now = Date.now();
92
- if (lastActivityAt && now - lastActivityAt > 60000) {
93
- lastSeenRef.current.clear();
115
+
116
+ for (const [key, entry] of map) {
117
+ if (now - entry.updatedAt > LAST_SEEN_MAX_AGE_MS) {
118
+ map.delete(key);
119
+ }
94
120
  }
95
- }, 30000);
121
+
122
+ if (map.size > LAST_SEEN_MAX_ENTRIES) {
123
+ const excess = map.size - LAST_SEEN_MAX_ENTRIES;
124
+ const keys = map.keys();
125
+ for (let i = 0; i < excess; i++) {
126
+ const next = keys.next();
127
+ if (next.done) break;
128
+ map.delete(next.value);
129
+ }
130
+ }
131
+ }, 30_000);
96
132
 
97
133
  return () => clearInterval(interval);
98
- }, [lastActivityAt]);
134
+ }, []);
99
135
 
100
136
  const value: RealTimeActivityContextValue = {
101
137
  lastActivityAt,
@@ -104,16 +140,14 @@ export function RealTimeActivityProvider({ children }: { children: ReactNode })
104
140
  };
105
141
 
106
142
  return (
107
- <RealTimeActivityContext.Provider value={value}>
108
- {children}
109
- </RealTimeActivityContext.Provider>
143
+ <RealTimeActivityContext.Provider value={value}>{children}</RealTimeActivityContext.Provider>
110
144
  );
111
145
  }
112
146
 
113
147
  export function useRealTimeActivity(): RealTimeActivityContextValue {
114
148
  const context = useContext(RealTimeActivityContext);
115
149
  if (!context) {
116
- throw new Error('useRealTimeActivity must be used within RealTimeActivityProvider');
150
+ throw new Error("useRealTimeActivity must be used within RealTimeActivityProvider");
117
151
  }
118
152
  return context;
119
153
  }
@@ -1,34 +1,32 @@
1
- import { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo, type ReactNode } from 'react';
2
- import { initDatabase, closeDatabase, isDatabaseInitialized, getAppRunId } from '@/storage/db.ts';
3
- import { insertProviderSnapshotBatch } from '@/storage/repos/providerSnapshots.ts';
4
- import { insertUsageEventBatch } from '@/storage/repos/usageEvents.ts';
5
- import { upsertAgentSession, insertAgentSessionSnapshot, getLatestStreamTotalsForAllSessions } from '@/storage/repos/agentSessions.ts';
6
- import type {
7
- ProviderSnapshotInsert,
8
- UsageEventInsert,
9
- AgentSessionUpsert,
10
- AgentSessionSnapshotInsert,
11
- AgentSessionStreamSnapshotRow,
12
- StreamTotals,
13
- } from '@/storage/types.ts';
14
- import { computeStreamDelta } from '@/storage/types.ts';
15
- import { useDemoMode } from './DemoModeContext.tsx';
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import { closeDatabase, getAppRunId, initDatabase, isDatabaseInitialized } from "@/storage/db.ts";
12
+ import { seedPreviousTotals } from "@/storage/persistence-service.ts";
13
+ import { insertProviderSnapshotBatch } from "@/storage/repos/providerSnapshots.ts";
14
+ import { insertUsageEventBatch } from "@/storage/repos/usageEvents.ts";
15
+ import { incrementalVacuum, pruneOldData } from "@/storage/retention.ts";
16
+ import type { ProviderSnapshotInsert, UsageEventInsert } from "@/storage/types.ts";
17
+ import { useDemoMode } from "./DemoModeContext.tsx";
16
18
 
17
19
  interface StorageContextValue {
18
20
  isReady: boolean;
19
21
  appRunId: number | null;
20
22
  recordProviderSnapshots: (snapshots: ProviderSnapshotInsert[]) => void;
21
23
  recordUsageEvents: (events: UsageEventInsert[]) => void;
22
- recordAgentSession: (
23
- session: AgentSessionUpsert,
24
- snapshot: Omit<AgentSessionSnapshotInsert, 'agentSessionId'>,
25
- streams: Omit<AgentSessionStreamSnapshotRow, 'agentSessionSnapshotId'>[]
26
- ) => number | null;
27
24
  }
28
25
 
29
26
  const StorageContext = createContext<StorageContextValue | null>(null);
30
27
 
31
- const SNAPSHOT_INTERVAL_MS = 60_000;
28
+ const PROVIDER_SNAPSHOT_INTERVAL_MS = 300_000;
29
+ const PRUNE_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
32
30
 
33
31
  interface StorageProviderProps {
34
32
  children: ReactNode;
@@ -38,8 +36,6 @@ export function StorageProvider({ children }: StorageProviderProps) {
38
36
  const [isReady, setIsReady] = useState(false);
39
37
  const [appRunId, setAppRunId] = useState<number | null>(null);
40
38
  const lastProviderSnapshotRef = useRef<Map<string, number>>(new Map());
41
- const lastSessionSnapshotRef = useRef<Map<string, number>>(new Map());
42
- const previousTotalsRef = useRef<Map<string, StreamTotals>>(new Map());
43
39
  const { demoMode, simulator } = useDemoMode();
44
40
 
45
41
  useEffect(() => {
@@ -55,28 +51,29 @@ export function StorageProvider({ children }: StorageProviderProps) {
55
51
  async function init() {
56
52
  try {
57
53
  await initDatabase();
58
- if (mounted) {
59
- try {
60
- const latestTotals = getLatestStreamTotalsForAllSessions();
61
- for (const row of latestTotals) {
62
- const streamKey = `${row.agentId}:${row.sessionId}:${row.provider}:${row.model}`;
63
- previousTotalsRef.current.set(streamKey, {
64
- inputTokens: row.inputTokens,
65
- outputTokens: row.outputTokens,
66
- cacheReadTokens: row.cacheReadTokens,
67
- cacheWriteTokens: row.cacheWriteTokens,
68
- costUsd: row.costUsd,
69
- requestCount: row.requestCount,
70
- });
71
- }
72
- } catch (err) {
73
- console.error('Failed to seed previous totals from DB:', err);
54
+
55
+ // Prune expired snapshots before seeding — keeps startup fast
56
+ try {
57
+ const pruneResult = pruneOldData();
58
+ if (pruneResult.agentSessionSnapshots > 0 || pruneResult.providerSnapshots > 0) {
59
+ console.log(
60
+ `[retention] Pruned ${pruneResult.agentSessionSnapshots} session snapshots, ` +
61
+ `${pruneResult.providerSnapshots} provider snapshots in ${pruneResult.durationMs}ms`,
62
+ );
63
+ incrementalVacuum();
74
64
  }
65
+ } catch (err) {
66
+ console.error("Failed to prune old data:", err);
67
+ }
68
+
69
+ if (mounted) {
70
+ // Seed the persistence service with previous stream totals from DB
71
+ seedPreviousTotals();
75
72
  setIsReady(true);
76
73
  setAppRunId(getAppRunId());
77
74
  }
78
75
  } catch (err) {
79
- console.error('Failed to initialize database:', err);
76
+ console.error("Failed to initialize database:", err);
80
77
  }
81
78
  }
82
79
 
@@ -90,109 +87,42 @@ export function StorageProvider({ children }: StorageProviderProps) {
90
87
  };
91
88
  }, [demoMode]);
92
89
 
93
- const recordProviderSnapshots = useCallback((snapshots: ProviderSnapshotInsert[]) => {
94
- if (!isReady || demoMode || snapshots.length === 0) return;
95
-
96
- const now = Date.now();
97
- const filtered = snapshots.filter(s => {
98
- const lastTs = lastProviderSnapshotRef.current.get(s.provider) ?? 0;
99
- return now - lastTs >= SNAPSHOT_INTERVAL_MS;
100
- });
101
-
102
- if (filtered.length === 0) return;
103
-
104
- try {
105
- insertProviderSnapshotBatch(filtered);
106
- for (const s of filtered) {
107
- lastProviderSnapshotRef.current.set(s.provider, now);
108
- }
109
- } catch (err) {
110
- console.error('Failed to record provider snapshots:', err);
111
- }
112
- }, [isReady, demoMode]);
113
-
114
- const recordUsageEvents = useCallback((events: UsageEventInsert[]) => {
115
- if (!isReady || demoMode || events.length === 0) return;
116
-
117
- try {
118
- insertUsageEventBatch(events);
119
- } catch (err) {
120
- console.error('Failed to record usage events:', err);
121
- }
122
- }, [isReady, demoMode]);
123
-
124
- const recordAgentSession = useCallback((
125
- session: AgentSessionUpsert,
126
- snapshot: Omit<AgentSessionSnapshotInsert, 'agentSessionId'>,
127
- streams: Omit<AgentSessionStreamSnapshotRow, 'agentSessionSnapshotId'>[]
128
- ): number | null => {
129
- if (!isReady || demoMode) return null;
90
+ const recordProviderSnapshots = useCallback(
91
+ (snapshots: ProviderSnapshotInsert[]) => {
92
+ if (!isReady || demoMode || snapshots.length === 0) return;
130
93
 
131
- const sessionKey = `${session.agentId}:${session.sessionId}`;
132
- const now = Date.now();
133
- const lastTs = lastSessionSnapshotRef.current.get(sessionKey) ?? 0;
94
+ const now = Date.now();
95
+ const filtered = snapshots.filter((s) => {
96
+ const lastTs = lastProviderSnapshotRef.current.get(s.provider) ?? 0;
97
+ return now - lastTs >= PROVIDER_SNAPSHOT_INTERVAL_MS;
98
+ });
134
99
 
135
- if (now - lastTs < SNAPSHOT_INTERVAL_MS) {
136
- return null;
137
- }
100
+ if (filtered.length === 0) return;
138
101
 
139
- try {
140
- const agentSessionId = upsertAgentSession(session);
141
-
142
- const snapshotId = insertAgentSessionSnapshot(
143
- { ...snapshot, agentSessionId },
144
- streams
145
- );
146
-
147
- lastSessionSnapshotRef.current.set(sessionKey, now);
148
-
149
- const usageEvents: UsageEventInsert[] = [];
150
- for (const stream of streams) {
151
- const streamKey = `${sessionKey}:${stream.provider}:${stream.model}`;
152
- const current: StreamTotals = {
153
- inputTokens: stream.inputTokens,
154
- outputTokens: stream.outputTokens,
155
- cacheReadTokens: stream.cacheReadTokens,
156
- cacheWriteTokens: stream.cacheWriteTokens,
157
- costUsd: stream.costUsd,
158
- requestCount: stream.requestCount,
159
- };
160
-
161
- const previous = previousTotalsRef.current.get(streamKey);
162
- const delta = computeStreamDelta(current, previous);
163
-
164
- if (delta) {
165
- usageEvents.push({
166
- timestamp: now,
167
- source: 'agent',
168
- provider: stream.provider,
169
- model: stream.model,
170
- agentId: session.agentId,
171
- sessionId: session.sessionId,
172
- projectPath: session.projectPath ?? null,
173
- inputTokens: delta.inputTokens,
174
- outputTokens: delta.outputTokens,
175
- cacheReadTokens: delta.cacheReadTokens,
176
- cacheWriteTokens: delta.cacheWriteTokens,
177
- costUsd: delta.costUsd,
178
- requestCount: delta.requestCount,
179
- pricingSource: stream.pricingSource ?? null,
180
- });
102
+ try {
103
+ insertProviderSnapshotBatch(filtered);
104
+ for (const s of filtered) {
105
+ lastProviderSnapshotRef.current.set(s.provider, now);
181
106
  }
182
-
183
- previousTotalsRef.current.set(streamKey, current);
107
+ } catch (err) {
108
+ console.error("Failed to record provider snapshots:", err);
184
109
  }
110
+ },
111
+ [isReady, demoMode],
112
+ );
185
113
 
186
- if (usageEvents.length > 0) {
187
- insertUsageEventBatch(usageEvents);
188
- }
114
+ const recordUsageEvents = useCallback(
115
+ (events: UsageEventInsert[]) => {
116
+ if (!isReady || demoMode || events.length === 0) return;
189
117
 
190
- return snapshotId;
191
- } catch (err) {
192
- console.error('Failed to record agent session:', err);
193
- return null;
194
- }
195
- }, [isReady, demoMode]);
118
+ try {
119
+ insertUsageEventBatch(events);
120
+ } catch (err) {
121
+ console.error("Failed to record usage events:", err);
122
+ }
123
+ },
124
+ [isReady, demoMode],
125
+ );
196
126
 
197
127
  useEffect(() => {
198
128
  if (!demoMode || !isReady || !simulator) return;
@@ -204,25 +134,41 @@ export function StorageProvider({ children }: StorageProviderProps) {
204
134
  return () => clearInterval(interval);
205
135
  }, [demoMode, isReady, simulator]);
206
136
 
207
- const value: StorageContextValue = useMemo(() => ({
208
- isReady,
209
- appRunId,
210
- recordProviderSnapshots,
211
- recordUsageEvents,
212
- recordAgentSession,
213
- }), [isReady, appRunId, recordProviderSnapshots, recordUsageEvents, recordAgentSession]);
214
-
215
- return (
216
- <StorageContext.Provider value={value}>
217
- {children}
218
- </StorageContext.Provider>
137
+ // Periodic data retention pruning (every hour)
138
+ useEffect(() => {
139
+ if (demoMode || !isReady) return;
140
+
141
+ const intervalId = setInterval(() => {
142
+ try {
143
+ const result = pruneOldData();
144
+ if (result.agentSessionSnapshots > 0 || result.providerSnapshots > 0) {
145
+ incrementalVacuum();
146
+ }
147
+ } catch {
148
+ // Silently ignore periodic prune failures
149
+ }
150
+ }, PRUNE_INTERVAL_MS);
151
+
152
+ return () => clearInterval(intervalId);
153
+ }, [demoMode, isReady]);
154
+
155
+ const value: StorageContextValue = useMemo(
156
+ () => ({
157
+ isReady,
158
+ appRunId,
159
+ recordProviderSnapshots,
160
+ recordUsageEvents,
161
+ }),
162
+ [isReady, appRunId, recordProviderSnapshots, recordUsageEvents],
219
163
  );
164
+
165
+ return <StorageContext.Provider value={value}>{children}</StorageContext.Provider>;
220
166
  }
221
167
 
222
168
  export function useStorage(): StorageContextValue {
223
169
  const context = useContext(StorageContext);
224
170
  if (!context) {
225
- throw new Error('useStorage must be used within StorageProvider');
171
+ throw new Error("useStorage must be used within StorageProvider");
226
172
  }
227
173
  return context;
228
174
  }
@@ -1,6 +1,12 @@
1
- import { createContext, useContext, useState, type ReactNode } from 'react';
2
- import type { ThemePlugin, ThemeColors, ThemeComponents, ColorScheme, ColorSchemePreference } from '@/plugins/types/theme.ts';
3
- import { tokyoNightTheme } from '@/plugins/themes/tokyo-night.ts';
1
+ import { createContext, type ReactNode, useContext, useState } from "react";
2
+ import { tokyoNightTheme } from "@/plugins/themes/tokyo-night.ts";
3
+ import type {
4
+ ColorScheme,
5
+ ColorSchemePreference,
6
+ ThemeColors,
7
+ ThemeComponents,
8
+ ThemePlugin,
9
+ } from "@/plugins/types/theme.ts";
4
10
 
5
11
  interface ThemeContextValue {
6
12
  theme: ThemePlugin;
@@ -42,17 +48,13 @@ export function ThemeProvider({ initialTheme, children }: ThemeProviderProps) {
42
48
  setPreviewTheme,
43
49
  };
44
50
 
45
- return (
46
- <ThemeContext.Provider value={value}>
47
- {children}
48
- </ThemeContext.Provider>
49
- );
51
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
50
52
  }
51
53
 
52
54
  export function useTheme(): ThemeContextValue {
53
55
  const context = useContext(ThemeContext);
54
56
  if (!context) {
55
- throw new Error('useTheme must be used within ThemeProvider');
57
+ throw new Error("useTheme must be used within ThemeProvider");
56
58
  }
57
59
  return context;
58
60
  }
@@ -70,16 +72,19 @@ export function resolveTheme(
70
72
  if (themes.length === 0) return tokyoNightTheme;
71
73
 
72
74
  const targetScheme: ColorScheme | null =
73
- schemePref === 'auto' ? detectedMode :
74
- schemePref === 'light' ? 'light' :
75
- schemePref === 'dark' ? 'dark' :
76
- null;
75
+ schemePref === "auto"
76
+ ? detectedMode
77
+ : schemePref === "light"
78
+ ? "light"
79
+ : schemePref === "dark"
80
+ ? "dark"
81
+ : null;
77
82
 
78
- const stored = themes.find(t => t.id === themeId);
83
+ const stored = themes.find((t) => t.id === themeId);
79
84
 
80
85
  if (!stored) {
81
86
  if (targetScheme) {
82
- return themes.find(t => t.colorScheme === targetScheme) ?? themes[0]!;
87
+ return themes.find((t) => t.colorScheme === targetScheme) ?? themes[0]!;
83
88
  }
84
89
  return themes[0]!;
85
90
  }
@@ -89,7 +94,7 @@ export function resolveTheme(
89
94
  }
90
95
 
91
96
  const familySibling = themes.find(
92
- t => t.family === stored.family && t.colorScheme === targetScheme && t.id !== stored.id,
97
+ (t) => t.family === stored.family && t.colorScheme === targetScheme && t.id !== stored.id,
93
98
  );
94
99
  return familySibling ?? stored;
95
100
  }