@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.
- package/package.json +9 -4
- package/src/agents/aggregator.ts +8 -1
- package/src/agents/types.ts +3 -0
- package/src/cli.ts +2 -2
- package/src/demo/simulator.ts +1 -1
- package/src/plugins/agents/index.ts +2 -0
- package/src/plugins/auth-sources.ts +2 -3
- package/src/plugins/lifecycle.ts +17 -13
- package/src/plugins/loader.test.ts +1 -1
- package/src/plugins/loader.ts +15 -7
- package/src/plugins/notification-bus.ts +5 -2
- package/src/plugins/notifications/terminal-bell.ts +9 -2
- package/src/plugins/notifications/visual-flash.ts +13 -3
- package/src/plugins/plugin-context-factory.ts +4 -19
- package/src/plugins/providers/anthropic.ts +2 -2
- package/src/plugins/providers/antigravity.ts +1 -1
- package/src/plugins/providers/chutes.ts +189 -0
- package/src/plugins/providers/codex.ts +1 -1
- package/src/plugins/providers/gemini.ts +1 -1
- package/src/plugins/providers/github-copilot.ts +1 -1
- package/src/plugins/providers/index.ts +1 -0
- package/src/plugins/providers/minimax.ts +1 -1
- package/src/plugins/providers/openai-api.ts +1 -1
- package/src/plugins/providers/opencode-zen.ts +1 -1
- package/src/plugins/providers/perplexity.ts +1 -1
- package/src/plugins/providers/zai.ts +4 -2
- package/src/plugins/registry.ts +51 -46
- package/src/plugins/sandbox-guard.test.ts +1 -1
- package/src/plugins/sandbox-guard.ts +1 -1
- package/src/plugins/sandbox.ts +1 -1
- package/src/plugins/themes/catppuccin-latte.ts +1 -1
- package/src/plugins/themes/catppuccin-mocha.ts +1 -1
- package/src/plugins/themes/claude-code.ts +1 -1
- package/src/plugins/themes/dracula.ts +1 -1
- package/src/plugins/themes/github-light.ts +1 -1
- package/src/plugins/themes/gruvbox-dark.ts +1 -1
- package/src/plugins/themes/gruvbox-light.ts +1 -1
- package/src/plugins/themes/kanagawa.ts +1 -1
- package/src/plugins/themes/nord.ts +1 -1
- package/src/plugins/themes/one-dark.ts +1 -1
- package/src/plugins/themes/opencode.ts +1 -1
- package/src/plugins/themes/rose-pine-dawn.ts +1 -1
- package/src/plugins/themes/rose-pine.ts +1 -1
- package/src/plugins/themes/solarized-light.ts +1 -1
- package/src/plugins/themes/tokyo-night.ts +1 -1
- package/src/plugins/update-checker.ts +2 -2
- package/src/pricing/estimator.test.ts +1 -1
- package/src/pricing/estimator.ts +1 -1
- package/src/pricing/fallback.ts +1 -1
- package/src/pricing/index.ts +1 -1
- package/src/pricing/models-dev.ts +1 -1
- package/src/storage/index.ts +6 -0
- package/src/storage/migrations/index.ts +17 -0
- package/src/storage/persistence-service.ts +206 -0
- package/src/storage/repos/pluginStorage.ts +35 -0
- package/src/storage/retention.ts +146 -0
- package/src/tui/App.tsx +1 -1
- package/src/tui/components/DebugPanel.tsx +23 -10
- package/src/tui/components/ProviderCard.tsx +1 -1
- package/src/tui/components/ProviderDetailPanel.tsx +1 -1
- package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
- package/src/tui/components/SessionsTable.tsx +15 -4
- package/src/tui/components/SettingsModal.tsx +383 -76
- package/src/tui/components/ThemePicker.tsx +1 -1
- package/src/tui/contexts/AgentSessionContext.tsx +44 -80
- package/src/tui/contexts/PluginContext.tsx +13 -3
- package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
- package/src/tui/contexts/StorageContext.tsx +42 -130
- package/src/tui/contexts/ThemeContext.tsx +3 -3
- package/src/tui/hooks/useExitAnimation.ts +4 -0
- package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
- package/src/tui/views/Dashboard.tsx +40 -3
- package/src/tui/views/ProjectsView.tsx +29 -6
- package/src/tui/views/RealTimeDashboard.tsx +29 -8
- package/src/tui/views/SettingsView.tsx +9 -4
- package/src/version.ts +1 -1
- package/src/plugins/types/agent.ts +0 -108
- package/src/plugins/types/base.ts +0 -278
- package/src/plugins/types/index.ts +0 -19
- package/src/plugins/types/notification.ts +0 -51
- package/src/plugins/types/provider.ts +0 -237
- package/src/plugins/types/theme.ts +0 -78
- package/src/storage/database.ts +0 -623
package/src/plugins/registry.ts
CHANGED
|
@@ -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 "
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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))
|
|
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))
|
|
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))
|
|
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))
|
|
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
|
-
|
|
152
|
-
|
|
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 ${
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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)
|
package/src/plugins/sandbox.ts
CHANGED
|
@@ -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 "
|
|
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).
|
|
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 "
|
|
2
|
+
import type { ModelPricing } from "@tokentop/plugin-sdk";
|
|
3
3
|
import { estimateCost, estimateSessionCost, formatCost, formatTokenCount } from "./estimator.ts";
|
|
4
4
|
|
|
5
5
|
describe("estimateCost()", () => {
|
package/src/pricing/estimator.ts
CHANGED
package/src/pricing/fallback.ts
CHANGED
package/src/pricing/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ModelPricing, ProviderPlugin, ProviderPricing } from "
|
|
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,
|
package/src/storage/index.ts
CHANGED
|
@@ -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
|
+
}
|