@tokentop/ttop 0.2.2

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 (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +273 -0
  3. package/bin/ttop.js +49 -0
  4. package/package.json +68 -0
  5. package/src/agents/aggregator.ts +176 -0
  6. package/src/agents/costing.ts +74 -0
  7. package/src/agents/index.ts +12 -0
  8. package/src/agents/types.ts +71 -0
  9. package/src/cli.ts +167 -0
  10. package/src/config/schema.test.ts +166 -0
  11. package/src/config/schema.ts +165 -0
  12. package/src/demo/simulator.ts +745 -0
  13. package/src/plugins/agents/index.ts +2 -0
  14. package/src/plugins/auth-sources.ts +186 -0
  15. package/src/plugins/lifecycle.ts +177 -0
  16. package/src/plugins/loader.test.ts +195 -0
  17. package/src/plugins/loader.ts +282 -0
  18. package/src/plugins/notification-bus.ts +153 -0
  19. package/src/plugins/notifications/index.ts +2 -0
  20. package/src/plugins/notifications/terminal-bell.ts +66 -0
  21. package/src/plugins/notifications/visual-flash.ts +57 -0
  22. package/src/plugins/npm-installer.ts +164 -0
  23. package/src/plugins/plugin-context-factory.ts +67 -0
  24. package/src/plugins/plugin-host.ts +205 -0
  25. package/src/plugins/providers/anthropic.ts +325 -0
  26. package/src/plugins/providers/antigravity.ts +309 -0
  27. package/src/plugins/providers/codex.ts +250 -0
  28. package/src/plugins/providers/gemini.ts +391 -0
  29. package/src/plugins/providers/github-copilot.ts +194 -0
  30. package/src/plugins/providers/index.ts +12 -0
  31. package/src/plugins/providers/minimax.ts +223 -0
  32. package/src/plugins/providers/openai-api.ts +250 -0
  33. package/src/plugins/providers/opencode-zen.ts +125 -0
  34. package/src/plugins/providers/perplexity.ts +177 -0
  35. package/src/plugins/providers/zai.ts +225 -0
  36. package/src/plugins/registry.ts +251 -0
  37. package/src/plugins/sandbox-guard.test.ts +191 -0
  38. package/src/plugins/sandbox-guard.ts +155 -0
  39. package/src/plugins/sandbox.ts +138 -0
  40. package/src/plugins/themes/catppuccin-latte.ts +45 -0
  41. package/src/plugins/themes/catppuccin-mocha.ts +45 -0
  42. package/src/plugins/themes/claude-code.ts +45 -0
  43. package/src/plugins/themes/dracula.ts +45 -0
  44. package/src/plugins/themes/github-light.ts +45 -0
  45. package/src/plugins/themes/gruvbox-dark.ts +45 -0
  46. package/src/plugins/themes/gruvbox-light.ts +45 -0
  47. package/src/plugins/themes/index.ts +15 -0
  48. package/src/plugins/themes/kanagawa.ts +45 -0
  49. package/src/plugins/themes/nord.ts +45 -0
  50. package/src/plugins/themes/one-dark.ts +45 -0
  51. package/src/plugins/themes/opencode.ts +47 -0
  52. package/src/plugins/themes/rose-pine-dawn.ts +45 -0
  53. package/src/plugins/themes/rose-pine.ts +45 -0
  54. package/src/plugins/themes/solarized-light.ts +45 -0
  55. package/src/plugins/themes/tokyo-night.ts +49 -0
  56. package/src/plugins/types/agent.ts +108 -0
  57. package/src/plugins/types/base.ts +267 -0
  58. package/src/plugins/types/index.ts +19 -0
  59. package/src/plugins/types/notification.ts +51 -0
  60. package/src/plugins/types/provider.ts +233 -0
  61. package/src/plugins/types/theme.ts +70 -0
  62. package/src/plugins/update-checker.ts +177 -0
  63. package/src/pricing/estimator.test.ts +181 -0
  64. package/src/pricing/estimator.ts +80 -0
  65. package/src/pricing/fallback.test.ts +71 -0
  66. package/src/pricing/fallback.ts +62 -0
  67. package/src/pricing/index.ts +66 -0
  68. package/src/pricing/models-dev.ts +130 -0
  69. package/src/storage/database.ts +613 -0
  70. package/src/storage/db.ts +64 -0
  71. package/src/storage/index.ts +66 -0
  72. package/src/storage/migrations/index.ts +189 -0
  73. package/src/storage/paths.ts +24 -0
  74. package/src/storage/repos/agentSessions.ts +210 -0
  75. package/src/storage/repos/providerSnapshots.ts +116 -0
  76. package/src/storage/repos/usageEvents.ts +334 -0
  77. package/src/storage/types.ts +288 -0
  78. package/src/tui/App.tsx +381 -0
  79. package/src/tui/components/CommandPalette.tsx +166 -0
  80. package/src/tui/components/DebugConsole.tsx +161 -0
  81. package/src/tui/components/DebugPanel.tsx +660 -0
  82. package/src/tui/components/FooterHints.tsx +22 -0
  83. package/src/tui/components/GhostProviderCard.tsx +45 -0
  84. package/src/tui/components/Header.tsx +125 -0
  85. package/src/tui/components/HelpOverlay.tsx +45 -0
  86. package/src/tui/components/InlineGauge.tsx +34 -0
  87. package/src/tui/components/InlineSparkline.tsx +78 -0
  88. package/src/tui/components/KpiStrip.tsx +246 -0
  89. package/src/tui/components/LimitGauge.tsx +186 -0
  90. package/src/tui/components/ModalBackdrop.tsx +42 -0
  91. package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
  92. package/src/tui/components/ProviderCard.tsx +309 -0
  93. package/src/tui/components/ProviderDetailPanel.tsx +237 -0
  94. package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
  95. package/src/tui/components/ProvidersList.tsx +380 -0
  96. package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
  97. package/src/tui/components/SessionsTable.tsx +401 -0
  98. package/src/tui/components/SettingsModal.tsx +587 -0
  99. package/src/tui/components/SidebarBreakdown.tsx +87 -0
  100. package/src/tui/components/Skeleton.tsx +102 -0
  101. package/src/tui/components/SmartSidebar.tsx +644 -0
  102. package/src/tui/components/Sparkline.tsx +180 -0
  103. package/src/tui/components/Spinner.tsx +36 -0
  104. package/src/tui/components/StatusBar.tsx +96 -0
  105. package/src/tui/components/ThemePicker.tsx +234 -0
  106. package/src/tui/components/Toast.tsx +61 -0
  107. package/src/tui/components/UsageGauge.tsx +80 -0
  108. package/src/tui/components/index.ts +5 -0
  109. package/src/tui/contexts/AgentSessionContext.tsx +307 -0
  110. package/src/tui/contexts/ConfigContext.tsx +141 -0
  111. package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
  112. package/src/tui/contexts/DemoModeContext.tsx +60 -0
  113. package/src/tui/contexts/DrawerContext.tsx +42 -0
  114. package/src/tui/contexts/InputContext.tsx +30 -0
  115. package/src/tui/contexts/LogContext.tsx +150 -0
  116. package/src/tui/contexts/PluginContext.tsx +424 -0
  117. package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
  118. package/src/tui/contexts/StorageContext.tsx +233 -0
  119. package/src/tui/contexts/ThemeContext.tsx +95 -0
  120. package/src/tui/contexts/TimeWindowContext.tsx +158 -0
  121. package/src/tui/contexts/ToastContext.tsx +40 -0
  122. package/src/tui/contexts/index.ts +5 -0
  123. package/src/tui/createApp.tsx +47 -0
  124. package/src/tui/debug/captureFrame.ts +262 -0
  125. package/src/tui/debug/snapshot.tsx +644 -0
  126. package/src/tui/driver/assertions.ts +279 -0
  127. package/src/tui/driver/cli.ts +717 -0
  128. package/src/tui/driver/coverage.ts +204 -0
  129. package/src/tui/driver/demo-snapshot.ts +66 -0
  130. package/src/tui/driver/diff.ts +166 -0
  131. package/src/tui/driver/driver.ts +270 -0
  132. package/src/tui/driver/index.ts +53 -0
  133. package/src/tui/driver/recorder.ts +303 -0
  134. package/src/tui/driver/test-workflow.ts +115 -0
  135. package/src/tui/driver/test.ts +47 -0
  136. package/src/tui/hooks/index.ts +20 -0
  137. package/src/tui/hooks/useAnimatedValue.ts +113 -0
  138. package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
  139. package/src/tui/hooks/useDashboardState.ts +116 -0
  140. package/src/tui/hooks/useEmaActivity.ts +223 -0
  141. package/src/tui/hooks/useEntranceAnimation.ts +62 -0
  142. package/src/tui/hooks/useExitAnimation.ts +137 -0
  143. package/src/tui/hooks/usePulse.ts +138 -0
  144. package/src/tui/hooks/useSafeRenderer.ts +24 -0
  145. package/src/tui/hooks/useValueFlash.ts +111 -0
  146. package/src/tui/index.tsx +50 -0
  147. package/src/tui/utils/providerColor.ts +32 -0
  148. package/src/tui/views/Dashboard.tsx +340 -0
  149. package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
  150. package/src/tui/views/ProjectsView.tsx +1277 -0
  151. package/src/tui/views/RealTimeDashboard.tsx +393 -0
  152. package/src/tui/views/SettingsView.tsx +465 -0
  153. package/src/tui/views/index.ts +2 -0
  154. package/src/types/global.d.ts +5 -0
  155. package/src/types/opentui-test-utils.d.ts +18 -0
  156. package/src/utils/clipboard.ts +118 -0
  157. package/src/utils/currency.test.ts +106 -0
  158. package/src/utils/currency.ts +79 -0
  159. package/src/version.ts +1 -0
@@ -0,0 +1,233 @@
1
+ import type { BasePlugin, PluginHttpClient, PluginLogger } from './base.ts';
2
+
3
+ export interface ProviderCapabilities {
4
+ usageLimits: boolean;
5
+ apiRateLimits: boolean;
6
+ tokenUsage: boolean;
7
+ actualCosts: boolean;
8
+ }
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Credential types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface Credentials {
15
+ apiKey?: string;
16
+ oauth?: OAuthCredentials;
17
+ groupId?: string;
18
+ source: 'env' | 'opencode' | 'external' | 'config';
19
+ }
20
+
21
+ export interface OAuthCredentials {
22
+ accessToken: string;
23
+ refreshToken?: string;
24
+ expiresAt?: number;
25
+ accountId?: string;
26
+ managedProjectId?: string;
27
+ }
28
+
29
+ export interface RefreshedCredentials {
30
+ accessToken: string;
31
+ refreshToken: string;
32
+ expiresAt: number;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Auth (plugin-owned discovery)
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Result of a credential discovery attempt. */
40
+ export interface CredentialResult {
41
+ ok: boolean;
42
+ credentials?: Credentials;
43
+ reason?: 'missing' | 'expired' | 'invalid' | 'error';
44
+ message?: string;
45
+ }
46
+
47
+ /**
48
+ * Shape of an entry in OpenCode's auth.json file.
49
+ * Used by AuthSources.opencode.getProviderEntry().
50
+ */
51
+ export interface OpenCodeAuthEntry {
52
+ type: 'api' | 'oauth' | 'codex' | 'github' | 'wellknown';
53
+ key?: string;
54
+ access?: string;
55
+ refresh?: string;
56
+ expires?: number;
57
+ token?: string;
58
+ accessToken?: string;
59
+ refreshToken?: string;
60
+ expiresAt?: number;
61
+ accountId?: string;
62
+ groupId?: string;
63
+ }
64
+
65
+ /**
66
+ * Sandboxed auth source helpers injected into plugins.
67
+ * All methods respect the plugin's declared permissions.
68
+ */
69
+ export interface AuthSources {
70
+ /** Read an environment variable (sandboxed to `permissions.env.vars`). */
71
+ env: {
72
+ get(name: string): string | undefined;
73
+ };
74
+
75
+ /** Read files from the filesystem (sandboxed to `permissions.filesystem.paths`). */
76
+ files: {
77
+ readText(path: string): Promise<string | null>;
78
+ readJson<T = unknown>(path: string): Promise<T | null>;
79
+ exists(path: string): Promise<boolean>;
80
+ };
81
+
82
+ /**
83
+ * Read from OpenCode's auth storage.
84
+ * Core handles locating the auth file; plugin provides the provider key.
85
+ */
86
+ opencode: {
87
+ getProviderEntry(key: string): Promise<OpenCodeAuthEntry | null>;
88
+ };
89
+
90
+ /** Platform information for cross-platform credential path resolution. */
91
+ platform: {
92
+ os: 'darwin' | 'linux' | 'win32';
93
+ homedir: string;
94
+ arch: string;
95
+ };
96
+ }
97
+
98
+ /** Per-plugin key-value storage (namespaced by plugin ID). */
99
+ export interface PluginStorage {
100
+ get(key: string): Promise<string | null>;
101
+ set(key: string, value: string): Promise<void>;
102
+ delete(key: string): Promise<void>;
103
+ has(key: string): Promise<boolean>;
104
+ }
105
+
106
+ /** Full runtime context provided to plugin methods like `auth.discover()`. */
107
+ export interface PluginContext {
108
+ /** Plugin's validated configuration values. */
109
+ readonly config: Record<string, unknown>;
110
+ /** Scoped logger. */
111
+ readonly logger: PluginLogger;
112
+ /** Sandboxed HTTP client. */
113
+ readonly http: PluginHttpClient;
114
+ /** Credential discovery helpers. */
115
+ readonly authSources: AuthSources;
116
+ /** Per-plugin persistent key-value storage. */
117
+ readonly storage: PluginStorage;
118
+ /** Abort signal — fired when the plugin is being stopped or the app is shutting down. */
119
+ readonly signal: AbortSignal;
120
+ }
121
+
122
+ export interface ProviderAuth {
123
+ /** Discover credentials using the provided context helpers. */
124
+ discover(ctx: PluginContext): Promise<CredentialResult>;
125
+ /** Check whether discovered credentials are sufficient to operate. */
126
+ isConfigured(credentials: Credentials): boolean;
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Pricing
131
+ // ---------------------------------------------------------------------------
132
+
133
+ export interface ModelPricing {
134
+ input: number;
135
+ output: number;
136
+ cacheRead?: number;
137
+ cacheWrite?: number;
138
+ source?: string;
139
+ }
140
+
141
+ /** Pricing configuration for a provider's models. */
142
+ export interface ProviderPricing {
143
+ /**
144
+ * Map a model ID to the identifier used by the pricing service.
145
+ * Return `undefined` to use the model ID as-is.
146
+ */
147
+ mapModelId?(modelId: string): string | undefined;
148
+ /**
149
+ * The provider ID to use when querying models.dev.
150
+ * Defaults to `plugin.id` if not specified.
151
+ */
152
+ modelsDevProviderId?: string;
153
+ /** Static fallback prices keyed by model ID. */
154
+ staticPrices?: Record<string, ModelPricing>;
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Usage data
159
+ // ---------------------------------------------------------------------------
160
+
161
+ export interface UsageLimit {
162
+ usedPercent: number | null;
163
+ resetsAt?: number;
164
+ label?: string;
165
+ windowMinutes?: number;
166
+ }
167
+
168
+ export interface CostBreakdown {
169
+ total: number;
170
+ input?: number;
171
+ output?: number;
172
+ cacheRead?: number;
173
+ cacheWrite?: number;
174
+ currency: string;
175
+ }
176
+
177
+ export interface ProviderUsageData {
178
+ planType?: string;
179
+ allowed?: boolean;
180
+ limitReached?: boolean;
181
+ limits?: {
182
+ primary?: UsageLimit;
183
+ secondary?: UsageLimit;
184
+ items?: UsageLimit[];
185
+ };
186
+ tokens?: {
187
+ input: number;
188
+ output: number;
189
+ cacheRead?: number;
190
+ cacheWrite?: number;
191
+ };
192
+ credits?: {
193
+ hasCredits: boolean;
194
+ unlimited: boolean;
195
+ balance?: string;
196
+ };
197
+ cost?: {
198
+ actual?: CostBreakdown;
199
+ estimated?: CostBreakdown;
200
+ source: 'api' | 'estimated';
201
+ };
202
+ fetchedAt: number;
203
+ error?: string;
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Fetch context
208
+ // ---------------------------------------------------------------------------
209
+
210
+ export interface ProviderFetchContext {
211
+ readonly credentials: Credentials;
212
+ readonly http: PluginHttpClient;
213
+ readonly logger: PluginLogger;
214
+ readonly config: Record<string, unknown>;
215
+ readonly signal: AbortSignal;
216
+ readonly options?: {
217
+ timePeriod?: 'session' | 'daily' | 'weekly' | 'monthly';
218
+ sessionId?: string;
219
+ };
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Provider Plugin
224
+ // ---------------------------------------------------------------------------
225
+
226
+ export interface ProviderPlugin extends BasePlugin {
227
+ readonly type: 'provider';
228
+ readonly capabilities: ProviderCapabilities;
229
+ readonly auth: ProviderAuth;
230
+ readonly pricing?: ProviderPricing;
231
+ fetchUsage(ctx: ProviderFetchContext): Promise<ProviderUsageData>;
232
+ refreshToken?(auth: OAuthCredentials): Promise<RefreshedCredentials>;
233
+ }
@@ -0,0 +1,70 @@
1
+ import { z } from 'zod';
2
+ import type { BasePlugin } from './base.ts';
3
+
4
+ export const ThemeColorsSchema = z.object({
5
+ background: z.string(),
6
+ foreground: z.string(),
7
+ text: z.string(),
8
+ textMuted: z.string(),
9
+ textSubtle: z.string(),
10
+ primary: z.string(),
11
+ secondary: z.string(),
12
+ accent: z.string(),
13
+ success: z.string(),
14
+ warning: z.string(),
15
+ error: z.string(),
16
+ info: z.string(),
17
+ border: z.string(),
18
+ borderMuted: z.string(),
19
+ selection: z.string(),
20
+ highlight: z.string(),
21
+ gaugeBackground: z.string(),
22
+ gaugeFill: z.string(),
23
+ gaugeWarning: z.string(),
24
+ gaugeDanger: z.string(),
25
+ });
26
+
27
+ export type ThemeColors = z.infer<typeof ThemeColorsSchema>;
28
+
29
+ export const ThemeComponentsSchema = z.object({
30
+ header: z.object({
31
+ background: z.string().optional(),
32
+ foreground: z.string().optional(),
33
+ titleColor: z.string().optional(),
34
+ titleAccentColor: z.string().optional(),
35
+ }).optional(),
36
+ statusBar: z.object({
37
+ background: z.string().optional(),
38
+ foreground: z.string().optional(),
39
+ }).optional(),
40
+ commandPalette: z.object({
41
+ background: z.string().optional(),
42
+ border: z.string().optional(),
43
+ }).optional(),
44
+ gauge: z.object({
45
+ height: z.number().optional(),
46
+ borderRadius: z.number().optional(),
47
+ }).optional(),
48
+ });
49
+
50
+ export type ThemeComponents = z.infer<typeof ThemeComponentsSchema>;
51
+
52
+ export type ColorScheme = 'light' | 'dark';
53
+
54
+ export type ColorSchemePreference = 'auto' | 'light' | 'dark';
55
+
56
+ export interface ThemePlugin extends BasePlugin {
57
+ readonly type: 'theme';
58
+ readonly family: string;
59
+ readonly colorScheme: ColorScheme;
60
+ readonly colors: ThemeColors;
61
+ readonly components?: ThemeComponents;
62
+ }
63
+
64
+ export interface ResolvedTheme {
65
+ id: string;
66
+ name: string;
67
+ colorScheme: ColorScheme;
68
+ colors: ThemeColors;
69
+ components: ThemeComponents;
70
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Plugin update checker.
3
+ *
4
+ * Queries the npm registry for the latest published version of each
5
+ * npm-sourced plugin and compares it to the currently installed version.
6
+ * Results are cached for the lifetime of the process (one check per plugin).
7
+ *
8
+ * Only npm-published plugins are checked — builtin and local plugins are
9
+ * skipped because they have no registry entry to compare against.
10
+ */
11
+
12
+ import type { PluginType } from './types/base.ts';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface PluginUpdateInfo {
19
+ /** npm package name (e.g. "@tokentop/agent-opencode") */
20
+ packageName: string;
21
+ /** Currently installed version */
22
+ currentVersion: string;
23
+ /** Latest version on the registry, or null if the lookup failed */
24
+ latestVersion: string | null;
25
+ /** True when latestVersion is newer than currentVersion */
26
+ hasUpdate: boolean;
27
+ /** Non-null when the registry fetch failed */
28
+ error?: string;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Simple semver comparison (major.minor.patch only, no pre-release)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function parseVersion(v: string): [number, number, number] | null {
36
+ const clean = v.replace(/^v/, '');
37
+ const match = clean.match(/^(\d+)\.(\d+)\.(\d+)/);
38
+ if (!match) return null;
39
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
40
+ }
41
+
42
+ /**
43
+ * Returns true if `latest` is strictly newer than `current`.
44
+ */
45
+ function isNewer(current: string, latest: string): boolean {
46
+ const a = parseVersion(current);
47
+ const b = parseVersion(latest);
48
+ if (!a || !b) return false;
49
+ if (b[0] !== a[0]) return b[0] > a[0];
50
+ if (b[1] !== a[1]) return b[1] > a[1];
51
+ return b[2] > a[2];
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // npm registry fetcher
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Fetch the latest dist-tag version for `packageName` from the npm registry.
60
+ * Uses the abbreviated metadata endpoint for minimal payload.
61
+ */
62
+ async function fetchLatestVersion(packageName: string): Promise<string> {
63
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName).replace('%40', '@')}`;
64
+ const res = await fetch(url, {
65
+ headers: { Accept: 'application/vnd.npm.install-v1+json' },
66
+ signal: AbortSignal.timeout(8_000),
67
+ });
68
+
69
+ if (!res.ok) {
70
+ throw new Error(`npm registry returned ${res.status} for ${packageName}`);
71
+ }
72
+
73
+ const data = (await res.json()) as { 'dist-tags'?: { latest?: string } };
74
+ const latest = data?.['dist-tags']?.latest;
75
+ if (!latest) {
76
+ throw new Error(`No dist-tags.latest found for ${packageName}`);
77
+ }
78
+ return latest;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Package name convention
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Derive the npm package name from a plugin's id and type.
87
+ *
88
+ * Convention:
89
+ * Official → @tokentop/{type}-{id} (e.g. @tokentop/agent-opencode)
90
+ * Community → tokentop-{type}-{id} (e.g. tokentop-provider-replicate)
91
+ *
92
+ * Since we can't know whether a given plugin is official or community from
93
+ * the plugin object alone, we check the official name first and fall back
94
+ * to the community pattern if the registry 404s.
95
+ */
96
+ export function derivePackageName(pluginId: string, pluginType: PluginType): string {
97
+ return `@tokentop/${pluginType}-${pluginId}`;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Update checker
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /** In-memory cache: packageName → PluginUpdateInfo */
105
+ const cache = new Map<string, PluginUpdateInfo>();
106
+
107
+ /**
108
+ * Check a single plugin for updates.
109
+ * Returns cached result if already checked this session.
110
+ */
111
+ export async function checkPluginUpdate(
112
+ pluginId: string,
113
+ pluginType: PluginType,
114
+ currentVersion: string,
115
+ packageName?: string,
116
+ ): Promise<PluginUpdateInfo> {
117
+ const pkg = packageName ?? derivePackageName(pluginId, pluginType);
118
+
119
+ const cached = cache.get(pkg);
120
+ if (cached) return cached;
121
+
122
+ try {
123
+ const latestVersion = await fetchLatestVersion(pkg);
124
+ const info: PluginUpdateInfo = {
125
+ packageName: pkg,
126
+ currentVersion,
127
+ latestVersion,
128
+ hasUpdate: isNewer(currentVersion, latestVersion),
129
+ };
130
+ cache.set(pkg, info);
131
+ return info;
132
+ } catch (err) {
133
+ const info: PluginUpdateInfo = {
134
+ packageName: pkg,
135
+ currentVersion,
136
+ latestVersion: null,
137
+ hasUpdate: false,
138
+ error: err instanceof Error ? err.message : String(err),
139
+ };
140
+ cache.set(pkg, info);
141
+ return info;
142
+ }
143
+ }
144
+
145
+ export interface CheckablePlugin {
146
+ id: string;
147
+ type: PluginType;
148
+ version: string;
149
+ /** Override the derived package name (for npm-installed plugins whose spec is known) */
150
+ packageName?: string;
151
+ }
152
+
153
+ /**
154
+ * Check multiple plugins for updates in parallel.
155
+ * Only checks plugins that appear to be npm-published (official or community naming).
156
+ * Returns a Map keyed by `{type}-{id}` for easy lookup.
157
+ */
158
+ export async function checkAllPluginUpdates(
159
+ plugins: CheckablePlugin[],
160
+ ): Promise<Map<string, PluginUpdateInfo>> {
161
+ const results = new Map<string, PluginUpdateInfo>();
162
+
163
+ const checks = plugins.map(async (p) => {
164
+ const info = await checkPluginUpdate(p.id, p.type, p.version, p.packageName);
165
+ results.set(`${p.type}-${p.id}`, info);
166
+ });
167
+
168
+ await Promise.allSettled(checks);
169
+ return results;
170
+ }
171
+
172
+ /**
173
+ * Clear the in-memory cache (useful for manual refresh).
174
+ */
175
+ export function clearUpdateCache(): void {
176
+ cache.clear();
177
+ }
@@ -0,0 +1,181 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ModelPricing } from '@/plugins/types/provider.ts';
3
+ import {
4
+ estimateCost,
5
+ estimateSessionCost,
6
+ formatCost,
7
+ formatTokenCount,
8
+ } from './estimator.ts';
9
+
10
+ describe('estimateCost()', () => {
11
+ test('calculates input and output costs for a typical coding session', () => {
12
+ const usage = { input: 150_000, output: 50_000 };
13
+ const pricing: ModelPricing = { input: 3, output: 15 };
14
+
15
+ expect(estimateCost(usage, pricing)).toEqual({
16
+ total: 1.2,
17
+ input: 0.45,
18
+ output: 0.75,
19
+ currency: 'USD',
20
+ });
21
+ });
22
+
23
+ test('includes cache read and write costs when both usage and pricing are present', () => {
24
+ const usage = { input: 100_000, output: 25_000, cacheRead: 80_000, cacheWrite: 40_000 };
25
+ const pricing: ModelPricing = {
26
+ input: 3,
27
+ output: 15,
28
+ cacheRead: 0.3,
29
+ cacheWrite: 3.75,
30
+ };
31
+
32
+ expect(estimateCost(usage, pricing)).toEqual({
33
+ total: 0.849,
34
+ input: 0.3,
35
+ output: 0.375,
36
+ cacheRead: 0.024,
37
+ cacheWrite: 0.15,
38
+ currency: 'USD',
39
+ });
40
+ });
41
+
42
+ test('does not include cache fields when cache pricing is unavailable', () => {
43
+ const usage = { input: 10_000, output: 20_000, cacheRead: 5_000, cacheWrite: 7_000 };
44
+ const pricing: ModelPricing = { input: 2, output: 8 };
45
+
46
+ expect(estimateCost(usage, pricing)).toEqual({
47
+ total: 0.18,
48
+ input: 0.02,
49
+ output: 0.16,
50
+ currency: 'USD',
51
+ });
52
+ });
53
+
54
+ test('returns zero cost for zero token usage', () => {
55
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
56
+ const pricing: ModelPricing = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 };
57
+
58
+ expect(estimateCost(usage, pricing)).toEqual({
59
+ total: 0,
60
+ input: 0,
61
+ output: 0,
62
+ currency: 'USD',
63
+ });
64
+ });
65
+
66
+ test('handles very large token counts correctly', () => {
67
+ const usage = {
68
+ input: 123_456_789,
69
+ output: 987_654_321,
70
+ cacheRead: 55_555_555,
71
+ cacheWrite: 44_444_444,
72
+ };
73
+ const pricing: ModelPricing = {
74
+ input: 1.25,
75
+ output: 10,
76
+ cacheRead: 0.1,
77
+ cacheWrite: 1,
78
+ };
79
+
80
+ expect(estimateCost(usage, pricing)).toEqual({
81
+ total: 10080.864196,
82
+ input: 154.320986,
83
+ output: 9876.54321,
84
+ cacheRead: 5.555556,
85
+ cacheWrite: 44.444444,
86
+ currency: 'USD',
87
+ });
88
+ });
89
+
90
+ test('rounds costs to six decimal places', () => {
91
+ const usage = { input: 1, output: 1 };
92
+ const pricing: ModelPricing = { input: 1.234567, output: 2.345678 };
93
+
94
+ expect(estimateCost(usage, pricing)).toEqual({
95
+ total: 0.000004,
96
+ input: 0.000001,
97
+ output: 0.000002,
98
+ currency: 'USD',
99
+ });
100
+ });
101
+ });
102
+
103
+ describe('estimateSessionCost()', () => {
104
+ test('aggregates multiple sessions before estimating cost', () => {
105
+ const sessions = [
106
+ { input: 100_000, output: 20_000, cacheRead: 10_000, cacheWrite: 5_000 },
107
+ { input: 50_000, output: 10_000, cacheRead: 5_000, cacheWrite: 10_000 },
108
+ { input: 25_000, output: 5_000 },
109
+ ];
110
+ const pricing: ModelPricing = {
111
+ input: 3,
112
+ output: 15,
113
+ cacheRead: 0.3,
114
+ cacheWrite: 3.75,
115
+ };
116
+
117
+ expect(estimateSessionCost(sessions, pricing)).toEqual({
118
+ total: 1.11075,
119
+ input: 0.525,
120
+ output: 0.525,
121
+ cacheRead: 0.0045,
122
+ cacheWrite: 0.05625,
123
+ currency: 'USD',
124
+ });
125
+ });
126
+
127
+ test('returns zero cost for an empty session list', () => {
128
+ const pricing: ModelPricing = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 };
129
+
130
+ expect(estimateSessionCost([], pricing)).toEqual({
131
+ total: 0,
132
+ input: 0,
133
+ output: 0,
134
+ currency: 'USD',
135
+ });
136
+ });
137
+
138
+ test('matches estimateCost for a single session', () => {
139
+ const session = { input: 150_000, output: 50_000, cacheRead: 80_000, cacheWrite: 40_000 };
140
+ const pricing: ModelPricing = {
141
+ input: 3,
142
+ output: 15,
143
+ cacheRead: 0.3,
144
+ cacheWrite: 3.75,
145
+ };
146
+
147
+ expect(estimateSessionCost([session], pricing)).toEqual(estimateCost(session, pricing));
148
+ });
149
+ });
150
+
151
+ describe('formatCost()', () => {
152
+ test('formats USD costs below $0.01 with 4 decimal places', () => {
153
+ expect(formatCost(0.009876)).toBe('$0.0099');
154
+ });
155
+
156
+ test('formats USD costs below $1 with 3 decimal places', () => {
157
+ expect(formatCost(0.45678)).toBe('$0.457');
158
+ });
159
+
160
+ test('formats USD costs at or above $1 with 2 decimal places', () => {
161
+ expect(formatCost(12.3456)).toBe('$12.35');
162
+ });
163
+
164
+ test('formats non-USD currency with 4 decimals and currency suffix', () => {
165
+ expect(formatCost(12.3456, 'EUR')).toBe('12.3456 EUR');
166
+ });
167
+ });
168
+
169
+ describe('formatTokenCount()', () => {
170
+ test('returns raw token count for values below 1K', () => {
171
+ expect(formatTokenCount(999)).toBe('999');
172
+ });
173
+
174
+ test('formats token count in K for values below 1M', () => {
175
+ expect(formatTokenCount(12_500)).toBe('12.5K');
176
+ });
177
+
178
+ test('formats token count in M for values at or above 1M', () => {
179
+ expect(formatTokenCount(2_345_678)).toBe('2.35M');
180
+ });
181
+ });