@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokentop/ttop",
3
- "version": "0.3.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.80",
37
- "@opentui/react": "0.1.80",
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",
@@ -91,25 +91,29 @@ class PluginLifecycleManager {
91
91
  async initializeAll(): Promise<void> {
92
92
  const plugins = pluginRegistry.getAllPlugins();
93
93
 
94
- for (const plugin of plugins) {
95
- const entry = this.getOrCreate(plugin);
96
- if (entry.state !== "loaded") continue;
97
-
98
- const ok = await this.invokeHook(plugin, "initialize");
99
- entry.state = ok ? "initialized" : "failed";
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
- for (const plugin of plugins) {
107
- const entry = this.getOrCreate(plugin);
108
- if (entry.state !== "initialized") continue;
108
+ await Promise.all(
109
+ plugins.map(async (plugin) => {
110
+ const entry = this.getOrCreate(plugin);
111
+ if (entry.state !== "initialized") return;
109
112
 
110
- const ok = await this.invokeHook(plugin, "start");
111
- entry.state = ok ? "started" : "failed";
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: { type: "boolean", default: true, description: "Enable terminal bell notifications" },
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: { type: "boolean", default: true, description: "Enable visual flash notifications" },
22
- duration: { type: "number", default: 100, description: "Flash duration in milliseconds" },
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 = await ctx.authSources.opencode.getProviderEntry("zai-coding-plan");
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" } };
@@ -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})`);
@@ -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
+ }