Skip to content

Commit 961edb5

Browse files
authored
Merge pull request #471 from Opencode-DCP/feat/compression-time
feat: add compression time to `/dcp stats`
2 parents 0745a4b + 15c5d20 commit 961edb5

17 files changed

Lines changed: 818 additions & 39 deletions

index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createChatMessageHandler,
1414
createChatMessageTransformHandler,
1515
createCommandExecuteHandler,
16+
createEventHandler,
1617
createSystemPromptHandler,
1718
createTextCompleteHandler,
1819
} from "./lib/hooks"
@@ -57,7 +58,6 @@ const plugin: Plugin = (async (ctx) => {
5758
config,
5859
prompts,
5960
),
60-
6161
"experimental.chat.messages.transform": createChatMessageTransformHandler(
6262
ctx.client,
6363
state,
@@ -76,6 +76,7 @@ const plugin: Plugin = (async (ctx) => {
7676
ctx.directory,
7777
hostPermissions,
7878
),
79+
event: createEventHandler(state, logger),
7980
tool: {
8081
...(config.compress.permission !== "deny" && {
8182
compress:

lib/commands/compression-targets.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export interface CompressionTarget {
55
runId: number
66
topic: string
77
compressedTokens: number
8+
durationMs: number
89
grouped: boolean
910
blocks: CompressionBlock[]
1011
}
@@ -26,6 +27,7 @@ function buildTarget(blocks: CompressionBlock[]): CompressionTarget {
2627
runId: first.runId,
2728
topic: grouped ? first.batchTopic || first.topic : first.topic,
2829
compressedTokens: ordered.reduce((total, block) => total + block.compressedTokens, 0),
30+
durationMs: ordered.reduce((total, block) => Math.max(total, block.durationMs), 0),
2931
grouped,
3032
blocks: ordered,
3133
}

lib/commands/stats.ts

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { sendIgnoredMessage } from "../ui/notification"
99
import { formatTokenCount } from "../ui/utils"
1010
import { loadAllSessionStats, type AggregatedStats } from "../state/persistence"
1111
import { getCurrentParams } from "../token-utils"
12+
import { getActiveCompressionTargets } from "./compression-targets"
1213

1314
export interface StatsCommandContext {
1415
client: any
@@ -20,8 +21,10 @@ export interface StatsCommandContext {
2021

2122
function formatStatsMessage(
2223
sessionTokens: number,
24+
sessionSummaryTokens: number,
2325
sessionTools: number,
2426
sessionMessages: number,
27+
sessionDurationMs: number,
2528
allTime: AggregatedStats,
2629
): string {
2730
const lines: string[] = []
@@ -30,11 +33,15 @@ function formatStatsMessage(
3033
lines.push("│ DCP Statistics │")
3134
lines.push("╰───────────────────────────────────────────────────────────╯")
3235
lines.push("")
33-
lines.push("Session:")
36+
lines.push("Compression:")
3437
lines.push("─".repeat(60))
35-
lines.push(` Tokens pruned: ~${formatTokenCount(sessionTokens)}`)
36-
lines.push(` Tools pruned: ${sessionTools}`)
37-
lines.push(` Messages pruned: ${sessionMessages}`)
38+
lines.push(
39+
` Tokens in|out: ~${formatTokenCount(sessionTokens)} | ~${formatTokenCount(sessionSummaryTokens)}`,
40+
)
41+
lines.push(` Ratio: ${formatCompressionRatio(sessionTokens, sessionSummaryTokens)}`)
42+
lines.push(` Time: ${formatCompressionTime(sessionDurationMs)}`)
43+
lines.push(` Messages: ${sessionMessages}`)
44+
lines.push(` Tools: ${sessionTools}`)
3845
lines.push("")
3946
lines.push("All-time:")
4047
lines.push("─".repeat(60))
@@ -46,11 +53,55 @@ function formatStatsMessage(
4653
return lines.join("\n")
4754
}
4855

56+
function formatCompressionRatio(inputTokens: number, outputTokens: number): string {
57+
if (inputTokens <= 0) {
58+
return "0:1"
59+
}
60+
61+
if (outputTokens <= 0) {
62+
return "∞:1"
63+
}
64+
65+
const ratio = Math.max(1, Math.round(inputTokens / outputTokens))
66+
return `${ratio}:1`
67+
}
68+
69+
function formatCompressionTime(ms: number): string {
70+
const safeMs = Math.max(0, Math.round(ms))
71+
if (safeMs < 1000) {
72+
return `${safeMs} ms`
73+
}
74+
75+
const totalSeconds = safeMs / 1000
76+
if (totalSeconds < 60) {
77+
return `${totalSeconds.toFixed(1)} s`
78+
}
79+
80+
const wholeSeconds = Math.floor(totalSeconds)
81+
const hours = Math.floor(wholeSeconds / 3600)
82+
const minutes = Math.floor((wholeSeconds % 3600) / 60)
83+
const seconds = wholeSeconds % 60
84+
85+
if (hours > 0) {
86+
return `${hours}h ${minutes}m ${seconds}s`
87+
}
88+
89+
return `${minutes}m ${seconds}s`
90+
}
91+
4992
export async function handleStatsCommand(ctx: StatsCommandContext): Promise<void> {
5093
const { client, state, logger, sessionId, messages } = ctx
5194

5295
// Session stats from in-memory state
5396
const sessionTokens = state.stats.totalPruneTokens
97+
const sessionSummaryTokens = Array.from(state.prune.messages.blocksById.values()).reduce(
98+
(total, block) => (block.active ? total + block.summaryTokens : total),
99+
0,
100+
)
101+
const sessionDurationMs = getActiveCompressionTargets(state.prune.messages).reduce(
102+
(total, target) => total + target.durationMs,
103+
0,
104+
)
54105

55106
const prunedToolIds = new Set<string>(state.prune.tools.keys())
56107
for (const block of state.prune.messages.blocksById.values()) {
@@ -72,15 +123,24 @@ export async function handleStatsCommand(ctx: StatsCommandContext): Promise<void
72123
// All-time stats from storage files
73124
const allTime = await loadAllSessionStats(logger)
74125

75-
const message = formatStatsMessage(sessionTokens, sessionTools, sessionMessages, allTime)
126+
const message = formatStatsMessage(
127+
sessionTokens,
128+
sessionSummaryTokens,
129+
sessionTools,
130+
sessionMessages,
131+
sessionDurationMs,
132+
allTime,
133+
)
76134

77135
const params = getCurrentParams(state, messages, logger)
78136
await sendIgnoredMessage(client, sessionId, message, params, logger)
79137

80138
logger.info("Stats command executed", {
81139
sessionTokens,
140+
sessionSummaryTokens,
82141
sessionTools,
83142
sessionMessages,
143+
sessionDurationMs,
84144
allTimeTokens: allTime.totalTokens,
85145
allTimeTools: allTime.totalTools,
86146
allTimeMessages: allTime.totalMessages,

lib/compress/message.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType<typeof t
4848
async execute(args, toolCtx) {
4949
const input = args as CompressMessageToolArgs
5050
validateArgs(input)
51+
const callId =
52+
typeof (toolCtx as unknown as { callID?: unknown }).callID === "string"
53+
? (toolCtx as unknown as { callID: string }).callID
54+
: undefined
5155

5256
const { rawMessages, searchContext } = await prepareSession(
5357
ctx,
@@ -107,6 +111,7 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType<typeof t
107111
mode: "message",
108112
runId,
109113
compressMessageId: toolCtx.messageID,
114+
compressCallId: callId,
110115
summaryTokens,
111116
},
112117
plan.selection,

lib/compress/pipeline.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { sendCompressNotification } from "../ui/notification"
99
import type { ToolContext } from "./types"
1010
import { buildSearchContext, fetchSessionMessages } from "./search"
1111
import type { SearchContext } from "./types"
12+
import { applyPendingCompressionDurations } from "./timing"
1213

1314
interface RunContext {
1415
ask(input: {
@@ -83,6 +84,7 @@ export async function finalizeSession(
8384
batchTopic: string | undefined,
8485
): Promise<void> {
8586
ctx.state.manualMode = ctx.state.manualMode ? "active" : false
87+
applyPendingCompressionDurations(ctx.state)
8688
await saveSessionState(ctx.state, ctx.logger)
8789

8890
const params = getCurrentParams(ctx.state, rawMessages, ctx.logger)

lib/compress/range.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType<typeof too
5959
async execute(args, toolCtx) {
6060
const input = args as CompressRangeToolArgs
6161
validateArgs(input)
62+
const callId =
63+
typeof (toolCtx as unknown as { callID?: unknown }).callID === "string"
64+
? (toolCtx as unknown as { callID: string }).callID
65+
: undefined
6266

6367
const { rawMessages, searchContext } = await prepareSession(
6468
ctx,
@@ -148,6 +152,7 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType<typeof too
148152
mode: "range",
149153
runId,
150154
compressMessageId: toolCtx.messageID,
155+
compressCallId: callId,
151156
summaryTokens,
152157
},
153158
preparedPlan.selection,

lib/compress/state.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CompressionBlock, SessionState } from "../state"
1+
import type { CompressionBlock, PruneMessagesState, SessionState } from "../state"
22
import { formatBlockRef, formatMessageIdTag } from "../message-ids"
33
import type { AppliedCompressionResult, CompressionStateInput, SelectionResolution } from "./types"
44

@@ -26,6 +26,28 @@ export function allocateRunId(state: SessionState): number {
2626
return next
2727
}
2828

29+
export function attachCompressionDuration(
30+
messagesState: PruneMessagesState,
31+
callId: string,
32+
durationMs: number,
33+
): number {
34+
if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) {
35+
return 0
36+
}
37+
38+
let updates = 0
39+
for (const block of messagesState.blocksById.values()) {
40+
if (block.compressCallId !== callId) {
41+
continue
42+
}
43+
44+
block.durationMs = durationMs
45+
updates++
46+
}
47+
48+
return updates
49+
}
50+
2951
export function wrapCompressedSummary(blockId: number, summary: string): string {
3052
const header = COMPRESSED_BLOCK_HEADER
3153
const footer = formatMessageIdTag(formatBlockRef(blockId))
@@ -93,13 +115,15 @@ export function applyCompressionState(
93115
deactivatedByUser: false,
94116
compressedTokens: 0,
95117
summaryTokens: input.summaryTokens,
118+
durationMs: 0,
96119
mode: input.mode,
97120
topic: input.topic,
98121
batchTopic: input.batchTopic,
99122
startId: input.startId,
100123
endId: input.endId,
101124
anchorMessageId,
102125
compressMessageId: input.compressMessageId,
126+
compressCallId: input.compressCallId,
103127
includedBlockIds: included,
104128
consumedBlockIds: consumed,
105129
parentBlockIds: [],

lib/compress/timing.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { SessionState } from "../state/types"
2+
import { attachCompressionDuration } from "./state"
3+
4+
export interface PendingCompressionDuration {
5+
callId: string
6+
durationMs: number
7+
}
8+
9+
export interface CompressionTimingState {
10+
startsByCallId: Map<string, number>
11+
pendingByCallId: Map<string, PendingCompressionDuration>
12+
}
13+
14+
export function consumeCompressionStart(state: SessionState, callId: string): number | undefined {
15+
const start = state.compressionTiming.startsByCallId.get(callId)
16+
state.compressionTiming.startsByCallId.delete(callId)
17+
return start
18+
}
19+
20+
export function resolveCompressionDuration(
21+
startedAt: number | undefined,
22+
eventTime: number | undefined,
23+
partTime: { start?: unknown; end?: unknown } | undefined,
24+
): number | undefined {
25+
const runningAt =
26+
typeof partTime?.start === "number" && Number.isFinite(partTime.start)
27+
? partTime.start
28+
: eventTime
29+
const pendingToRunningMs =
30+
typeof startedAt === "number" && typeof runningAt === "number"
31+
? Math.max(0, runningAt - startedAt)
32+
: undefined
33+
34+
const toolStart = partTime?.start
35+
const toolEnd = partTime?.end
36+
const runtimeMs =
37+
typeof toolStart === "number" &&
38+
Number.isFinite(toolStart) &&
39+
typeof toolEnd === "number" &&
40+
Number.isFinite(toolEnd)
41+
? Math.max(0, toolEnd - toolStart)
42+
: undefined
43+
44+
return typeof pendingToRunningMs === "number" ? pendingToRunningMs : runtimeMs
45+
}
46+
47+
export function applyPendingCompressionDurations(state: SessionState): number {
48+
if (state.compressionTiming.pendingByCallId.size === 0) {
49+
return 0
50+
}
51+
52+
let updates = 0
53+
for (const [callId, entry] of state.compressionTiming.pendingByCallId) {
54+
const applied = attachCompressionDuration(
55+
state.prune.messages,
56+
entry.callId,
57+
entry.durationMs,
58+
)
59+
if (applied > 0) {
60+
updates += applied
61+
state.compressionTiming.pendingByCallId.delete(callId)
62+
}
63+
}
64+
65+
return updates
66+
}

lib/compress/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,5 +103,6 @@ export interface CompressionStateInput {
103103
mode: CompressionMode
104104
runId: number
105105
compressMessageId: string
106+
compressCallId?: string
106107
summaryTokens: number
107108
}

0 commit comments

Comments
 (0)