diff --git a/lib/commands/stats.ts b/lib/commands/stats.ts index bea2a6dc..06b2c623 100644 --- a/lib/commands/stats.ts +++ b/lib/commands/stats.ts @@ -45,9 +45,15 @@ function formatStatsMessage( lines.push("") lines.push("All-time:") lines.push("─".repeat(60)) - lines.push(` Tokens saved: ~${formatTokenCount(allTime.totalTokens)}`) - lines.push(` Tools pruned: ${allTime.totalTools}`) - lines.push(` Messages pruned: ${allTime.totalMessages}`) + lines.push( + ` Tokens in|out: ~${formatTokenCount(allTime.totalTokens)} | ~${formatTokenCount(allTime.totalSummaryTokens)}`, + ) + lines.push( + ` Ratio: ${formatCompressionRatio(allTime.totalTokens, allTime.totalSummaryTokens)}`, + ) + lines.push(` Time: ${formatCompressionTime(allTime.totalDurationMs)}`) + lines.push(` Messages: ${allTime.totalMessages}`) + lines.push(` Tools: ${allTime.totalTools}`) lines.push(` Sessions: ${allTime.sessionCount}`) return lines.join("\n") @@ -142,6 +148,8 @@ export async function handleStatsCommand(ctx: StatsCommandContext): Promise block.active) + const activeToolIds = new Set(Object.keys(prune.tools || {})) + for (const block of activeBlocks) { + for (const toolId of block.effectiveToolIds || []) { + activeToolIds.add(toolId) + } + } + + const groupedDurations = new Map() + const rangeDurationMs = activeBlocks.reduce((total, block) => { + if (block.mode === "message") { + const current = groupedDurations.get(block.runId) || 0 + groupedDurations.set(block.runId, Math.max(current, block.durationMs || 0)) + return total + } + + return total + (block.durationMs || 0) + }, 0) + + const groupedDurationMs = Array.from(groupedDurations.values()).reduce( + (total, durationMs) => total + durationMs, + 0, + ) + + return { + inputTokens: stats.totalPruneTokens, + summaryTokens: activeBlocks.reduce((total, block) => total + (block.summaryTokens || 0), 0), + durationMs: rangeDurationMs + groupedDurationMs, + tools: activeToolIds.size, + messages: Object.values(messages?.byMessageId || {}).reduce( + (total, entry) => total + (entry.activeBlockIds?.length > 0 ? 1 : 0), + 0, + ), + } +} + export async function saveSessionState( sessionState: SessionState, logger: Logger, @@ -70,25 +121,28 @@ export async function saveSessionState( await ensureStorageDir() - const state: PersistedSessionState = { - sessionName: sessionName, - prune: { - tools: Object.fromEntries(sessionState.prune.tools), - messages: { - byMessageId: Object.fromEntries(sessionState.prune.messages.byMessageId), - blocksById: Object.fromEntries( - Array.from(sessionState.prune.messages.blocksById.entries()).map( - ([blockId, block]) => [String(blockId), block], - ), - ), - activeBlockIds: Array.from(sessionState.prune.messages.activeBlockIds), - activeByAnchorMessageId: Object.fromEntries( - sessionState.prune.messages.activeByAnchorMessageId, + const prune: PersistedPrune = { + tools: Object.fromEntries(sessionState.prune.tools), + messages: { + byMessageId: Object.fromEntries(sessionState.prune.messages.byMessageId), + blocksById: Object.fromEntries( + Array.from(sessionState.prune.messages.blocksById.entries()).map( + ([blockId, block]) => [String(blockId), block], ), - nextBlockId: sessionState.prune.messages.nextBlockId, - nextRunId: sessionState.prune.messages.nextRunId, - }, + ), + activeBlockIds: Array.from(sessionState.prune.messages.activeBlockIds), + activeByAnchorMessageId: Object.fromEntries( + sessionState.prune.messages.activeByAnchorMessageId, + ), + nextBlockId: sessionState.prune.messages.nextBlockId, + nextRunId: sessionState.prune.messages.nextRunId, }, + } + + const state: PersistedSessionState = { + sessionName: sessionName, + prune, + compression: derivePersistedCompressionStats(prune, sessionState.stats), nudges: { contextLimitAnchors: Array.from(sessionState.nudges.contextLimitAnchors), turnNudgeAnchors: Array.from(sessionState.nudges.turnNudgeAnchors), @@ -209,6 +263,8 @@ export async function loadSessionState( export interface AggregatedStats { totalTokens: number + totalSummaryTokens: number + totalDurationMs: number totalTools: number totalMessages: number sessionCount: number @@ -217,6 +273,8 @@ export interface AggregatedStats { export async function loadAllSessionStats(logger: Logger): Promise { const result: AggregatedStats = { totalTokens: 0, + totalSummaryTokens: 0, + totalDurationMs: 0, totalTools: 0, totalMessages: 0, sessionCount: 0, @@ -237,13 +295,18 @@ export async function loadAllSessionStats(logger: Logger): Promise { + mkdirSync(STORAGE_DIR, { recursive: true }) + const sessionId = `ses_persistence_save_${Date.now()}` + const filePath = join(STORAGE_DIR, `${sessionId}.json`) + const state = createSessionState() + state.sessionId = sessionId + state.stats.totalPruneTokens = 120 + state.prune.tools.set("read", 1) + state.prune.messages.byMessageId.set("msg-1", { + tokenCount: 10, + allBlockIds: [1], + activeBlockIds: [1], + }) + state.prune.messages.byMessageId.set("msg-2", { + tokenCount: 10, + allBlockIds: [2], + activeBlockIds: [2], + }) + + const first = buildBlock(1, 10, "message", 225) + const second = buildBlock(2, 10, "message", 225) + state.prune.messages.blocksById.set(1, first) + state.prune.messages.blocksById.set(2, second) + state.prune.messages.activeBlockIds.add(1) + state.prune.messages.activeBlockIds.add(2) + + try { + await saveSessionState(state, new Logger(false)) + const saved = JSON.parse(await readFile(filePath, "utf-8")) + + assert.deepEqual(saved.compression, { + inputTokens: 120, + summaryTokens: 10, + durationMs: 225, + tools: 2, + messages: 2, + }) + } finally { + await rm(filePath, { force: true }) + } +}) + +test("loadAllSessionStats sums derived stats and uses LEGACY fallback", async () => { + mkdirSync(STORAGE_DIR, { recursive: true }) + const logger = new Logger(false) + const before = await loadAllSessionStats(logger) + const freshId = `ses_persistence_fresh_${Date.now()}` + const legacyId = `ses_persistence_legacy_${Date.now()}` + const freshPath = join(STORAGE_DIR, `${freshId}.json`) + const legacyPath = join(STORAGE_DIR, `${legacyId}.json`) + + const baseState = { + nudges: { + contextLimitAnchors: [], + turnNudgeAnchors: [], + iterationNudgeAnchors: [], + }, + lastUpdated: new Date().toISOString(), + } + + const fresh = { + ...baseState, + prune: { + tools: {}, + messages: { + byMessageId: {}, + blocksById: {}, + activeBlockIds: [], + activeByAnchorMessageId: {}, + nextBlockId: 1, + nextRunId: 1, + }, + }, + stats: { pruneTokenCounter: 0, totalPruneTokens: 100 }, + compression: { + inputTokens: 100, + summaryTokens: 25, + durationMs: 700, + tools: 3, + messages: 4, + }, + } + + const legacy = { + ...baseState, + prune: { + tools: { read: 1 }, + messages: { + byMessageId: { + "msg-1": { tokenCount: 10, allBlockIds: [1], activeBlockIds: [1] }, + "msg-2": { tokenCount: 10, allBlockIds: [2], activeBlockIds: [2] }, + }, + blocksById: { + "1": buildBlock(1, 20, "message", 300), + "2": buildBlock(2, 20, "message", 300), + "3": buildBlock(3, 21, "range", 80), + }, + activeBlockIds: [1, 2, 3], + activeByAnchorMessageId: { "msg-1": 1, "msg-2": 2, "msg-3": 3 }, + nextBlockId: 4, + nextRunId: 22, + }, + }, + stats: { pruneTokenCounter: 0, totalPruneTokens: 90 }, + } + + try { + await writeFile(freshPath, JSON.stringify(fresh, null, 2), "utf-8") + await writeFile(legacyPath, JSON.stringify(legacy, null, 2), "utf-8") + const after = await loadAllSessionStats(logger) + + assert.equal(after.totalTokens - before.totalTokens, 190) + assert.equal(after.totalSummaryTokens - before.totalSummaryTokens, 40) + assert.equal(after.totalDurationMs - before.totalDurationMs, 1080) + assert.equal(after.totalTools - before.totalTools, 5) + assert.equal(after.totalMessages - before.totalMessages, 6) + assert.equal(after.sessionCount - before.sessionCount, 2) + } finally { + await rm(freshPath, { force: true }) + await rm(legacyPath, { force: true }) + } +})