@tokentop/ttop 0.3.0 → 0.5.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 (83) hide show
  1. package/package.json +9 -4
  2. package/src/agents/aggregator.ts +8 -1
  3. package/src/agents/types.ts +3 -0
  4. package/src/cli.ts +2 -2
  5. package/src/demo/simulator.ts +1 -1
  6. package/src/plugins/agents/index.ts +2 -0
  7. package/src/plugins/auth-sources.ts +2 -3
  8. package/src/plugins/lifecycle.ts +17 -13
  9. package/src/plugins/loader.test.ts +1 -1
  10. package/src/plugins/loader.ts +15 -7
  11. package/src/plugins/notification-bus.ts +5 -2
  12. package/src/plugins/notifications/terminal-bell.ts +9 -2
  13. package/src/plugins/notifications/visual-flash.ts +13 -3
  14. package/src/plugins/plugin-context-factory.ts +4 -19
  15. package/src/plugins/providers/anthropic.ts +2 -2
  16. package/src/plugins/providers/antigravity.ts +1 -1
  17. package/src/plugins/providers/chutes.ts +189 -0
  18. package/src/plugins/providers/codex.ts +1 -1
  19. package/src/plugins/providers/gemini.ts +1 -1
  20. package/src/plugins/providers/github-copilot.ts +1 -1
  21. package/src/plugins/providers/index.ts +1 -0
  22. package/src/plugins/providers/minimax.ts +1 -1
  23. package/src/plugins/providers/openai-api.ts +1 -1
  24. package/src/plugins/providers/opencode-zen.ts +1 -1
  25. package/src/plugins/providers/perplexity.ts +1 -1
  26. package/src/plugins/providers/zai.ts +4 -2
  27. package/src/plugins/registry.ts +51 -46
  28. package/src/plugins/sandbox-guard.test.ts +1 -1
  29. package/src/plugins/sandbox-guard.ts +1 -1
  30. package/src/plugins/sandbox.ts +1 -1
  31. package/src/plugins/themes/catppuccin-latte.ts +1 -1
  32. package/src/plugins/themes/catppuccin-mocha.ts +1 -1
  33. package/src/plugins/themes/claude-code.ts +1 -1
  34. package/src/plugins/themes/dracula.ts +1 -1
  35. package/src/plugins/themes/github-light.ts +1 -1
  36. package/src/plugins/themes/gruvbox-dark.ts +1 -1
  37. package/src/plugins/themes/gruvbox-light.ts +1 -1
  38. package/src/plugins/themes/kanagawa.ts +1 -1
  39. package/src/plugins/themes/nord.ts +1 -1
  40. package/src/plugins/themes/one-dark.ts +1 -1
  41. package/src/plugins/themes/opencode.ts +1 -1
  42. package/src/plugins/themes/rose-pine-dawn.ts +1 -1
  43. package/src/plugins/themes/rose-pine.ts +1 -1
  44. package/src/plugins/themes/solarized-light.ts +1 -1
  45. package/src/plugins/themes/tokyo-night.ts +1 -1
  46. package/src/plugins/update-checker.ts +2 -2
  47. package/src/pricing/estimator.test.ts +1 -1
  48. package/src/pricing/estimator.ts +1 -1
  49. package/src/pricing/fallback.ts +1 -1
  50. package/src/pricing/index.ts +1 -1
  51. package/src/pricing/models-dev.ts +1 -1
  52. package/src/storage/index.ts +6 -0
  53. package/src/storage/migrations/index.ts +17 -0
  54. package/src/storage/persistence-service.ts +206 -0
  55. package/src/storage/repos/pluginStorage.ts +35 -0
  56. package/src/storage/retention.ts +146 -0
  57. package/src/tui/App.tsx +1 -1
  58. package/src/tui/components/DebugPanel.tsx +23 -10
  59. package/src/tui/components/ProviderCard.tsx +1 -1
  60. package/src/tui/components/ProviderDetailPanel.tsx +1 -1
  61. package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
  62. package/src/tui/components/SessionsTable.tsx +15 -4
  63. package/src/tui/components/SettingsModal.tsx +383 -76
  64. package/src/tui/components/ThemePicker.tsx +1 -1
  65. package/src/tui/contexts/AgentSessionContext.tsx +44 -80
  66. package/src/tui/contexts/PluginContext.tsx +13 -3
  67. package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
  68. package/src/tui/contexts/StorageContext.tsx +42 -130
  69. package/src/tui/contexts/ThemeContext.tsx +3 -3
  70. package/src/tui/hooks/useExitAnimation.ts +4 -0
  71. package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
  72. package/src/tui/views/Dashboard.tsx +40 -3
  73. package/src/tui/views/ProjectsView.tsx +29 -6
  74. package/src/tui/views/RealTimeDashboard.tsx +29 -8
  75. package/src/tui/views/SettingsView.tsx +9 -4
  76. package/src/version.ts +1 -1
  77. package/src/plugins/types/agent.ts +0 -108
  78. package/src/plugins/types/base.ts +0 -278
  79. package/src/plugins/types/index.ts +0 -19
  80. package/src/plugins/types/notification.ts +0 -51
  81. package/src/plugins/types/provider.ts +0 -237
  82. package/src/plugins/types/theme.ts +0 -78
  83. package/src/storage/database.ts +0 -623
@@ -1,11 +1,3 @@
1
- import type { PluginsConfig } from "@/config/schema.ts";
2
- import {
3
- discoverLocalPlugins,
4
- loadLocalPlugin,
5
- loadNpmPlugin,
6
- resolvePluginPath,
7
- } from "./loader.ts";
8
- import { installAllNpmPlugins, resolveNpmPluginPath } from "./npm-installer.ts";
9
1
  import type {
10
2
  AgentPlugin,
11
3
  AnyPlugin,
@@ -14,7 +6,15 @@ import type {
14
6
  PluginType,
15
7
  ProviderPlugin,
16
8
  ThemePlugin,
17
- } from "./types/index.ts";
9
+ } from "@tokentop/plugin-sdk";
10
+ import type { PluginsConfig } from "@/config/schema.ts";
11
+ import {
12
+ discoverLocalPlugins,
13
+ loadLocalPlugin,
14
+ loadNpmPlugin,
15
+ resolvePluginPath,
16
+ } from "./loader.ts";
17
+ import { installAllNpmPlugins, resolveNpmPluginPath } from "./npm-installer.ts";
18
18
 
19
19
  export type PluginSource = "builtin" | "local" | "npm";
20
20
 
@@ -35,6 +35,7 @@ class PluginRegistryImpl {
35
35
 
36
36
  private sources = new Map<string, PluginSource>();
37
37
  private initialized = false;
38
+ private officialIds = new Set<string>();
38
39
 
39
40
  private sourceKey(type: string, id: string): string {
40
41
  return `${type}-${id}`;
@@ -44,37 +45,20 @@ class PluginRegistryImpl {
44
45
  const key = this.sourceKey(plugin.type, plugin.id);
45
46
  const existingSource = this.sources.get(key);
46
47
 
47
- if (existingSource === "builtin" && source !== "builtin") {
48
+ // Priority: local > npm > builtin
49
+ // If a higher-priority source already registered this plugin, skip.
50
+ const priority: Record<PluginSource, number> = { local: 2, npm: 1, builtin: 0 };
51
+ if (existingSource && priority[existingSource] > priority[source]) {
48
52
  return;
49
53
  }
50
54
 
51
- this.sources.set(key, source);
52
- switch (plugin.type) {
53
- case "provider":
54
- if (this.plugins.provider.has(plugin.id)) {
55
- console.warn(`Plugin "${plugin.id}" already registered, overwriting`);
56
- }
57
- this.plugins.provider.set(plugin.id, plugin);
58
- break;
59
- case "agent":
60
- if (this.plugins.agent.has(plugin.id)) {
61
- console.warn(`Plugin "${plugin.id}" already registered, overwriting`);
62
- }
63
- this.plugins.agent.set(plugin.id, plugin);
64
- break;
65
- case "theme":
66
- if (this.plugins.theme.has(plugin.id)) {
67
- console.warn(`Plugin "${plugin.id}" already registered, overwriting`);
68
- }
69
- this.plugins.theme.set(plugin.id, plugin);
70
- break;
71
- case "notification":
72
- if (this.plugins.notification.has(plugin.id)) {
73
- console.warn(`Plugin "${plugin.id}" already registered, overwriting`);
74
- }
75
- this.plugins.notification.set(plugin.id, plugin);
76
- break;
55
+ if (existingSource && existingSource !== source) {
56
+ console.info(`Plugin "${plugin.id}" overridden by ${source} (was ${existingSource})`);
77
57
  }
58
+
59
+ this.sources.set(key, source);
60
+ const map = this.plugins[plugin.type] as Map<string, AnyPlugin>;
61
+ map.set(plugin.id, plugin);
78
62
  }
79
63
 
80
64
  unregister(type: PluginType, id: string): boolean {
@@ -118,6 +102,10 @@ class PluginRegistryImpl {
118
102
  return this.sources.get(this.sourceKey(type, id));
119
103
  }
120
104
 
105
+ isOfficial(type: PluginType, id: string): boolean {
106
+ return this.officialIds.has(this.sourceKey(type, id));
107
+ }
108
+
121
109
  async loadBuiltinPlugins(): Promise<void> {
122
110
  const [providers, agents, themes, notifications] = await Promise.all([
123
111
  import("./providers/index.ts"),
@@ -127,19 +115,31 @@ class PluginRegistryImpl {
127
115
  ]);
128
116
 
129
117
  for (const plugin of Object.values(providers)) {
130
- if (isProviderPlugin(plugin)) this.register(plugin, "builtin");
118
+ if (isProviderPlugin(plugin)) {
119
+ this.register(plugin, "builtin");
120
+ this.officialIds.add(this.sourceKey(plugin.type, plugin.id));
121
+ }
131
122
  }
132
123
 
133
124
  for (const plugin of Object.values(agents)) {
134
- if (isAgentPlugin(plugin)) this.register(plugin, "builtin");
125
+ if (isAgentPlugin(plugin)) {
126
+ this.register(plugin, "builtin");
127
+ this.officialIds.add(this.sourceKey(plugin.type, plugin.id));
128
+ }
135
129
  }
136
130
 
137
131
  for (const plugin of Object.values(themes)) {
138
- if (isThemePlugin(plugin)) this.register(plugin, "builtin");
132
+ if (isThemePlugin(plugin)) {
133
+ this.register(plugin, "builtin");
134
+ this.officialIds.add(this.sourceKey(plugin.type, plugin.id));
135
+ }
139
136
  }
140
137
 
141
138
  for (const plugin of Object.values(notifications)) {
142
- if (isNotificationPlugin(plugin)) this.register(plugin, "builtin");
139
+ if (isNotificationPlugin(plugin)) {
140
+ this.register(plugin, "builtin");
141
+ this.officialIds.add(this.sourceKey(plugin.type, plugin.id));
142
+ }
143
143
  }
144
144
  }
145
145
 
@@ -148,13 +148,13 @@ class PluginRegistryImpl {
148
148
  const resolvedExtraPaths = extraPaths.map((p) => resolvePluginPath(p));
149
149
  const allPaths = [...discoveredPaths, ...resolvedExtraPaths];
150
150
 
151
- for (const pluginPath of allPaths) {
152
- const result = await loadLocalPlugin(pluginPath);
151
+ const results = await Promise.all(allPaths.map((p) => loadLocalPlugin(p)));
152
+ for (const [i, result] of results.entries()) {
153
153
  if (result.success && result.plugin) {
154
154
  this.register(result.plugin, "local");
155
155
  console.info(`Loaded local plugin: ${result.plugin.name} (${result.plugin.id})`);
156
156
  } else {
157
- console.warn(`Failed to load plugin from ${pluginPath}: ${result.error}`);
157
+ console.warn(`Failed to load plugin from ${allPaths[i]}: ${result.error}`);
158
158
  }
159
159
  }
160
160
  }
@@ -171,9 +171,14 @@ class PluginRegistryImpl {
171
171
  }
172
172
  }
173
173
 
174
- for (const packageName of packages) {
175
- const resolvedPath = resolveNpmPluginPath(packageName);
176
- const result = await loadNpmPlugin(packageName, resolvedPath);
174
+ const loadResults = await Promise.all(
175
+ packages.map(async (packageName) => {
176
+ const resolvedPath = resolveNpmPluginPath(packageName);
177
+ const result = await loadNpmPlugin(packageName, resolvedPath);
178
+ return { packageName, result };
179
+ }),
180
+ );
181
+ for (const { packageName, result } of loadResults) {
177
182
  if (result.success && result.plugin) {
178
183
  this.register(result.plugin, "npm");
179
184
  console.info(`Loaded npm plugin: ${result.plugin.name} (${packageName})`);
@@ -1,11 +1,11 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { PluginPermissionError } from "@tokentop/plugin-sdk";
2
3
  import {
3
4
  deepFreeze,
4
5
  getActivePluginGuard,
5
6
  installGlobalFetchGuard,
6
7
  runInPluginGuard,
7
8
  } from "./sandbox-guard.ts";
8
- import { PluginPermissionError } from "./types/base.ts";
9
9
 
10
10
  const originalFetch = globalThis.fetch;
11
11
  const fetchCalls: string[] = [];
@@ -11,9 +11,9 @@
11
11
  * 2. **Deep-freeze** — prevents plugins from mutating the PluginContext.
12
12
  */
13
13
 
14
+ import { PluginPermissionError, type PluginPermissions } from "@tokentop/plugin-sdk";
14
15
  import { AsyncLocalStorage } from "async_hooks";
15
16
  import { createPluginLogger } from "./sandbox.ts";
16
- import { PluginPermissionError, type PluginPermissions } from "./types/base.ts";
17
17
 
18
18
  // ---------------------------------------------------------------------------
19
19
  // Plugin execution context (AsyncLocalStorage)
@@ -3,7 +3,7 @@ import {
3
3
  type PluginLogger,
4
4
  PluginPermissionError,
5
5
  type PluginPermissions,
6
- } from "./types/index.ts";
6
+ } from "@tokentop/plugin-sdk";
7
7
 
8
8
  export function createSandboxedHttpClient(
9
9
  pluginId: string,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const catppuccinLatteTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const catppuccinMochaTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const claudeCodeTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const draculaTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const githubLightTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const gruvboxDarkTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const gruvboxLightTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const kanagawaTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const nordTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const oneDarkTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const opencodeTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const rosePineDawnTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const rosePineTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const solarizedLightTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const tokyoNightTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -9,7 +9,7 @@
9
9
  * skipped because they have no registry entry to compare against.
10
10
  */
11
11
 
12
- import type { PluginType } from "./types/base.ts";
12
+ import type { PluginType } from "@tokentop/plugin-sdk";
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Types
@@ -60,7 +60,7 @@ function isNewer(current: string, latest: string): boolean {
60
60
  * Uses the abbreviated metadata endpoint for minimal payload.
61
61
  */
62
62
  async function fetchLatestVersion(packageName: string): Promise<string> {
63
- const url = `https://registry.npmjs.org/${encodeURIComponent(packageName).replace("%40", "@")}`;
63
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName).replaceAll("%40", "@")}`;
64
64
  const res = await fetch(url, {
65
65
  headers: { Accept: "application/vnd.npm.install-v1+json" },
66
66
  signal: AbortSignal.timeout(8_000),
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import type { ModelPricing } from "@/plugins/types/provider.ts";
2
+ import type { ModelPricing } from "@tokentop/plugin-sdk";
3
3
  import { estimateCost, estimateSessionCost, formatCost, formatTokenCount } from "./estimator.ts";
4
4
 
5
5
  describe("estimateCost()", () => {
@@ -1,4 +1,4 @@
1
- import type { CostBreakdown, ModelPricing } from "@/plugins/types/provider.ts";
1
+ import type { CostBreakdown, ModelPricing } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export interface TokenUsage {
4
4
  input: number;
@@ -1,4 +1,4 @@
1
- import type { ModelPricing } from "@/plugins/types/provider.ts";
1
+ import type { ModelPricing } from "@tokentop/plugin-sdk";
2
2
 
3
3
  //TODO: get more up to date fallback pricing
4
4
  export const FALLBACK_PRICING: Record<string, Record<string, ModelPricing>> = {
@@ -1,4 +1,4 @@
1
- import type { ModelPricing, ProviderPlugin, ProviderPricing } from "@/plugins/types/provider.ts";
1
+ import type { ModelPricing, ProviderPlugin, ProviderPricing } from "@tokentop/plugin-sdk";
2
2
  import { getFallbackPricing, getFallbackProviderPricing } from "./fallback.ts";
3
3
  import {
4
4
  clearCache as clearModelsDevCache,
@@ -1,4 +1,4 @@
1
- import type { ModelPricing } from "@/plugins/types/provider.ts";
1
+ import type { ModelPricing } from "@tokentop/plugin-sdk";
2
2
 
3
3
  const MODELS_DEV_API = "https://models.dev/api.json";
4
4
  const CACHE_TTL_MS = 3600000;
@@ -15,6 +15,12 @@ export {
15
15
  insertAgentSessionSnapshot,
16
16
  upsertAgentSession,
17
17
  } from "./repos/agentSessions.ts";
18
+ export {
19
+ pluginStorageDelete,
20
+ pluginStorageGet,
21
+ pluginStorageHas,
22
+ pluginStorageSet,
23
+ } from "./repos/pluginStorage.ts";
18
24
  export {
19
25
  getLatestProviderSnapshot,
20
26
  insertProviderSnapshot,
@@ -164,6 +164,23 @@ export const migrations: Migration[] = [
164
164
  `);
165
165
  },
166
166
  },
167
+ {
168
+ version: 2,
169
+ up: (db: Database) => {
170
+ db.exec(`
171
+ CREATE TABLE IF NOT EXISTS plugin_storage (
172
+ plugin_id TEXT NOT NULL,
173
+ key TEXT NOT NULL,
174
+ value TEXT NOT NULL,
175
+ updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
176
+ PRIMARY KEY (plugin_id, key)
177
+ );
178
+
179
+ CREATE INDEX IF NOT EXISTS idx_plugin_storage_plugin
180
+ ON plugin_storage(plugin_id);
181
+ `);
182
+ },
183
+ },
167
184
  ];
168
185
 
169
186
  export function getCurrentVersion(db: Database): number {
@@ -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
+ }
@@ -0,0 +1,35 @@
1
+ import { getDatabase } from "../db.ts";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Plugin KV storage
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export function pluginStorageGet(pluginId: string, key: string): string | null {
8
+ const db = getDatabase();
9
+ const row = db
10
+ .prepare("SELECT value FROM plugin_storage WHERE plugin_id = ? AND key = ?")
11
+ .get(pluginId, key) as { value: string } | null;
12
+ return row?.value ?? null;
13
+ }
14
+
15
+ export function pluginStorageSet(pluginId: string, key: string, value: string): void {
16
+ const db = getDatabase();
17
+ db.prepare(
18
+ `INSERT INTO plugin_storage (plugin_id, key, value, updated_at)
19
+ VALUES (?, ?, ?, ?)
20
+ ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
21
+ ).run(pluginId, key, value, Math.floor(Date.now() / 1000));
22
+ }
23
+
24
+ export function pluginStorageDelete(pluginId: string, key: string): void {
25
+ const db = getDatabase();
26
+ db.prepare("DELETE FROM plugin_storage WHERE plugin_id = ? AND key = ?").run(pluginId, key);
27
+ }
28
+
29
+ export function pluginStorageHas(pluginId: string, key: string): boolean {
30
+ const db = getDatabase();
31
+ const row = db
32
+ .prepare("SELECT 1 FROM plugin_storage WHERE plugin_id = ? AND key = ?")
33
+ .get(pluginId, key);
34
+ return row !== null && row !== undefined;
35
+ }