From a7d881112f87ab43aa632b01ee08b8301316991c Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 31 Mar 2026 07:38:57 +0200 Subject: [PATCH 1/2] all time stats tweaks --- lib/commands/stats.ts | 14 +++++++++--- lib/state/persistence.ts | 49 +++++++++++++++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 9 deletions(-) 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 { const result: AggregatedStats = { totalTokens: 0, + totalSummaryTokens: 0, + totalDurationMs: 0, totalTools: 0, totalMessages: 0, sessionCount: 0, @@ -237,13 +241,46 @@ export async function loadAllSessionStats(logger: Logger): Promise block.active) + const activeToolIds = new Set(Object.keys(state.prune.tools || {})) + for (const block of activeBlocks) { + for (const toolId of block.effectiveToolIds || []) { + activeToolIds.add(toolId) + } + } + + let activeDurationMs = 0 + const groupedDurations = new Map() + for (const block of activeBlocks) { + if (block.mode === "message") { + const current = groupedDurations.get(block.runId) || 0 + groupedDurations.set( + block.runId, + Math.max(current, block.durationMs || 0), + ) + continue + } + + activeDurationMs += block.durationMs || 0 + } + + for (const durationMs of groupedDurations.values()) { + activeDurationMs += durationMs + } + result.totalTokens += state.stats.totalPruneTokens - result.totalTools += state.prune.tools - ? Object.keys(state.prune.tools).length - : 0 - result.totalMessages += state.prune.messages?.byMessageId - ? Object.keys(state.prune.messages.byMessageId).length - : 0 + result.totalSummaryTokens += activeBlocks.reduce( + (total, block) => total + (block.summaryTokens || 0), + 0, + ) + result.totalDurationMs += activeDurationMs + result.totalTools += activeToolIds.size + result.totalMessages += Object.values(messages?.byMessageId || {}).reduce( + (total, entry) => total + (entry.activeBlockIds?.length > 0 ? 1 : 0), + 0, + ) result.sessionCount++ } } catch { From aa21200faedcc0af37cabd47ce2f7d7b2b5d0f0f Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 31 Mar 2026 08:03:22 +0200 Subject: [PATCH 2/2] refactor: session stats loading --- lib/state/persistence.ts | 140 +++++++++++++++++------------- tests/persistence.test.ts | 174 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+), 57 deletions(-) create mode 100644 tests/persistence.test.ts diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index 35e299bd..0ff21c80 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -35,11 +35,20 @@ export interface PersistedNudges { export interface PersistedSessionState { sessionName?: string prune: PersistedPrune + compression?: PersistedCompressionStats nudges: PersistedNudges stats: SessionStats lastUpdated: string } +export interface PersistedCompressionStats { + inputTokens: number + summaryTokens: number + durationMs: number + tools: number + messages: number +} + const STORAGE_DIR = join( process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", @@ -58,6 +67,48 @@ function getSessionFilePath(sessionId: string): string { return join(STORAGE_DIR, `${sessionId}.json`) } +export function derivePersistedCompressionStats( + prune: PersistedPrune, + stats: SessionStats, +): PersistedCompressionStats { + const messages = prune.messages + const blocks = Object.values(messages?.blocksById || {}) + const activeBlocks = blocks.filter((block) => 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), @@ -241,46 +295,18 @@ export async function loadAllSessionStats(logger: Logger): Promise block.active) - const activeToolIds = new Set(Object.keys(state.prune.tools || {})) - for (const block of activeBlocks) { - for (const toolId of block.effectiveToolIds || []) { - activeToolIds.add(toolId) - } - } - - let activeDurationMs = 0 - const groupedDurations = new Map() - for (const block of activeBlocks) { - if (block.mode === "message") { - const current = groupedDurations.get(block.runId) || 0 - groupedDurations.set( - block.runId, - Math.max(current, block.durationMs || 0), - ) - continue - } - - activeDurationMs += block.durationMs || 0 - } - - for (const durationMs of groupedDurations.values()) { - activeDurationMs += durationMs - } - - result.totalTokens += state.stats.totalPruneTokens - result.totalSummaryTokens += activeBlocks.reduce( - (total, block) => total + (block.summaryTokens || 0), - 0, - ) - result.totalDurationMs += activeDurationMs - result.totalTools += activeToolIds.size - result.totalMessages += Object.values(messages?.byMessageId || {}).reduce( - (total, entry) => total + (entry.activeBlockIds?.length > 0 ? 1 : 0), - 0, - ) + const compression = + state.compression || + // LEGACY: Older session files do not have derived compression stats yet. + // Keep this reconstruction path during migration, then delete it once old + // persisted state is no longer relevant. + derivePersistedCompressionStats(state.prune, state.stats) + + result.totalTokens += compression.inputTokens + result.totalSummaryTokens += compression.summaryTokens + result.totalDurationMs += compression.durationMs + result.totalTools += compression.tools + result.totalMessages += compression.messages result.sessionCount++ } } catch { diff --git a/tests/persistence.test.ts b/tests/persistence.test.ts new file mode 100644 index 00000000..22f8812d --- /dev/null +++ b/tests/persistence.test.ts @@ -0,0 +1,174 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mkdirSync } from "node:fs" +import { rm, writeFile, readFile } from "node:fs/promises" +import { homedir } from "node:os" +import { join } from "node:path" +import { Logger } from "../lib/logger" +import { saveSessionState, loadAllSessionStats } from "../lib/state/persistence" +import { createSessionState, type CompressionBlock } from "../lib/state" + +const STORAGE_DIR = join( + process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), + "opencode", + "storage", + "plugin", + "dcp", +) + +function buildBlock( + blockId: number, + runId: number, + mode: "message" | "range", + durationMs: number, +): CompressionBlock { + return { + blockId, + runId, + active: true, + deactivatedByUser: false, + compressedTokens: 10, + summaryTokens: 5, + durationMs, + mode, + topic: `topic-${blockId}`, + batchTopic: `batch-${runId}`, + startId: `m${blockId}`, + endId: `m${blockId}`, + anchorMessageId: `msg-${blockId}`, + compressMessageId: `origin-${runId}`, + compressCallId: `call-${blockId}`, + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [`msg-${blockId}`], + directToolIds: [], + effectiveMessageIds: [`msg-${blockId}`], + effectiveToolIds: blockId === 1 ? ["bash"] : [], + createdAt: blockId, + summary: `summary-${blockId}`, + } +} + +test("saveSessionState persists derived compression stats", async () => { + 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 }) + } +})