@tokentop/ttop 0.3.0 → 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.
- package/package.json +3 -3
- package/src/plugins/lifecycle.ts +17 -13
- package/src/plugins/notifications/terminal-bell.ts +8 -1
- package/src/plugins/notifications/visual-flash.ts +12 -2
- package/src/plugins/providers/zai.ts +3 -1
- package/src/plugins/registry.ts +42 -37
- package/src/storage/persistence-service.ts +206 -0
- package/src/storage/retention.ts +146 -0
- package/src/tui/components/DebugPanel.tsx +23 -10
- package/src/tui/components/SessionsTable.tsx +6 -2
- package/src/tui/components/SettingsModal.tsx +383 -76
- package/src/tui/contexts/AgentSessionContext.tsx +43 -79
- package/src/tui/contexts/PluginContext.tsx +6 -0
- package/src/tui/contexts/StorageContext.tsx +42 -130
- package/src/tui/hooks/useExitAnimation.ts +4 -0
- 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 +8 -3
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokentop/ttop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Real-time AI token usage monitor - htop for your API costs",
|
|
5
5
|
"author": "Nigel Bazzeghin <nbazzeghin@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"snapshot": "bun src/tui/debug/snapshot.tsx"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@opentui/core": "0.1.
|
|
37
|
-
"@opentui/react": "0.1.
|
|
36
|
+
"@opentui/core": "0.1.81",
|
|
37
|
+
"@opentui/react": "0.1.81",
|
|
38
38
|
"@tokentop/agent-claude-code": "^1.1.0",
|
|
39
39
|
"@tokentop/agent-opencode": "^1.0.0",
|
|
40
40
|
"@tokentop/plugin-sdk": "^1.1.0",
|
package/src/plugins/lifecycle.ts
CHANGED
|
@@ -91,25 +91,29 @@ class PluginLifecycleManager {
|
|
|
91
91
|
async initializeAll(): Promise<void> {
|
|
92
92
|
const plugins = pluginRegistry.getAllPlugins();
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
await Promise.all(
|
|
95
|
+
plugins.map(async (plugin) => {
|
|
96
|
+
const entry = this.getOrCreate(plugin);
|
|
97
|
+
if (entry.state !== "loaded") return;
|
|
98
|
+
|
|
99
|
+
const ok = await this.invokeHook(plugin, "initialize");
|
|
100
|
+
entry.state = ok ? "initialized" : "failed";
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
async startAll(): Promise<void> {
|
|
104
106
|
const plugins = pluginRegistry.getAllPlugins();
|
|
105
107
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
await Promise.all(
|
|
109
|
+
plugins.map(async (plugin) => {
|
|
110
|
+
const entry = this.getOrCreate(plugin);
|
|
111
|
+
if (entry.state !== "initialized") return;
|
|
109
112
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
+
const ok = await this.invokeHook(plugin, "start");
|
|
114
|
+
entry.state = ok ? "started" : "failed";
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
async stopAll(): Promise<void> {
|
|
@@ -18,8 +18,15 @@ export const terminalBellPlugin: NotificationPlugin = {
|
|
|
18
18
|
permissions: {},
|
|
19
19
|
|
|
20
20
|
configSchema: {
|
|
21
|
-
enabled: {
|
|
21
|
+
enabled: {
|
|
22
|
+
type: "boolean",
|
|
23
|
+
label: "Enabled",
|
|
24
|
+
default: true,
|
|
25
|
+
description: "Enable terminal bell notifications",
|
|
26
|
+
},
|
|
22
27
|
minSeverity: {
|
|
28
|
+
label: "Minimum severity",
|
|
29
|
+
description: "Only trigger bell for alerts at this severity or higher.",
|
|
23
30
|
type: "select",
|
|
24
31
|
default: "warning",
|
|
25
32
|
options: [
|
|
@@ -18,8 +18,18 @@ export const visualFlashPlugin: NotificationPlugin = {
|
|
|
18
18
|
permissions: {},
|
|
19
19
|
|
|
20
20
|
configSchema: {
|
|
21
|
-
enabled: {
|
|
22
|
-
|
|
21
|
+
enabled: {
|
|
22
|
+
type: "boolean",
|
|
23
|
+
label: "Enabled",
|
|
24
|
+
default: true,
|
|
25
|
+
description: "Enable visual flash notifications",
|
|
26
|
+
},
|
|
27
|
+
duration: {
|
|
28
|
+
type: "number",
|
|
29
|
+
label: "Flash duration (ms)",
|
|
30
|
+
default: 100,
|
|
31
|
+
description: "Flash duration in milliseconds",
|
|
32
|
+
},
|
|
23
33
|
},
|
|
24
34
|
|
|
25
35
|
supports(event: NotificationEvent): boolean {
|
|
@@ -64,7 +64,9 @@ export const zaiCodingPlanPlugin: ProviderPlugin = {
|
|
|
64
64
|
auth: {
|
|
65
65
|
async discover(ctx: PluginContext): Promise<CredentialResult> {
|
|
66
66
|
// 1. Try OpenCode auth (api, wellknown, or oauth)
|
|
67
|
-
const entry =
|
|
67
|
+
const entry =
|
|
68
|
+
(await ctx.authSources.opencode.getProviderEntry("zhipuai-coding-plan")) ||
|
|
69
|
+
(await ctx.authSources.opencode.getProviderEntry("zai-coding-plan"));
|
|
68
70
|
if (entry) {
|
|
69
71
|
if (entry.type === "api" && entry.key) {
|
|
70
72
|
return { ok: true, credentials: { apiKey: entry.key, source: "opencode" } };
|
package/src/plugins/registry.ts
CHANGED
|
@@ -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})`);
|
|
@@ -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,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data retention policy — prevents unbounded database growth.
|
|
3
|
+
*
|
|
4
|
+
* Detailed snapshot data (agent_session_snapshots, agent_session_stream_snapshots,
|
|
5
|
+
* provider_snapshots) is pruned after a retention window. Aggregated data in
|
|
6
|
+
* usage_events, hourly_aggregates, and daily_aggregates is kept longer since
|
|
7
|
+
* those tables grow much more slowly.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getDatabase, isDatabaseInitialized } from "./db.ts";
|
|
11
|
+
|
|
12
|
+
/** Keep detailed snapshots for 7 days */
|
|
13
|
+
const SNAPSHOT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
/** Keep provider snapshots for 7 days */
|
|
16
|
+
const PROVIDER_SNAPSHOT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
/** Keep usage events for 90 days */
|
|
19
|
+
const USAGE_EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
/** Maximum rows to delete in a single batch (prevents long-running transactions) */
|
|
22
|
+
const PRUNE_BATCH_SIZE = 10_000;
|
|
23
|
+
|
|
24
|
+
export interface PruneResult {
|
|
25
|
+
agentSessionSnapshots: number;
|
|
26
|
+
providerSnapshots: number;
|
|
27
|
+
usageEvents: number;
|
|
28
|
+
durationMs: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Prune old data from the database. Deletes in batches to avoid
|
|
33
|
+
* holding a write lock for too long.
|
|
34
|
+
*/
|
|
35
|
+
export function pruneOldData(nowMs: number = Date.now()): PruneResult {
|
|
36
|
+
if (!isDatabaseInitialized()) {
|
|
37
|
+
return { agentSessionSnapshots: 0, providerSnapshots: 0, usageEvents: 0, durationMs: 0 };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const db = getDatabase();
|
|
41
|
+
const start = performance.now();
|
|
42
|
+
|
|
43
|
+
let agentSessionSnapshots = 0;
|
|
44
|
+
let providerSnapshots = 0;
|
|
45
|
+
let usageEvents = 0;
|
|
46
|
+
|
|
47
|
+
const snapshotCutoff = nowMs - SNAPSHOT_RETENTION_MS;
|
|
48
|
+
const providerCutoff = nowMs - PROVIDER_SNAPSHOT_RETENTION_MS;
|
|
49
|
+
const usageEventCutoff = nowMs - USAGE_EVENT_RETENTION_MS;
|
|
50
|
+
|
|
51
|
+
// Prune agent_session_snapshots in batches.
|
|
52
|
+
// CASCADE delete handles agent_session_stream_snapshots automatically.
|
|
53
|
+
{
|
|
54
|
+
const stmt = db.prepare(`
|
|
55
|
+
DELETE FROM agent_session_snapshots
|
|
56
|
+
WHERE id IN (
|
|
57
|
+
SELECT id FROM agent_session_snapshots
|
|
58
|
+
WHERE timestamp < ?
|
|
59
|
+
LIMIT ?
|
|
60
|
+
)
|
|
61
|
+
`);
|
|
62
|
+
|
|
63
|
+
let deleted: number;
|
|
64
|
+
do {
|
|
65
|
+
const result = stmt.run(snapshotCutoff, PRUNE_BATCH_SIZE);
|
|
66
|
+
deleted = result.changes;
|
|
67
|
+
agentSessionSnapshots += deleted;
|
|
68
|
+
} while (deleted === PRUNE_BATCH_SIZE);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Prune old provider_snapshots in batches
|
|
72
|
+
{
|
|
73
|
+
const stmt = db.prepare(`
|
|
74
|
+
DELETE FROM provider_snapshots
|
|
75
|
+
WHERE id IN (
|
|
76
|
+
SELECT id FROM provider_snapshots
|
|
77
|
+
WHERE timestamp < ?
|
|
78
|
+
LIMIT ?
|
|
79
|
+
)
|
|
80
|
+
`);
|
|
81
|
+
|
|
82
|
+
let deleted: number;
|
|
83
|
+
do {
|
|
84
|
+
const result = stmt.run(providerCutoff, PRUNE_BATCH_SIZE);
|
|
85
|
+
deleted = result.changes;
|
|
86
|
+
providerSnapshots += deleted;
|
|
87
|
+
} while (deleted === PRUNE_BATCH_SIZE);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Prune old usage_events in batches
|
|
91
|
+
{
|
|
92
|
+
const stmt = db.prepare(`
|
|
93
|
+
DELETE FROM usage_events
|
|
94
|
+
WHERE id IN (
|
|
95
|
+
SELECT id FROM usage_events
|
|
96
|
+
WHERE timestamp < ?
|
|
97
|
+
LIMIT ?
|
|
98
|
+
)
|
|
99
|
+
`);
|
|
100
|
+
|
|
101
|
+
let deleted: number;
|
|
102
|
+
do {
|
|
103
|
+
const result = stmt.run(usageEventCutoff, PRUNE_BATCH_SIZE);
|
|
104
|
+
deleted = result.changes;
|
|
105
|
+
usageEvents += deleted;
|
|
106
|
+
} while (deleted === PRUNE_BATCH_SIZE);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const durationMs = Math.round(performance.now() - start);
|
|
110
|
+
|
|
111
|
+
return { agentSessionSnapshots, providerSnapshots, usageEvents, durationMs };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Run incremental auto_vacuum if supported (WAL mode).
|
|
116
|
+
* This reclaims free pages without blocking readers.
|
|
117
|
+
*/
|
|
118
|
+
export function incrementalVacuum(pages = 1000): void {
|
|
119
|
+
if (!isDatabaseInitialized()) return;
|
|
120
|
+
|
|
121
|
+
const db = getDatabase();
|
|
122
|
+
try {
|
|
123
|
+
db.exec(`PRAGMA incremental_vacuum(${pages})`);
|
|
124
|
+
} catch {
|
|
125
|
+
// Not all builds support incremental vacuum — silently skip
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get the database file size in bytes. Returns 0 if unable to determine.
|
|
131
|
+
*/
|
|
132
|
+
export function getDatabaseSizeBytes(): number {
|
|
133
|
+
if (!isDatabaseInitialized()) return 0;
|
|
134
|
+
|
|
135
|
+
const db = getDatabase();
|
|
136
|
+
try {
|
|
137
|
+
const row = db.prepare("PRAGMA page_count").get() as { page_count: number } | null;
|
|
138
|
+
const sizeRow = db.prepare("PRAGMA page_size").get() as { page_size: number } | null;
|
|
139
|
+
if (row && sizeRow) {
|
|
140
|
+
return row.page_count * sizeRow.page_size;
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// ignore
|
|
144
|
+
}
|
|
145
|
+
return 0;
|
|
146
|
+
}
|