Skip to content
Open
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
14 changes: 11 additions & 3 deletions lib/commands/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -142,6 +148,8 @@ export async function handleStatsCommand(ctx: StatsCommandContext): Promise<void
sessionMessages,
sessionDurationMs,
allTimeTokens: allTime.totalTokens,
allTimeSummaryTokens: allTime.totalSummaryTokens,
allTimeDurationMs: allTime.totalDurationMs,
allTimeTools: allTime.totalTools,
allTimeMessages: allTime.totalMessages,
})
Expand Down
111 changes: 87 additions & 24 deletions lib/state/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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<string>(Object.keys(prune.tools || {}))
for (const block of activeBlocks) {
for (const toolId of block.effectiveToolIds || []) {
activeToolIds.add(toolId)
}
}

const groupedDurations = new Map<number, number>()
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,
Expand All @@ -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),
Expand Down Expand Up @@ -209,6 +263,8 @@ export async function loadSessionState(

export interface AggregatedStats {
totalTokens: number
totalSummaryTokens: number
totalDurationMs: number
totalTools: number
totalMessages: number
sessionCount: number
Expand All @@ -217,6 +273,8 @@ export interface AggregatedStats {
export async function loadAllSessionStats(logger: Logger): Promise<AggregatedStats> {
const result: AggregatedStats = {
totalTokens: 0,
totalSummaryTokens: 0,
totalDurationMs: 0,
totalTools: 0,
totalMessages: 0,
sessionCount: 0,
Expand All @@ -237,13 +295,18 @@ export async function loadAllSessionStats(logger: Logger): Promise<AggregatedSta
const state = JSON.parse(content) as PersistedSessionState

if (state?.stats?.totalPruneTokens && state?.prune) {
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
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 {
Expand Down
174 changes: 174 additions & 0 deletions tests/persistence.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})