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
131 changes: 131 additions & 0 deletions db.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,26 @@ const migrations = [
try { db.exec('DELETE FROM session_cache'); } catch {}
try { db.exec('DELETE FROM cache_meta'); } catch {}
},
// v5: per-(session,date,model) metrics for the stats screen (tokens, tool calls,
// messages bucketed by message timestamp). Populated on next cold-start rebuild
// (the scan worker re-reads every JSONL), so no separate backfill is needed.
(db) => {
try {
db.exec(`CREATE TABLE IF NOT EXISTS session_metrics (
sessionId TEXT NOT NULL,
date TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
messageCount INTEGER DEFAULT 0,
toolCallCount INTEGER DEFAULT 0,
inputTokens INTEGER DEFAULT 0,
outputTokens INTEGER DEFAULT 0,
cacheReadTokens INTEGER DEFAULT 0,
cacheCreationTokens INTEGER DEFAULT 0,
PRIMARY KEY (sessionId, date, model)
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_session_metrics_date ON session_metrics(date)');
} catch {}
},
];

const currentDbVersion = (() => {
Expand Down Expand Up @@ -198,6 +218,14 @@ const stmts = {
cacheDeleteSession: db.prepare('DELETE FROM session_cache WHERE sessionId = ?'),
cacheDeleteFolder: db.prepare('DELETE FROM session_cache WHERE folder = ?'),
cacheTouchModified: db.prepare('UPDATE session_cache SET modified = ? WHERE sessionId = ?'),
// Session metrics statements (per-(session,date,model) token/tool/message counts)
metricsDeleteBySession: db.prepare('DELETE FROM session_metrics WHERE sessionId = ?'),
metricsDeleteByFolder: db.prepare('DELETE FROM session_metrics WHERE sessionId IN (SELECT sessionId FROM session_cache WHERE folder = ?)'),
metricsInsert: db.prepare(`
INSERT INTO session_metrics
(sessionId, date, model, messageCount, toolCallCount, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`),
// Cache meta statements
metaGet: db.prepare('SELECT * FROM cache_meta WHERE folder = ?'),
metaGetAll: db.prepare('SELECT * FROM cache_meta'),
Expand Down Expand Up @@ -285,6 +313,25 @@ const upsertCachedSessionsBatch = db.transaction((sessions) => {
}
});

// Replace all metric rows for a session in one transaction: delete-by-session
// then insert the fresh per-(date,model) rows. Called whenever a session is read
// in full (cold-start rebuild + NEW-file branch of the incremental refresh).
const replaceSessionMetricsBatch = db.transaction((sessionId, rows) => {
stmts.metricsDeleteBySession.run(sessionId);
for (const r of rows || []) {
stmts.metricsInsert.run(
sessionId, r.date, r.model || '',
r.messageCount | 0, r.toolCallCount | 0,
r.inputTokens | 0, r.outputTokens | 0,
r.cacheReadTokens | 0, r.cacheCreationTokens | 0
);
}
});

function replaceSessionMetrics(sessionId, rows) {
replaceSessionMetricsBatch(sessionId, rows);
}

function getCachedByParent(parentSessionId) {
return stmts.cacheGetByParent.all(parentSessionId);
}
Expand All @@ -307,10 +354,14 @@ function getCachedSession(sessionId) {
}

function deleteCachedSession(sessionId) {
stmts.metricsDeleteBySession.run(sessionId);
stmts.cacheDeleteSession.run(sessionId);
}

function deleteCachedFolder(folder) {
// Delete metrics first — metricsDeleteByFolder sub-selects on session_cache,
// so it must run before the session_cache rows for this folder are gone.
stmts.metricsDeleteByFolder.run(folder);
stmts.cacheDeleteFolder.run(folder);
stmts.metaDelete.run(folder);
}
Expand Down Expand Up @@ -428,6 +479,84 @@ function getDailyActivity() {
`).all();
}

// --- Session metrics aggregates (for the stats screen) ---

// One row per day, summed across all models. Powers the heatmap + daily bars.
// messageCount/toolCallCount/tokens come from session_metrics (bucketed by the
// per-message timestamp, not the session mtime); sessionCount counts distinct
// sessions active that day.
function getDailyMetrics() {
return db.prepare(`
SELECT date,
SUM(messageCount) AS messageCount,
SUM(toolCallCount) AS toolCallCount,
SUM(inputTokens + outputTokens) AS tokens,
COUNT(DISTINCT sessionId) AS sessionCount
FROM session_metrics
GROUP BY date
ORDER BY date ASC
`).all();
}

// [{date, tokensByModel: {model: tokens}}] sorted by date. Excludes the '' model
// bucket (synthetic / model-less assistant turns carry no tokens anyway).
function getDailyModelTokens() {
const rows = db.prepare(`
SELECT date, model, SUM(inputTokens + outputTokens) AS tokens
FROM session_metrics
WHERE model != ''
GROUP BY date, model
`).all();
const byDate = new Map();
for (const r of rows) {
let entry = byDate.get(r.date);
if (!entry) {
entry = { date: r.date, tokensByModel: {} };
byDate.set(r.date, entry);
}
entry.tokensByModel[r.model] = r.tokens;
}
return Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date));
}

// {model: {inputTokens, outputTokens}} across all time. Excludes '' model.
function getModelUsage() {
const rows = db.prepare(`
SELECT model,
SUM(inputTokens) AS inputTokens,
SUM(outputTokens) AS outputTokens
FROM session_metrics
WHERE model != ''
GROUP BY model
`).all();
const out = {};
for (const r of rows) {
out[r.model] = { inputTokens: r.inputTokens, outputTokens: r.outputTokens };
}
return out;
}

// {totalSessions, totalMessages, totalToolCalls, totalTokens}. totalSessions
// counts ONLY parent (human) sessions — subagents would otherwise inflate it.
function getTotalCounts() {
const sessions = db.prepare(
'SELECT COUNT(*) AS cnt FROM session_cache WHERE parentSessionId IS NULL'
).get();
const metrics = db.prepare(`
SELECT
SUM(messageCount) AS totalMessages,
SUM(toolCallCount) AS totalToolCalls,
SUM(inputTokens + outputTokens) AS totalTokens
FROM session_metrics
`).get();
return {
totalSessions: sessions.cnt || 0,
totalMessages: metrics.totalMessages || 0,
totalToolCalls: metrics.totalToolCalls || 0,
totalTokens: metrics.totalTokens || 0,
};
}

function closeDb() {
try { db.close(); } catch {}
}
Expand All @@ -437,10 +566,12 @@ module.exports = {
isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions,
touchCachedModified: (sessionId, modified) => stmts.cacheTouchModified.run(modified, sessionId),
deleteCachedSession, deleteCachedFolder,
replaceSessionMetrics,
getFolderMeta, getAllFolderMeta, setFolderMeta,
upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType,
searchByType, isSearchIndexPopulated, searchFtsRecreated,
getSetting, setSetting, deleteSetting,
getDailyActivity,
getDailyMetrics, getDailyModelTokens, getModelUsage, getTotalCounts,
closeDb,
};
62 changes: 24 additions & 38 deletions main.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, screen, shell } = require('electron');
const { Worker } = require('worker_threads');

Check warning on line 2 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'Worker' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 2 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'Worker' is assigned a value but never used. Allowed unused vars must match /^_/u
const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
Expand All @@ -16,7 +16,7 @@
}

// getFolderIndexMtimeMs moved to session-cache.js
const { startMcpServer, shutdownMcpServer, shutdownAll: shutdownAllMcp, resolvePendingDiff, rekeyMcpServer, cleanStaleLockFiles } = require('./mcp-bridge');

Check warning on line 19 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'cleanStaleLockFiles' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 19 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'cleanStaleLockFiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { fetchAndTransformUsage } = require('./claude-auth');
log.transports.file.level = app.isPackaged ? 'info' : 'debug';
log.transports.console.level = app.isPackaged ? 'info' : 'debug';
Expand All @@ -36,7 +36,7 @@
);

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs } = require('./shell-profiles');

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');

Expand Down Expand Up @@ -73,11 +73,11 @@
getMeta, getAllMeta, toggleStar, setName, setArchived,
isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions,
deleteCachedSession, deleteCachedFolder,
getFolderMeta, getAllFolderMeta, setFolderMeta,

Check warning on line 76 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'getFolderMeta' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 76 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'getFolderMeta' is assigned a value but never used. Allowed unused vars must match /^_/u
upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType,
searchByType, isSearchIndexPopulated, searchFtsRecreated,
getSetting, setSetting, deleteSetting,
getDailyActivity,
getDailyMetrics, getDailyModelTokens, getModelUsage, getTotalCounts,
closeDb,
} = require('./db');

Expand Down Expand Up @@ -289,8 +289,8 @@
setFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, populateCacheFromFilesystem,

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'populateCacheFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'populateCacheFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker } = sessionCache;

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file');


Expand Down Expand Up @@ -734,31 +734,34 @@
// periods where Claude already rotated the parent JSONL files off disk.
ipcMain.handle('get-stats-from-db', () => {
try {
const rows = getDailyActivity(); // [{date, messageCount, sessionCount}]
let totalMessages = 0;
let totalSessions = 0;
let firstSessionDate = null;
for (const row of rows) {
totalMessages += row.messageCount || 0;
totalSessions += row.sessionCount || 0;
if (!firstSessionDate) firstSessionDate = row.date;
}
const lastComputedDate = new Date().toISOString().slice(0, 10);
return {
dailyActivity: rows, // [{date, messageCount, sessionCount}]
totalMessages,
totalSessions,
firstSessionDate: firstSessionDate || lastComputedDate,
lastComputedDate,
// dailyModelTokens intentionally omitted — not tracked per-day in session_cache
modelUsage: {},
};
return buildStatsFromDb();
} catch (err) {
log.error('Error building stats from DB:', err);
return null;
}
});

// Build the full stats object the renderer consumes. Sourced from
// session_metrics (per-(session,date,model) tokens/tool-calls/messages bucketed
// by message timestamp) so tokens, tool calls, and per-model usage are all real
// data — not the hardcoded {} the heatmap-only path used to return.
function buildStatsFromDb() {
const daily = getDailyMetrics(); // [{date, messageCount, toolCallCount, tokens, sessionCount}]
const totals = getTotalCounts();
const lastComputedDate = new Date().toISOString().slice(0, 10);
return {
dailyActivity: daily,
dailyModelTokens: getDailyModelTokens(),
modelUsage: getModelUsage(),
totalMessages: totals.totalMessages,
totalSessions: totals.totalSessions,
totalToolCalls: totals.totalToolCalls,
totalTokens: totals.totalTokens,
firstSessionDate: daily[0]?.date || lastComputedDate,
lastComputedDate,
};
}

// --- IPC: refresh-stats (fetch /usage + build stats from DB; /stats PTY removed) ---
ipcMain.handle('refresh-stats', async () => {
try {
Expand All @@ -770,24 +773,7 @@
// at once and the renderer can update heatmap + usage in a single round-trip.
let stats = null;
try {
const rows = getDailyActivity();
let totalMessages = 0;
let totalSessions = 0;
let firstSessionDate = null;
for (const row of rows) {
totalMessages += row.messageCount || 0;
totalSessions += row.sessionCount || 0;
if (!firstSessionDate) firstSessionDate = row.date;
}
const lastComputedDate = new Date().toISOString().slice(0, 10);
stats = {
dailyActivity: rows,
totalMessages,
totalSessions,
firstSessionDate: firstSessionDate || lastComputedDate,
lastComputedDate,
modelUsage: {},
};
stats = buildStatsFromDb();
} catch (dbErr) {
log.error('Error building stats from DB in refresh-stats:', dbErr);
}
Expand Down Expand Up @@ -1383,7 +1369,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 1372 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 1372 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
21 changes: 13 additions & 8 deletions public/stats-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -426,27 +426,32 @@ function buildStatsSummary(stats, dailyMap) {

const totalSessions = stats.totalSessions || Object.keys(dailyMap).length;

// Compact number formatting (K/M/B) shared by the total-tokens, tool-calls,
// and per-model token cards.
const fmtNum = (n) => {
n = n || 0;
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
return n.toLocaleString();
};

// Model usage — values are objects with token counts, show as cards
const models = stats.modelUsage || {};

const cards = [
{ value: totalSessions.toLocaleString(), label: 'Total Sessions' },
{ value: totalMessages.toLocaleString(), label: 'Total Messages' },
{ value: fmtNum(stats.totalTokens), label: 'Total Tokens' },
{ value: fmtNum(stats.totalToolCalls), label: 'Tool Calls' },
{ value: currentStreak + 'd', label: 'Current Streak' },
{ value: longestStreak + 'd', label: 'Longest Streak' },
];

for (const [model, usage] of Object.entries(models)) {
const shortName = model.replace(/^claude-/, '').replace(/-\d{8}$/, '');
const tokens = (usage?.inputTokens || 0) + (usage?.outputTokens || 0);
const label = shortName;
// Format token count in millions/thousands
let valueStr;
if (tokens >= 1e9) valueStr = (tokens / 1e9).toFixed(1) + 'B';
else if (tokens >= 1e6) valueStr = (tokens / 1e6).toFixed(1) + 'M';
else if (tokens >= 1e3) valueStr = (tokens / 1e3).toFixed(1) + 'K';
else valueStr = tokens.toLocaleString();
cards.push({ value: valueStr, label: label + ' tokens' });
cards.push({ value: fmtNum(tokens), label: shortName + ' tokens' });
}

for (const card of cards) {
Expand Down
Loading
Loading