diff --git a/db.js b/db.js index ef9d5ded..c6353daa 100644 --- a/db.js +++ b/db.js @@ -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 = (() => { @@ -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'), @@ -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); } @@ -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); } @@ -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 {} } @@ -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, }; diff --git a/main.js b/main.js index 971cedbb..4681efe5 100644 --- a/main.js +++ b/main.js @@ -77,7 +77,7 @@ const { upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, searchByType, isSearchIndexPopulated, searchFtsRecreated, getSetting, setSetting, deleteSetting, - getDailyActivity, + getDailyMetrics, getDailyModelTokens, getModelUsage, getTotalCounts, closeDb, } = require('./db'); @@ -734,31 +734,34 @@ ipcMain.handle('get-stats', () => { // 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 { @@ -770,24 +773,7 @@ ipcMain.handle('refresh-stats', async () => { // 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); } diff --git a/public/stats-view.js b/public/stats-view.js index 0b5608ed..ab012a3c 100644 --- a/public/stats-view.js +++ b/public/stats-view.js @@ -426,12 +426,24 @@ 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' }, ]; @@ -439,14 +451,7 @@ function buildStatsSummary(stats, dailyMap) { 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) { diff --git a/read-session-file.js b/read-session-file.js index a0b21f83..3331ed27 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -31,6 +31,84 @@ function readSubagentMeta(jsonlPath) { } } +/** A user turn that contains ONLY tool_result blocks isn't a real message — + * it's the harness feeding tool output back to the model. Counting these + * inflates per-day message counts dramatically (observed 116991 msg/day). + * Returns true only when content is a non-empty array whose every item is a + * {type:'tool_result'} block. */ +function isToolResultOnly(content) { + if (!Array.isArray(content) || content.length === 0) return false; + return content.every(c => c && c.type === 'tool_result'); +} + +/** Pure helper: given an array of raw JSONL lines (strings) and a fallback date + * (YYYY-MM-DD, used when a line has no usable timestamp), accumulate per-(date, + * model) metrics. Returns an array of: + * { date, model, messageCount, toolCallCount, inputTokens, outputTokens, + * cacheReadTokens, cacheCreationTokens } + * Tokens and tool calls are only attributed to assistant lines; synthetic / + * model-less assistant lines bucket under model '' (counted as a message but + * with zero tokens). User turns that are purely tool_result aren't counted as + * messages. Non-message line types are ignored entirely. + */ +function extractDailyMetrics(lines, fallbackDate) { + const map = new Map(); + const bucket = (date, model) => { + const key = `${date}|${model}`; + let m = map.get(key); + if (!m) { + m = { + date, model, + messageCount: 0, toolCallCount: 0, + inputTokens: 0, outputTokens: 0, + cacheReadTokens: 0, cacheCreationTokens: 0, + }; + map.set(key, m); + } + return m; + }; + + for (const line of lines) { + if (!line) continue; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + + const ts = typeof entry.timestamp === 'string' && entry.timestamp.length >= 10 + ? entry.timestamp.slice(0, 10) + : fallbackDate; + + const isAssistant = entry.type === 'assistant' || + (entry.type === 'message' && entry.role === 'assistant'); + const isUser = entry.type === 'user' || + (entry.type === 'message' && entry.role === 'user'); + + if (isAssistant) { + let model = entry.message?.model || ''; + if (model === '') model = ''; + const m = bucket(ts, model); + m.messageCount += 1; + if (model) { + const usage = entry.message?.usage || {}; + m.inputTokens += usage.input_tokens | 0; + m.outputTokens += usage.output_tokens | 0; + m.cacheReadTokens += usage.cache_read_input_tokens | 0; + m.cacheCreationTokens += usage.cache_creation_input_tokens | 0; + } + const content = entry.message?.content; + if (Array.isArray(content)) { + for (const c of content) { + if (c && c.type === 'tool_use') m.toolCallCount += 1; + } + } + } else if (isUser) { + if (isToolResultOnly(entry.message?.content)) continue; + bucket(ts, '').messageCount += 1; + } + } + + return Array.from(map.values()); +} + /** Parse a single .jsonl file into a session object (or null if invalid). * opts.parentSessionId — if set, treat as a subagent transcript and stamp the * parent reference into the returned row. @@ -88,6 +166,9 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { } if (!summary || messageCount < 1) return null; + const fallbackDate = stat.mtime.toISOString().slice(0, 10); + const dailyMetrics = extractDailyMetrics(lines, fallbackDate); + if (isSubagent) { // Sidechain marker must be present — otherwise the file lives under a // subagents/ directory but isn't actually a subagent transcript. Bail. @@ -113,6 +194,7 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { agentId, subagentType, description, + dailyMetrics, }; } @@ -122,6 +204,7 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { created: stat.birthtime.toISOString(), modified: stat.mtime.toISOString(), messageCount, textContent, slug, customTitle, aiTitle, + dailyMetrics, }; } catch { return null; @@ -274,4 +357,4 @@ function readSessionDisplayHeader(filePath, opts = {}) { } } -module.exports = { readSessionFile, readSessionDisplayHeader, subagentSessionId, resolveJsonlPath, readSubagentMeta, enumerateSessionFiles }; +module.exports = { readSessionFile, readSessionDisplayHeader, subagentSessionId, resolveJsonlPath, readSubagentMeta, enumerateSessionFiles, extractDailyMetrics, isToolResultOnly }; diff --git a/session-cache.js b/session-cache.js index bce3cdfe..291179fd 100644 --- a/session-cache.js +++ b/session-cache.js @@ -11,7 +11,7 @@ const { encodeProjectPath } = require('./encode-project-path'); * Call init(ctx) once with the shared context object. */ let PROJECTS_DIR, activeSessions, getMainWindow, log; -let deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSession, touchCachedModified; +let deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSession, touchCachedModified, replaceSessionMetrics; let deleteSearchFolder, deleteSearchSession, upsertSearchEntries; let setFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName; @@ -26,6 +26,7 @@ function init(ctx) { upsertCachedSessions = ctx.db.upsertCachedSessions; touchCachedModified = ctx.db.touchCachedModified; deleteCachedSession = ctx.db.deleteCachedSession; + replaceSessionMetrics = ctx.db.replaceSessionMetrics; deleteSearchFolder = ctx.db.deleteSearchFolder; deleteSearchSession = ctx.db.deleteSearchSession; upsertSearchEntries = ctx.db.upsertSearchEntries; @@ -193,6 +194,11 @@ function refreshFolder(folder, opts = {}) { if (s) { currentIds.add(s.sessionId); sessionsToUpsert.push(s); + // Per-(date,model) metrics only exist on the full-read path. The header-only + // refresh branch above doesn't produce dailyMetrics, so this is the sole + // write point for an incremental refresh — short transaction, fine to run + // outside the upsert batch. + replaceSessionMetrics(s.sessionId, s.dailyMetrics); // Title precedence: user rename (session_meta.name) > JSONL custom-title > JSONL ai-title. // Only customTitle (Claude /title) promotes to session_meta.name — AI titles must NEVER // be written there or they'd overwrite the user's UI rename on the next index pass. @@ -474,6 +480,8 @@ function populateCacheViaWorker() { // Only JSONL custom-title (genuine user title) promotes to the DB name column. // AI titles must not — see refreshFolder for the rationale. if (s.customTitle) setName(s.sessionId, s.customTitle); + // Worker called readSessionFile, so dailyMetrics is present. + replaceSessionMetrics(s.sessionId, s.dailyMetrics); } upsertSearchEntries(sessions.map(s => { // Search title precedence matches the sidebar: user rename > custom-title > ai-title. diff --git a/test/db-session-metrics.test.js b/test/db-session-metrics.test.js new file mode 100644 index 00000000..9be4edac --- /dev/null +++ b/test/db-session-metrics.test.js @@ -0,0 +1,141 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +// The session_metrics aggregates in db.js are powered by better-sqlite3, which +// is compiled against Electron's Node ABI and cannot be required from plain +// node:test. These tests validate the aggregation LOGIC by running pure-JS +// mirrors of the SQL (same pattern as db-daily-activity.test.js) on fixture +// rows, so regressions in the GROUP BY shape surface in CI. + +// Mirror of getDailyMetrics: +// SELECT date, SUM(messageCount), SUM(toolCallCount), +// SUM(inputTokens+outputTokens) AS tokens, COUNT(DISTINCT sessionId) +// FROM session_metrics GROUP BY date ORDER BY date ASC +function aggregateDailyMetrics(rows) { + const map = new Map(); + for (const r of rows) { + let e = map.get(r.date); + if (!e) { + e = { date: r.date, messageCount: 0, toolCallCount: 0, tokens: 0, _sessions: new Set() }; + map.set(r.date, e); + } + e.messageCount += r.messageCount || 0; + e.toolCallCount += r.toolCallCount || 0; + e.tokens += (r.inputTokens || 0) + (r.outputTokens || 0); + e._sessions.add(r.sessionId); + } + return Array.from(map.values()) + .map(e => ({ date: e.date, messageCount: e.messageCount, toolCallCount: e.toolCallCount, tokens: e.tokens, sessionCount: e._sessions.size })) + .sort((a, b) => a.date.localeCompare(b.date)); +} + +// Mirror of getModelUsage: +// SELECT model, SUM(inputTokens), SUM(outputTokens) +// FROM session_metrics WHERE model != '' GROUP BY model +function aggregateModelUsage(rows) { + const out = {}; + for (const r of rows) { + if (!r.model) continue; + if (!out[r.model]) out[r.model] = { inputTokens: 0, outputTokens: 0 }; + out[r.model].inputTokens += r.inputTokens || 0; + out[r.model].outputTokens += r.outputTokens || 0; + } + return out; +} + +// Mirror of getDailyModelTokens: +// SELECT date, model, SUM(inputTokens+outputTokens) AS tokens +// FROM session_metrics WHERE model != '' GROUP BY date, model +// → [{date, tokensByModel: {model: tokens}}] sorted by date +function aggregateDailyModelTokens(rows) { + const byDate = new Map(); + for (const r of rows) { + if (!r.model) continue; + let e = byDate.get(r.date); + if (!e) { e = { date: r.date, tokensByModel: {} }; byDate.set(r.date, e); } + e.tokensByModel[r.model] = (e.tokensByModel[r.model] || 0) + (r.inputTokens || 0) + (r.outputTokens || 0); + } + return Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date)); +} + +// Mirror of getTotalCounts (metrics half + parent-only session count): +function aggregateTotals(metricRows, cacheRows) { + let totalMessages = 0, totalToolCalls = 0, totalTokens = 0; + for (const r of metricRows) { + totalMessages += r.messageCount || 0; + totalToolCalls += r.toolCallCount || 0; + totalTokens += (r.inputTokens || 0) + (r.outputTokens || 0); + } + const totalSessions = cacheRows.filter(r => r.parentSessionId == null).length; + return { totalSessions, totalMessages, totalToolCalls, totalTokens }; +} + +const FIXTURE = [ + { sessionId: 's1', date: '2026-06-01', model: 'claude-opus-4-8', messageCount: 4, toolCallCount: 3, inputTokens: 1000, outputTokens: 200 }, + { sessionId: 's1', date: '2026-06-01', model: '', messageCount: 3, toolCallCount: 0, inputTokens: 0, outputTokens: 0 }, + { sessionId: 's2', date: '2026-06-01', model: 'claude-sonnet-4-6', messageCount: 2, toolCallCount: 1, inputTokens: 500, outputTokens: 100 }, + { sessionId: 's2', date: '2026-06-02', model: 'claude-opus-4-8', messageCount: 5, toolCallCount: 2, inputTokens: 800, outputTokens: 300 }, +]; + +test('aggregateDailyMetrics sums per day across models and counts distinct sessions', () => { + const rows = aggregateDailyMetrics(FIXTURE); + assert.equal(rows.length, 2); + const d1 = rows.find(r => r.date === '2026-06-01'); + const d2 = rows.find(r => r.date === '2026-06-02'); + assert.equal(d1.messageCount, 9); // 4 + 3 + 2 + assert.equal(d1.toolCallCount, 4); // 3 + 0 + 1 + assert.equal(d1.tokens, 1800); // 1200 + 0 + 600 + assert.equal(d1.sessionCount, 2); // s1, s2 + assert.equal(d2.messageCount, 5); + assert.equal(d2.tokens, 1100); + assert.equal(d2.sessionCount, 1); +}); + +test('aggregateDailyMetrics sorts dates ascending', () => { + const rows = aggregateDailyMetrics([ + { sessionId: 'x', date: '2026-06-03', model: 'm', messageCount: 1, inputTokens: 1, outputTokens: 0 }, + { sessionId: 'x', date: '2026-06-01', model: 'm', messageCount: 1, inputTokens: 1, outputTokens: 0 }, + ]); + assert.deepEqual(rows.map(r => r.date), ['2026-06-01', '2026-06-03']); +}); + +test('aggregateModelUsage groups tokens by model and excludes empty model', () => { + const usage = aggregateModelUsage(FIXTURE); + assert.deepEqual(Object.keys(usage).sort(), ['claude-opus-4-8', 'claude-sonnet-4-6']); + assert.equal(usage['claude-opus-4-8'].inputTokens, 1800); // 1000 + 800 + assert.equal(usage['claude-opus-4-8'].outputTokens, 500); // 200 + 300 + assert.equal(usage['claude-sonnet-4-6'].inputTokens, 500); + assert.ok(!('' in usage), 'empty-model bucket excluded from model usage'); +}); + +test('aggregateDailyModelTokens returns per-date tokensByModel excluding empty model', () => { + const daily = aggregateDailyModelTokens(FIXTURE); + assert.equal(daily.length, 2); + const d1 = daily.find(r => r.date === '2026-06-01'); + assert.equal(d1.tokensByModel['claude-opus-4-8'], 1200); + assert.equal(d1.tokensByModel['claude-sonnet-4-6'], 600); + assert.ok(!('' in d1.tokensByModel), 'empty model not present'); + const d2 = daily.find(r => r.date === '2026-06-02'); + assert.equal(d2.tokensByModel['claude-opus-4-8'], 1100); +}); + +test('aggregateTotals counts only parent sessions and sums metrics', () => { + const cacheRows = [ + { sessionId: 's1', parentSessionId: null }, + { sessionId: 's2', parentSessionId: null }, + { sessionId: 'sub:s1:a', parentSessionId: 's1' }, + { sessionId: 'sub:s1:b', parentSessionId: 's1' }, + ]; + const totals = aggregateTotals(FIXTURE, cacheRows); + assert.equal(totals.totalSessions, 2, 'subagents excluded from session count'); + assert.equal(totals.totalMessages, 14); // 4+3+2+5 + assert.equal(totals.totalToolCalls, 6); // 3+0+1+2 + assert.equal(totals.totalTokens, 2900); // 1200+0+600+1100 +}); + +test('aggregates return empty/zero on no rows', () => { + assert.deepEqual(aggregateDailyMetrics([]), []); + assert.deepEqual(aggregateModelUsage([]), {}); + assert.deepEqual(aggregateDailyModelTokens([]), []); + assert.deepEqual(aggregateTotals([], []), { totalSessions: 0, totalMessages: 0, totalToolCalls: 0, totalTokens: 0 }); +}); diff --git a/test/read-session-file-metrics.test.js b/test/read-session-file-metrics.test.js new file mode 100644 index 00000000..0064d9c9 --- /dev/null +++ b/test/read-session-file-metrics.test.js @@ -0,0 +1,162 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { readSessionFile, extractDailyMetrics, isToolResultOnly } = require('../read-session-file'); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-rsfm-')); +} +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +// Convenience: find the metric row for a given (date, model) pair. +function find(rows, date, model) { + return rows.find(r => r.date === date && r.model === model); +} + +test('isToolResultOnly detects pure tool_result user turns', () => { + assert.equal(isToolResultOnly([{ type: 'tool_result', content: 'x' }]), true); + assert.equal(isToolResultOnly([{ type: 'tool_result' }, { type: 'tool_result' }]), true); + // Mixed content (text + tool_result) is a real message. + assert.equal(isToolResultOnly([{ type: 'tool_result' }, { type: 'text', text: 'hi' }]), false); + // Plain text array, empty, or non-array → not tool-result-only. + assert.equal(isToolResultOnly([{ type: 'text', text: 'hi' }]), false); + assert.equal(isToolResultOnly([]), false); + assert.equal(isToolResultOnly('hello'), false); + assert.equal(isToolResultOnly(undefined), false); +}); + +test('extractDailyMetrics sums tokens per (date, model) from message.usage', () => { + const lines = [ + JSON.stringify({ type: 'assistant', timestamp: '2026-06-01T10:00:00.000Z', message: { + model: 'claude-opus-4-8', + usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10, cache_creation_input_tokens: 5 }, + } }), + JSON.stringify({ type: 'assistant', timestamp: '2026-06-01T11:00:00.000Z', message: { + model: 'claude-opus-4-8', + usage: { input_tokens: 200, output_tokens: 80 }, + } }), + ]; + const rows = extractDailyMetrics(lines, '2026-06-01'); + assert.equal(rows.length, 1); + const r = find(rows, '2026-06-01', 'claude-opus-4-8'); + assert.ok(r); + assert.equal(r.inputTokens, 300); + assert.equal(r.outputTokens, 130); + assert.equal(r.cacheReadTokens, 10); + assert.equal(r.cacheCreationTokens, 5); + assert.equal(r.messageCount, 2); +}); + +test('extractDailyMetrics buckets by message timestamp, not a single date', () => { + const lines = [ + JSON.stringify({ type: 'assistant', timestamp: '2026-06-01T23:00:00.000Z', message: { model: 'claude-sonnet-4-6', usage: { input_tokens: 10, output_tokens: 1 } } }), + JSON.stringify({ type: 'assistant', timestamp: '2026-06-02T01:00:00.000Z', message: { model: 'claude-sonnet-4-6', usage: { input_tokens: 20, output_tokens: 2 } } }), + ]; + const rows = extractDailyMetrics(lines, '2026-06-02'); + assert.equal(rows.length, 2, 'two distinct dates → two rows'); + assert.equal(find(rows, '2026-06-01', 'claude-sonnet-4-6').inputTokens, 10); + assert.equal(find(rows, '2026-06-02', 'claude-sonnet-4-6').inputTokens, 20); +}); + +test('extractDailyMetrics treats and model-less assistant lines as model "" with no tokens', () => { + const lines = [ + JSON.stringify({ type: 'assistant', timestamp: '2026-06-01T10:00:00.000Z', message: { model: '', usage: { input_tokens: 999, output_tokens: 999 } } }), + JSON.stringify({ type: 'assistant', timestamp: '2026-06-01T10:01:00.000Z', message: { usage: { input_tokens: 5, output_tokens: 5 } } }), + ]; + const rows = extractDailyMetrics(lines, '2026-06-01'); + const r = find(rows, '2026-06-01', ''); + assert.ok(r, 'should bucket under empty model'); + assert.equal(r.messageCount, 2, 'both still count as messages'); + assert.equal(r.inputTokens, 0, 'synthetic/model-less tokens are not attributed'); + assert.equal(r.outputTokens, 0); +}); + +test('extractDailyMetrics counts tool_use blocks', () => { + const lines = [ + JSON.stringify({ type: 'assistant', timestamp: '2026-06-01T10:00:00.000Z', message: { + model: 'claude-opus-4-8', + usage: { input_tokens: 1, output_tokens: 1 }, + content: [ + { type: 'text', text: 'doing things' }, + { type: 'tool_use', name: 'Bash' }, + { type: 'tool_use', name: 'Read' }, + ], + } }), + ]; + const rows = extractDailyMetrics(lines, '2026-06-01'); + const r = find(rows, '2026-06-01', 'claude-opus-4-8'); + assert.equal(r.toolCallCount, 2); +}); + +test('extractDailyMetrics does NOT count tool_result-only user turns as messages', () => { + const lines = [ + JSON.stringify({ type: 'user', timestamp: '2026-06-01T10:00:00.000Z', message: { content: 'real question' } }), + JSON.stringify({ type: 'user', timestamp: '2026-06-01T10:00:30.000Z', message: { content: [{ type: 'tool_result', content: 'out' }] } }), + JSON.stringify({ type: 'assistant', timestamp: '2026-06-01T10:01:00.000Z', message: { model: 'claude-opus-4-8', usage: { input_tokens: 1, output_tokens: 1 } } }), + ]; + const rows = extractDailyMetrics(lines, '2026-06-01'); + const userRow = find(rows, '2026-06-01', ''); + assert.equal(userRow.messageCount, 1, 'only the real user turn counts, not the tool_result-only one'); +}); + +test('extractDailyMetrics falls back to fallbackDate when timestamp is missing', () => { + const lines = [ + JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-8', usage: { input_tokens: 7, output_tokens: 3 } } }), + ]; + const rows = extractDailyMetrics(lines, '2026-05-30'); + const r = find(rows, '2026-05-30', 'claude-opus-4-8'); + assert.ok(r); + assert.equal(r.inputTokens, 7); +}); + +test('extractDailyMetrics ignores non-message line types and malformed JSON', () => { + const lines = [ + JSON.stringify({ type: 'summary', summary: 'x', timestamp: '2026-06-01T10:00:00.000Z' }), + JSON.stringify({ type: 'custom-title', customTitle: 'My session', timestamp: '2026-06-01T10:00:00.000Z' }), + '{ this is not valid json', + JSON.stringify({ type: 'assistant', timestamp: '2026-06-01T10:00:00.000Z', message: { model: 'claude-opus-4-8', usage: { input_tokens: 4, output_tokens: 2 } } }), + ]; + const rows = extractDailyMetrics(lines, '2026-06-01'); + assert.equal(rows.length, 1); + assert.equal(rows[0].model, 'claude-opus-4-8'); + assert.equal(rows[0].messageCount, 1); +}); + +test('readSessionFile attaches dailyMetrics to the returned session object', () => { + const tmp = mkTmp(); + try { + const lines = [ + JSON.stringify({ type: 'user', timestamp: '2026-06-01T09:00:00.000Z', message: 'hello, please help' }), + JSON.stringify({ type: 'assistant', timestamp: '2026-06-01T09:01:00.000Z', message: { + model: 'claude-opus-4-8', + usage: { input_tokens: 120, output_tokens: 40 }, + content: [{ type: 'tool_use', name: 'Bash' }], + } }), + JSON.stringify({ type: 'user', timestamp: '2026-06-01T09:01:30.000Z', message: { content: [{ type: 'tool_result', content: 'ok' }] } }), + ]; + const file = path.join(tmp, 'sess-1.jsonl'); + fs.writeFileSync(file, lines.join('\n') + '\n', 'utf8'); + + const s = readSessionFile(file, 'folder', '/some/project'); + assert.ok(s, 'session should parse'); + assert.ok(Array.isArray(s.dailyMetrics), 'dailyMetrics present'); + + const opus = s.dailyMetrics.find(r => r.model === 'claude-opus-4-8'); + assert.ok(opus); + assert.equal(opus.inputTokens, 120); + assert.equal(opus.outputTokens, 40); + assert.equal(opus.toolCallCount, 1); + assert.equal(opus.messageCount, 1); + + const userBucket = s.dailyMetrics.find(r => r.model === ''); + assert.ok(userBucket); + assert.equal(userBucket.messageCount, 1, 'only the real user turn, tool_result-only excluded'); + } finally { + cleanup(tmp); + } +});