Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 155 additions & 1 deletion src/main/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ import * as schema from "./db.schema";
let _db: ReturnType<typeof drizzle> | undefined;
let _sqlite: InstanceType<typeof Database> | undefined;

/**
* Monotonic counter bumped ONLY by writes the profile actually reads — the
* durable usage_events log (dbAppendUsageEvents) and identity edits. The profile
* caches key on this, so high-frequency chat persistence (runtime snapshots/
* turns) does NOT churn the cache during active sessions.
*/
let _profileDataGeneration = 0;
export function getProfileDataGeneration(): number {
return _profileDataGeneration;
}
export function bumpProfileDataGeneration(): void {
_profileDataGeneration++;
}

/** How long durable usage events are retained (well beyond the 364-day heatmap). */
const USAGE_EVENTS_RETENTION_DAYS = 730;

export function initDatabase(dbPath: string) {
console.log(`[db] opening ${dbPath}`);
const sqlite = new Database(dbPath);
Expand Down Expand Up @@ -98,11 +115,24 @@ export function initDatabase(dbPath: string) {
todos TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
kind TEXT NOT NULL,
provider TEXT,
model TEXT,
mode TEXT,
fast INTEGER NOT NULL DEFAULT 0,
effort TEXT,
name TEXT,
value INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_usage_events_kind ON usage_events (kind);
`);

// Baseline schema version for future DB migrations.
// New upgrade steps should live behind this gate when we need them.
const SCHEMA_VERSION = 16;
const SCHEMA_VERSION = 19;

const storedVersion = Number(
(
Expand Down Expand Up @@ -260,13 +290,41 @@ export function initDatabase(dbPath: string) {
`);
}

if (storedVersion < 19) {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
kind TEXT NOT NULL,
provider TEXT,
model TEXT,
mode TEXT,
fast INTEGER NOT NULL DEFAULT 0,
effort TEXT,
name TEXT,
value INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_usage_events_kind ON usage_events (kind);
`);
}

sqlite
.prepare(
"INSERT INTO app_state (key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.run(String(SCHEMA_VERSION));
}

// Bound the durable usage log: drop events older than the retention window so
// a long-lived install can't accumulate unboundedly (aggregation reads scan
// this table). Runs once per startup; cheap on a bounded table.
try {
const cutoff = Date.now() - USAGE_EVENTS_RETENTION_DAYS * 86_400_000;
sqlite.prepare("DELETE FROM usage_events WHERE ts < ?").run(cutoff);
} catch {
// usage_events may not exist on a partially-migrated db; ignore.
}

console.log("[db] initialized");
return _db;
}
Expand Down Expand Up @@ -713,6 +771,8 @@ function replaceThreadContextUsageInSqlite(
sqlite.prepare("DELETE FROM thread_context_usage WHERE thread_id = ?").run(threadId);
return;
}
// Token usage is captured durably at the canonical-event layer (usage_events),
// not here — this row is only the live context-window snapshot for the UI.
sqlite
.prepare(
`INSERT INTO thread_context_usage (thread_id, usage) VALUES (?, ?)
Expand Down Expand Up @@ -752,6 +812,100 @@ function safeParse(json: string): unknown {
}
}

// ── Durable usage-events log (decoupled from thread lifecycle) ──────
//
// Append-only, NO foreign key to threads — so usage stats survive thread delete
// and archive. Provider/model/mode are denormalized at write time (captured at
// the canonical-event layer in the renderer), so aggregation never needs to join
// back to a thread that may no longer exist.

export interface UsageEventInput {
ts: number;
kind: string;
provider?: string | null | undefined;
model?: string | null | undefined;
mode?: string | null | undefined;
fast?: boolean | undefined;
effort?: string | null | undefined;
name?: string | null | undefined;
value?: number | undefined;
}

export interface UsageEventRow {
ts: number;
kind: string;
provider: string | null;
model: string | null;
mode: string | null;
fast: boolean;
effort: string | null;
name: string | null;
value: number;
}

export function dbAppendUsageEvents(events: readonly UsageEventInput[]): void {
if (!_sqlite) throw new Error("Database not initialized");
if (events.length === 0) return;
const stmt = _sqlite.prepare(
"INSERT INTO usage_events (ts, kind, provider, model, mode, fast, effort, name, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
);
_sqlite.transaction((rows: readonly UsageEventInput[]) => {
for (const e of rows) {
stmt.run(
e.ts,
e.kind,
e.provider ?? null,
e.model ?? null,
e.mode ?? null,
e.fast ? 1 : 0,
e.effort ?? null,
e.name ?? null,
e.value ?? 1,
);
}
})(events);
bumpProfileDataGeneration();
}

// Cache the full-table read keyed on the profile generation so the two readers
// of a single profile open (coreStats + tokenStats) share one scan, and
// repeat opens between writes don't rescan at all. Invalidated implicitly: any
// usage write bumps the generation, so a stale generation never matches.
let _usageEventsCache: { generation: number; rows: UsageEventRow[] } | undefined;

export function dbGetAllUsageEvents(): UsageEventRow[] {
if (!_sqlite) throw new Error("Database not initialized");
if (_usageEventsCache && _usageEventsCache.generation === _profileDataGeneration) {
return _usageEventsCache.rows;
}
const rows = _sqlite
.prepare("SELECT ts, kind, provider, model, mode, fast, effort, name, value FROM usage_events")
.all() as Array<{
ts: number;
kind: string;
provider: string | null;
model: string | null;
mode: string | null;
fast: number;
effort: string | null;
name: string | null;
value: number;
}>;
const mapped: UsageEventRow[] = rows.map((r) => ({
ts: r.ts,
kind: r.kind,
provider: r.provider,
model: r.model,
mode: r.mode,
fast: r.fast === 1,
effort: r.effort,
name: r.name,
value: r.value,
}));
_usageEventsCache = { generation: _profileDataGeneration, rows: mapped };
return mapped;
}

export function dbDeleteProject(projectId: string): void {
const db = getDb();
db.delete(schema.projects).where(eq(schema.projects.id, projectId)).run();
Expand Down
29 changes: 29 additions & 0 deletions src/main/ipc/localHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { clipboard, dialog, nativeImage, shell, type BrowserWindow } from "elect
import type { BrowserPanelManager } from "../browser";
import { openMicrophoneSettings } from "../browser/permissions";
import {
dbAppendUsageEvents,
dbDeleteProject,
dbDeleteThread,
dbGetProjectNotes,
Expand All @@ -29,6 +30,13 @@ import {
saveHandoffContextFile,
} from "../attachments/localFiles";
import { createProjectDirectory } from "../projectDirectory";
import {
getProfileCoreStats,
getProfileDevicesResponse,
getProfileIdentityResponse,
getProfileTokenStats,
setProfileIdentityResponse,
} from "../profile";
import {
applyClaudeProfileEnvironment,
readSharedSettingsFile,
Expand Down Expand Up @@ -75,6 +83,15 @@ function getUsageLoginManager(
return usageLoginManager;
}

function roundRect(rect: { x: number; y: number; width: number; height: number }) {
return {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
}

const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);

function assertSafeExternalUrl(rawUrl: string): string {
Expand Down Expand Up @@ -325,5 +342,17 @@ export function createLocalIpcHandlers(
options.requireLightcodePaths,
options.getBrowserPanelManager,
).getLoginState(),
getProfileCoreStats: (req) => getProfileCoreStats(req),
getProfileTokenStats: (req) => getProfileTokenStats(req),
getProfileDevices: () => getProfileDevicesResponse(),
getProfileIdentity: () => getProfileIdentityResponse(),
setProfileIdentity: (identity) => setProfileIdentityResponse(identity),
copyShareImage: async (rect) => {
const win = options.getMainWindow();
if (!win) return;
const image = await win.webContents.capturePage(roundRect(rect));
if (!image.isEmpty()) clipboard.writeImage(image);
},
appendUsageEvents: ({ events }) => dbAppendUsageEvents(events),
});
}
Loading