@royalcat/opencode-dcp-rc 4.0.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +21 -1
  2. package/dist/index.js +1402 -27
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/commands/stats.d.ts +3 -1
  5. package/dist/lib/commands/stats.d.ts.map +1 -1
  6. package/dist/lib/compress/rc.d.ts.map +1 -1
  7. package/dist/lib/compress/summary.d.ts +11 -1
  8. package/dist/lib/compress/summary.d.ts.map +1 -1
  9. package/dist/lib/compress/types.d.ts +10 -1
  10. package/dist/lib/compress/types.d.ts.map +1 -1
  11. package/dist/lib/compress/usage.d.ts +45 -0
  12. package/dist/lib/compress/usage.d.ts.map +1 -0
  13. package/dist/lib/config.d.ts +2 -0
  14. package/dist/lib/config.d.ts.map +1 -1
  15. package/dist/lib/prompts/context-limit-nudge.d.ts +1 -1
  16. package/dist/lib/prompts/context-limit-nudge.d.ts.map +1 -1
  17. package/dist/lib/prompts/extensions/system.d.ts +2 -2
  18. package/dist/lib/prompts/extensions/system.d.ts.map +1 -1
  19. package/dist/lib/prompts/iteration-nudge.d.ts +1 -1
  20. package/dist/lib/prompts/iteration-nudge.d.ts.map +1 -1
  21. package/dist/lib/prompts/turn-nudge.d.ts +1 -1
  22. package/dist/lib/prompts/turn-nudge.d.ts.map +1 -1
  23. package/dist/lib/state/persistence.d.ts +2 -0
  24. package/dist/lib/state/persistence.d.ts.map +1 -1
  25. package/dist/lib/state/state.d.ts.map +1 -1
  26. package/dist/lib/state/types.d.ts +3 -0
  27. package/dist/lib/state/types.d.ts.map +1 -1
  28. package/dist/lib/tui/dialogs.d.ts.map +1 -1
  29. package/dist/lib/v2/index.d.ts.map +1 -1
  30. package/dist/lib/v2/rpc.d.ts +20 -0
  31. package/dist/lib/v2/rpc.d.ts.map +1 -1
  32. package/dist/lib/v2/scrub.d.ts +94 -0
  33. package/dist/lib/v2/scrub.d.ts.map +1 -0
  34. package/dist/lib/v2/usage.d.ts +45 -0
  35. package/dist/lib/v2/usage.d.ts.map +1 -0
  36. package/lib/commands/stats.ts +21 -0
  37. package/lib/compress/rc.ts +43 -7
  38. package/lib/compress/summary.ts +28 -1
  39. package/lib/compress/types.ts +14 -1
  40. package/lib/compress/usage.ts +168 -0
  41. package/lib/config.ts +30 -0
  42. package/lib/prompts/context-limit-nudge.ts +2 -0
  43. package/lib/prompts/extensions/system.ts +4 -0
  44. package/lib/prompts/iteration-nudge.ts +2 -0
  45. package/lib/prompts/system.ts +1 -1
  46. package/lib/prompts/turn-nudge.ts +2 -0
  47. package/lib/state/persistence.ts +18 -2
  48. package/lib/state/state.ts +4 -0
  49. package/lib/state/types.ts +3 -0
  50. package/lib/tui/dialogs.tsx +24 -0
  51. package/lib/v2/index.ts +58 -5
  52. package/lib/v2/rpc.ts +12 -0
  53. package/lib/v2/scrub.ts +954 -0
  54. package/lib/v2/usage.ts +450 -0
  55. package/package.json +1 -1
@@ -21,6 +21,13 @@ import { countTokens } from "../token-utils"
21
21
  import { saveSessionState } from "../state/persistence"
22
22
  import { rcFormat } from "../prompts/extensions/tool"
23
23
  import { appendProtectedPromptInfo, appendProtectedTools } from "./protected-content"
24
+ import {
25
+ addCompressionUsage,
26
+ addCompressionUsageTotals,
27
+ emptyCompressionUsage,
28
+ estimatedCompressionUsage,
29
+ type CompressionUsageTotals,
30
+ } from "./usage"
24
31
  import {
25
32
  buildSummaryPrompt,
26
33
  parseSummarySections,
@@ -34,6 +41,16 @@ export interface RcCompressToolArgs {
34
41
  ids: string[]
35
42
  }
36
43
 
44
+ let summaryCallCounter = 0
45
+
46
+ /** Unique per hidden summary request so provider usage can be attributed. */
47
+ function nextSummaryCallId(): string {
48
+ summaryCallCounter += 1
49
+ return `c${summaryCallCounter.toString(36)}-${Date.now().toString(36)}-${Math.random()
50
+ .toString(36)
51
+ .slice(2, 8)}`
52
+ }
53
+
37
54
  /**
38
55
  * Compress selections with a hidden, synchronous summary request.
39
56
  *
@@ -244,7 +261,12 @@ export function createRcCompressTool(ctx: ToolContext): ReturnType<typeof tool>
244
261
  priorSummaries: plan.priorSummaries,
245
262
  }))
246
263
 
247
- const text = await generateSummaries(ctx, sessionID, segments)
264
+ const { text, usage } = await generateSummaries(ctx, sessionID, segments)
265
+ ctx.state.stats.compressionUsage = addCompressionUsageTotals(
266
+ ctx.state.stats.compressionUsage,
267
+ usage,
268
+ )
269
+ await saveSessionState(ctx.state, ctx.logger)
248
270
  const accepted = await resolveAcceptedSections(
249
271
  ctx,
250
272
  plans.map((plan) => plan.selector.selector),
@@ -290,9 +312,24 @@ async function generateSummaries(
290
312
  ctx: ToolContext,
291
313
  sessionID: string,
292
314
  segments: SummarySegment[],
293
- ): Promise<string> {
294
- const prompt = buildSummaryPrompt(segments, ctx.state.idFormat)
295
- let text = await ctx.generate({ sessionID, prompt })
315
+ ): Promise<{ text: string; usage: CompressionUsageTotals }> {
316
+ let usage = emptyCompressionUsage()
317
+
318
+ const summarize = async (promptSegments: SummarySegment[]): Promise<string> => {
319
+ const callId = nextSummaryCallId()
320
+ const prompt = buildSummaryPrompt(promptSegments, ctx.state.idFormat, callId)
321
+ const result = await ctx.generate({ sessionID, prompt, callId })
322
+ const text = result.text ?? ""
323
+ // Provider usage is authoritative; fall back to a local estimate when
324
+ // the plugin could not observe the underlying model call.
325
+ usage = addCompressionUsage(
326
+ usage,
327
+ result.usage ?? estimatedCompressionUsage(countTokens(prompt), countTokens(text)),
328
+ )
329
+ return text
330
+ }
331
+
332
+ let text = await summarize(segments)
296
333
 
297
334
  // One retry for sections the model failed to emit.
298
335
  for (let retry = 0; retry < 2; retry++) {
@@ -311,14 +348,13 @@ async function generateSummaries(
311
348
  if (retrySegments.length === 0) {
312
349
  break
313
350
  }
314
- const retryPrompt = buildSummaryPrompt(retrySegments, ctx.state.idFormat)
315
351
  try {
316
- text += "\n\n" + (await ctx.generate({ sessionID, prompt: retryPrompt }))
352
+ text += "\n\n" + (await summarize(retrySegments))
317
353
  } catch {
318
354
  break
319
355
  }
320
356
  }
321
- return text
357
+ return { text, usage }
322
358
  }
323
359
 
324
360
  async function resolveAcceptedSections(
@@ -4,8 +4,34 @@ import { formatBlockRef, formatMessageRef, parseBoundaryId, type IdFormat } from
4
4
  /**
5
5
  * Marker embedded in the hidden summary prompt so the plugin can recognize its
6
6
  * own transient (`session.generate`) request and strip everything else from it.
7
+ * Requests carry a per-call suffix (`[[DCP-RC-SUMMARY:<callId>]]`) so captured
8
+ * provider usage can be attributed to a specific request.
7
9
  */
8
10
  export const RC_SUMMARY_MARKER = "[[DCP-RC-SUMMARY]]"
11
+ /** Prefix shared by the static marker and its per-call variant. */
12
+ export const RC_SUMMARY_MARKER_PREFIX = "[[DCP-RC-SUMMARY"
13
+
14
+ function sanitizeCallId(callId: string): string {
15
+ return callId.replace(/[^A-Za-z0-9_-]/g, "-")
16
+ }
17
+
18
+ /** Marker line for a hidden summary request, optionally tagged with a call id. */
19
+ export function buildSummaryMarker(callId?: string): string {
20
+ if (!callId) {
21
+ return RC_SUMMARY_MARKER
22
+ }
23
+ return `${RC_SUMMARY_MARKER_PREFIX}:${sanitizeCallId(callId)}]]`
24
+ }
25
+
26
+ /** True when the text contains a hidden summary marker, with or without a call id. */
27
+ export function hasSummaryMarker(text: string): boolean {
28
+ return text.includes(RC_SUMMARY_MARKER_PREFIX)
29
+ }
30
+
31
+ /** Extract the call id from a hidden summary marker, when present. */
32
+ export function extractSummaryCallId(text: string): string | undefined {
33
+ return text.match(/\[\[DCP-RC-SUMMARY:([A-Za-z0-9_-]+)\]\]/)?.[1]
34
+ }
9
35
 
10
36
  export interface PriorSummary {
11
37
  /** Canonical block reference, e.g. `b2` or `@b2@`. */
@@ -134,6 +160,7 @@ const SUMMARY_RULES = `RULES
134
160
  export function buildSummaryPrompt(
135
161
  selectors: SummarySegment[],
136
162
  idFormat: IdFormat = "xml",
163
+ callId?: string,
137
164
  ): string {
138
165
  const selectorList = selectors.map((segment) => segment.selector)
139
166
  const example = selectorList[0] ?? (idFormat === "compact" ? "" : "m0004")
@@ -152,7 +179,7 @@ export function buildSummaryPrompt(
152
179
  })
153
180
  .join("\n\n")
154
181
 
155
- return `${RC_SUMMARY_MARKER}
182
+ return `${buildSummaryMarker(callId)}
156
183
 
157
184
  You generate compressed-context entries for a coding session. Summarize ONLY the conversation segments listed below. Do not summarize anything else and do not ask questions.
158
185
 
@@ -2,6 +2,14 @@ import type { PluginConfig } from "../config"
2
2
  import type { Logger } from "../logger"
3
3
  import type { PromptStore } from "../prompts/store"
4
4
  import type { CompressionBlock, CompressionMode, SessionState, WithParts } from "../state"
5
+ import type { CompressionUsage } from "./usage"
6
+
7
+ /** Result of the hidden transient model call (OpenCode V2 `session.generate`). */
8
+ export interface GenerateSummaryResult {
9
+ text: string
10
+ /** Provider usage when captured, otherwise a local estimate. */
11
+ usage?: CompressionUsage
12
+ }
5
13
 
6
14
  export interface ToolContext {
7
15
  client: any
@@ -10,7 +18,12 @@ export interface ToolContext {
10
18
  config: PluginConfig
11
19
  prompts: PromptStore
12
20
  /** Hidden transient model call (OpenCode V2 `session.generate`). */
13
- generate: (input: { sessionID: string; prompt: string }) => Promise<string>
21
+ generate: (input: {
22
+ sessionID: string
23
+ prompt: string
24
+ /** Per-request id embedded in the prompt marker for usage attribution. */
25
+ callId?: string
26
+ }) => Promise<GenerateSummaryResult>
14
27
  }
15
28
 
16
29
  export interface BoundaryReference {
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Usage accounting for the hidden compression (summary) request.
3
+ *
4
+ * Provider-reported usage is preferred: OpenCode V2 generates the summary
5
+ * through `session.generate`, which does not surface token usage to the
6
+ * caller, so the numbers are captured by wrapping the resolved language
7
+ * model (`ctx.aisdk.hook("language")`) and reading the `finish` usage.
8
+ * When that path is unavailable, local tokenizer estimates are recorded
9
+ * instead so the plugin can still report an approximate cost.
10
+ */
11
+
12
+ export type CompressionUsageSource = "provider" | "estimated"
13
+
14
+ /** Usage of a single hidden compression request. */
15
+ export interface CompressionUsage {
16
+ inputTokens: number
17
+ outputTokens: number
18
+ cacheReadTokens: number
19
+ cacheWriteTokens: number
20
+ reasoningTokens: number
21
+ source: CompressionUsageSource
22
+ }
23
+
24
+ /** Aggregated usage of all hidden compression requests in a session. */
25
+ export interface CompressionUsageTotals {
26
+ calls: number
27
+ providerCalls: number
28
+ estimatedCalls: number
29
+ inputTokens: number
30
+ outputTokens: number
31
+ cacheReadTokens: number
32
+ cacheWriteTokens: number
33
+ reasoningTokens: number
34
+ }
35
+
36
+ export function emptyCompressionUsage(): CompressionUsageTotals {
37
+ return {
38
+ calls: 0,
39
+ providerCalls: 0,
40
+ estimatedCalls: 0,
41
+ inputTokens: 0,
42
+ outputTokens: 0,
43
+ cacheReadTokens: 0,
44
+ cacheWriteTokens: 0,
45
+ reasoningTokens: 0,
46
+ }
47
+ }
48
+
49
+ function toCount(value: unknown, fallback = 0): number {
50
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
51
+ return fallback
52
+ }
53
+ return Math.round(value)
54
+ }
55
+
56
+ /**
57
+ * Normalize AI SDK v3 usage (`{ inputTokens: { total, noCache, cacheRead,
58
+ * cacheWrite }, outputTokens: { total, text, reasoning } }`) into the plugin
59
+ * shape. Returns undefined when there is nothing usable to record.
60
+ */
61
+ export function normalizeProviderUsage(raw: unknown): CompressionUsage | undefined {
62
+ if (!raw || typeof raw !== "object") {
63
+ return undefined
64
+ }
65
+
66
+ const value = raw as { inputTokens?: unknown; outputTokens?: unknown }
67
+ const input =
68
+ value.inputTokens && typeof value.inputTokens === "object"
69
+ ? (value.inputTokens as Record<string, unknown>)
70
+ : {}
71
+ const output =
72
+ value.outputTokens && typeof value.outputTokens === "object"
73
+ ? (value.outputTokens as Record<string, unknown>)
74
+ : {}
75
+
76
+ const cacheRead = toCount(input.cacheRead)
77
+ const cacheWrite = toCount(input.cacheWrite)
78
+ const uncached = toCount(input.noCache)
79
+ const inputFromParts = uncached + cacheRead + cacheWrite
80
+ const inputTotal = Math.max(toCount(input.total, inputFromParts), inputFromParts)
81
+
82
+ const reasoning = toCount(output.reasoning)
83
+ const outputTotal = Math.max(toCount(output.total, toCount(output.text) + reasoning), reasoning)
84
+
85
+ if (
86
+ inputTotal === 0 &&
87
+ outputTotal === 0 &&
88
+ cacheRead === 0 &&
89
+ cacheWrite === 0 &&
90
+ reasoning === 0
91
+ ) {
92
+ return undefined
93
+ }
94
+
95
+ return {
96
+ inputTokens: inputTotal,
97
+ outputTokens: outputTotal,
98
+ cacheReadTokens: cacheRead,
99
+ cacheWriteTokens: cacheWrite,
100
+ reasoningTokens: reasoning,
101
+ source: "provider",
102
+ }
103
+ }
104
+
105
+ /** Local fallback when the provider did not report usage. */
106
+ export function estimatedCompressionUsage(
107
+ inputTokens: number,
108
+ outputTokens: number,
109
+ ): CompressionUsage {
110
+ return {
111
+ inputTokens: toCount(inputTokens),
112
+ outputTokens: toCount(outputTokens),
113
+ cacheReadTokens: 0,
114
+ cacheWriteTokens: 0,
115
+ reasoningTokens: 0,
116
+ source: "estimated",
117
+ }
118
+ }
119
+
120
+ export function addCompressionUsage(
121
+ totals: CompressionUsageTotals,
122
+ usage: CompressionUsage,
123
+ ): CompressionUsageTotals {
124
+ return {
125
+ calls: totals.calls + 1,
126
+ providerCalls: totals.providerCalls + (usage.source === "provider" ? 1 : 0),
127
+ estimatedCalls: totals.estimatedCalls + (usage.source === "estimated" ? 1 : 0),
128
+ inputTokens: totals.inputTokens + usage.inputTokens,
129
+ outputTokens: totals.outputTokens + usage.outputTokens,
130
+ cacheReadTokens: totals.cacheReadTokens + usage.cacheReadTokens,
131
+ cacheWriteTokens: totals.cacheWriteTokens + usage.cacheWriteTokens,
132
+ reasoningTokens: totals.reasoningTokens + usage.reasoningTokens,
133
+ }
134
+ }
135
+
136
+ export function addCompressionUsageTotals(
137
+ totals: CompressionUsageTotals,
138
+ other: CompressionUsageTotals,
139
+ ): CompressionUsageTotals {
140
+ return {
141
+ calls: totals.calls + other.calls,
142
+ providerCalls: totals.providerCalls + other.providerCalls,
143
+ estimatedCalls: totals.estimatedCalls + other.estimatedCalls,
144
+ inputTokens: totals.inputTokens + other.inputTokens,
145
+ outputTokens: totals.outputTokens + other.outputTokens,
146
+ cacheReadTokens: totals.cacheReadTokens + other.cacheReadTokens,
147
+ cacheWriteTokens: totals.cacheWriteTokens + other.cacheWriteTokens,
148
+ reasoningTokens: totals.reasoningTokens + other.reasoningTokens,
149
+ }
150
+ }
151
+
152
+ /** Backward-compatible load of persisted totals. */
153
+ export function normalizeCompressionUsageTotals(value: unknown): CompressionUsageTotals {
154
+ if (!value || typeof value !== "object") {
155
+ return emptyCompressionUsage()
156
+ }
157
+ const raw = value as Record<string, unknown>
158
+ return {
159
+ calls: toCount(raw.calls),
160
+ providerCalls: toCount(raw.providerCalls),
161
+ estimatedCalls: toCount(raw.estimatedCalls),
162
+ inputTokens: toCount(raw.inputTokens),
163
+ outputTokens: toCount(raw.outputTokens),
164
+ cacheReadTokens: toCount(raw.cacheReadTokens),
165
+ cacheWriteTokens: toCount(raw.cacheWriteTokens),
166
+ reasoningTokens: toCount(raw.reasoningTokens),
167
+ }
168
+ }
package/lib/config.ts CHANGED
@@ -35,6 +35,8 @@ export interface CompressConfig {
35
35
  protectedTools: string[]
36
36
  protectTags: boolean
37
37
  protectUserMessages: boolean
38
+ scrubModelOutput: boolean
39
+ scrubMessageIds: boolean
38
40
  }
39
41
 
40
42
  export interface Commands {
@@ -131,6 +133,8 @@ export const VALID_CONFIG_KEYS = new Set([
131
133
  "compress.protectedTools",
132
134
  "compress.protectTags",
133
135
  "compress.protectUserMessages",
136
+ "compress.scrubModelOutput",
137
+ "compress.scrubMessageIds",
134
138
  "strategies",
135
139
  "strategies.deduplication",
136
140
  "strategies.deduplication.enabled",
@@ -432,6 +436,28 @@ export function validateConfigTypes(config: Record<string, any>): ValidationErro
432
436
  })
433
437
  }
434
438
 
439
+ if (
440
+ compress.scrubModelOutput !== undefined &&
441
+ typeof compress.scrubModelOutput !== "boolean"
442
+ ) {
443
+ errors.push({
444
+ key: "compress.scrubModelOutput",
445
+ expected: "boolean",
446
+ actual: typeof compress.scrubModelOutput,
447
+ })
448
+ }
449
+
450
+ if (
451
+ compress.scrubMessageIds !== undefined &&
452
+ typeof compress.scrubMessageIds !== "boolean"
453
+ ) {
454
+ errors.push({
455
+ key: "compress.scrubMessageIds",
456
+ expected: "boolean",
457
+ actual: typeof compress.scrubMessageIds,
458
+ })
459
+ }
460
+
435
461
  if (
436
462
  typeof compress.iterationNudgeThreshold === "number" &&
437
463
  compress.iterationNudgeThreshold < 1
@@ -676,6 +702,8 @@ const defaultConfig: PluginConfig = {
676
702
  protectedTools: [...COMPRESS_DEFAULT_PROTECTED_TOOLS],
677
703
  protectTags: false,
678
704
  protectUserMessages: false,
705
+ scrubModelOutput: true,
706
+ scrubMessageIds: true,
679
707
  },
680
708
  strategies: {
681
709
  deduplication: {
@@ -844,6 +872,8 @@ function mergeCompress(
844
872
  protectedTools: [...new Set([...base.protectedTools, ...(override.protectedTools ?? [])])],
845
873
  protectTags: override.protectTags ?? base.protectTags,
846
874
  protectUserMessages: override.protectUserMessages ?? base.protectUserMessages,
875
+ scrubModelOutput: override.scrubModelOutput ?? base.scrubModelOutput,
876
+ scrubMessageIds: override.scrubMessageIds ?? base.scrubMessageIds,
847
877
  }
848
878
  }
849
879
 
@@ -14,5 +14,7 @@ Avoid the newest active working messages unless it is clearly closed.
14
14
  SUMMARY REQUIREMENTS
15
15
  Your summary MUST cover all essential details from the selected messages so work can continue.
16
16
  If the compressed range includes user messages, preserve user intent exactly. Prefer direct quotes for short user messages to avoid semantic drift.
17
+
18
+ Never repeat or quote this reminder in your reply.
17
19
  </dcp-system-reminder>
18
20
  `
@@ -6,6 +6,8 @@ Only use the compress tool after seeing \`<compress triggered manually>\` in the
6
6
  Issue exactly ONE compress tool per manual trigger. Do NOT launch multiple compress tools in parallel. Each trigger grants a single compression; after it completes, wait for the next trigger.
7
7
 
8
8
  After completing a manually triggered context-management action, STOP IMMEDIATELY. Do NOT continue with any task execution. End your response right after the tool use completes and wait for the next user input.
9
+
10
+ Never repeat this notice in your reply.
9
11
  </dcp-system-reminder>
10
12
  `
11
13
 
@@ -15,6 +17,8 @@ You are operating in a subagent environment.
15
17
  The initial subagent instruction is imperative and must be followed exactly.
16
18
  It is the only user message intentionally not assigned a message ID, and therefore is not eligible for compression.
17
19
  All subsequent messages in the session will have IDs.
20
+
21
+ Never repeat this notice in your reply.
18
22
  </dcp-system-reminder>
19
23
  `
20
24
 
@@ -2,5 +2,7 @@ export const ITERATION_NUDGE = `<dcp-system-reminder>
2
2
  You've been iterating for a while after the last user message.
3
3
 
4
4
  If there is a closed portion that is unlikely to be referenced immediately (for example, finished research before implementation), use the compress tool on it now.
5
+
6
+ Never repeat or quote this reminder in your reply.
5
7
  </dcp-system-reminder>
6
8
  `
@@ -8,7 +8,7 @@ You operate in a context-constrained environment. Manage context continuously to
8
8
 
9
9
  The ONLY tool you have for context management is \`compress\`. It replaces older conversation content with compact technical summaries.
10
10
 
11
- ${markers} and \`<dcp-system-reminder>\` tags are environment-injected metadata. Do not output them.
11
+ ${markers} and \`<dcp-system-reminder>\` tags are environment-injected metadata. Do not output, quote, or mention them in your replies or reasoning.
12
12
 
13
13
  THE PHILOSOPHY OF COMPRESS
14
14
  \`compress\` transforms conversation content into dense, high-fidelity summaries. This is not cleanup - it is crystallization. Your summary becomes the authoritative record of what transpired.
@@ -6,5 +6,7 @@ If direction has shifted, compress earlier ranges that are now less relevant.
6
6
 
7
7
  The goal is to filter noise and distill key information so context accumulation stays under control.
8
8
  Keep active context uncompressed.
9
+
10
+ Never repeat or quote this reminder in your reply.
9
11
  </dcp-system-reminder>
10
12
  `
@@ -10,6 +10,12 @@ import { homedir } from "os"
10
10
  import { join } from "path"
11
11
  import type { CompressionBlock, PrunedMessageEntry, SessionState, SessionStats } from "./types"
12
12
  import type { Logger } from "../logger"
13
+ import {
14
+ addCompressionUsageTotals,
15
+ emptyCompressionUsage,
16
+ normalizeCompressionUsageTotals,
17
+ type CompressionUsageTotals,
18
+ } from "../compress/usage"
13
19
  import { serializePruneMessagesState } from "./utils"
14
20
 
15
21
  /** Prune state as stored on disk */
@@ -227,6 +233,7 @@ function emptyPersistedState(manualMode: boolean): PersistedSessionState {
227
233
  stats: {
228
234
  pruneTokenCounter: 0,
229
235
  totalPruneTokens: 0,
236
+ compressionUsage: emptyCompressionUsage(),
230
237
  },
231
238
  lastUpdated: new Date().toISOString(),
232
239
  }
@@ -257,6 +264,7 @@ export interface AggregatedStats {
257
264
  totalTools: number
258
265
  totalMessages: number
259
266
  sessionCount: number
267
+ compressionUsage: CompressionUsageTotals
260
268
  }
261
269
 
262
270
  export async function loadAllSessionStats(logger: Logger): Promise<AggregatedStats> {
@@ -265,6 +273,7 @@ export async function loadAllSessionStats(logger: Logger): Promise<AggregatedSta
265
273
  totalTools: 0,
266
274
  totalMessages: 0,
267
275
  sessionCount: 0,
276
+ compressionUsage: emptyCompressionUsage(),
268
277
  }
269
278
 
270
279
  try {
@@ -281,14 +290,21 @@ export async function loadAllSessionStats(logger: Logger): Promise<AggregatedSta
281
290
  const content = await fs.readFile(filePath, "utf-8")
282
291
  const state = JSON.parse(content) as PersistedSessionState
283
292
 
284
- if (state?.stats?.totalPruneTokens && state?.prune) {
285
- result.totalTokens += state.stats.totalPruneTokens
293
+ const usage = normalizeCompressionUsageTotals(state?.stats?.compressionUsage)
294
+ const hasUsage = usage.calls > 0
295
+
296
+ if (state?.prune && (state?.stats?.totalPruneTokens || hasUsage)) {
297
+ result.totalTokens += state.stats?.totalPruneTokens || 0
286
298
  result.totalTools += state.prune.tools
287
299
  ? Object.keys(state.prune.tools).length
288
300
  : 0
289
301
  result.totalMessages += state.prune.messages?.byMessageId
290
302
  ? Object.keys(state.prune.messages.byMessageId).length
291
303
  : 0
304
+ result.compressionUsage = addCompressionUsageTotals(
305
+ result.compressionUsage,
306
+ usage,
307
+ )
292
308
  result.sessionCount++
293
309
  }
294
310
  } catch {
@@ -1,5 +1,6 @@
1
1
  import type { SessionState, ToolParameterEntry, WithParts } from "./types"
2
2
  import type { Logger } from "../logger"
3
+ import { emptyCompressionUsage, normalizeCompressionUsageTotals } from "../compress/usage"
3
4
  import { applyPendingCompressionDurations } from "../compress/timing"
4
5
  import { loadManualModeSetting, loadSessionState, saveSessionState } from "./persistence"
5
6
  import {
@@ -84,6 +85,7 @@ export function createSessionState(idFormat: IdFormat = "xml"): SessionState {
84
85
  stats: {
85
86
  pruneTokenCounter: 0,
86
87
  totalPruneTokens: 0,
88
+ compressionUsage: emptyCompressionUsage(),
87
89
  },
88
90
  compressionTiming: {
89
91
  startsByCallId: new Map<string, number>(),
@@ -122,6 +124,7 @@ export function resetSessionState(state: SessionState): void {
122
124
  state.stats = {
123
125
  pruneTokenCounter: 0,
124
126
  totalPruneTokens: 0,
127
+ compressionUsage: emptyCompressionUsage(),
125
128
  }
126
129
  state.toolParameters.clear()
127
130
  state.subAgentResultCache.clear()
@@ -186,6 +189,7 @@ export async function ensureSessionInitialized(
186
189
  state.stats = {
187
190
  pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0,
188
191
  totalPruneTokens: persisted.stats?.totalPruneTokens || 0,
192
+ compressionUsage: normalizeCompressionUsageTotals(persisted.stats?.compressionUsage),
189
193
  }
190
194
 
191
195
  const applied = applyPendingCompressionDurations(state)
@@ -1,4 +1,5 @@
1
1
  import type { CompressionTimingState } from "../compress/timing"
2
+ import type { CompressionUsageTotals } from "../compress/usage"
2
3
  import type { IdFormat } from "../message-ids"
3
4
  import { Message, Part } from "@opencode-ai/sdk/v2"
4
5
 
@@ -22,6 +23,8 @@ export interface ToolParameterEntry {
22
23
  export interface SessionStats {
23
24
  pruneTokenCounter: number
24
25
  totalPruneTokens: number
26
+ /** Token usage of the hidden compression (summary) requests. */
27
+ compressionUsage: CompressionUsageTotals
25
28
  }
26
29
 
27
30
  export interface PrunedMessageEntry {
@@ -121,6 +121,24 @@ export function StatsDialog(props: { api: ViewApi; report: StatsReport; onBack:
121
121
  label="Messages pruned"
122
122
  value={`${props.report.sessionMessages}`}
123
123
  />
124
+ <Metric
125
+ theme={theme}
126
+ label="Summary requests"
127
+ value={`${props.report.sessionCompressionUsage.calls}`}
128
+ hint={`${props.report.sessionCompressionUsage.providerCalls} provider · ${props.report.sessionCompressionUsage.estimatedCalls} estimated`}
129
+ />
130
+ <Metric
131
+ theme={theme}
132
+ label="Request in|out"
133
+ value={`~${formatTokenCount(props.report.sessionCompressionUsage.inputTokens)} | ~${formatTokenCount(props.report.sessionCompressionUsage.outputTokens)}`}
134
+ hint="tokens"
135
+ />
136
+ <Metric
137
+ theme={theme}
138
+ label="Request cache r|w"
139
+ value={`~${formatTokenCount(props.report.sessionCompressionUsage.cacheReadTokens)} | ~${formatTokenCount(props.report.sessionCompressionUsage.cacheWriteTokens)}`}
140
+ hint={`reasoning ~${formatTokenCount(props.report.sessionCompressionUsage.reasoningTokens)}`}
141
+ />
124
142
  </Card>
125
143
  <Card theme={theme} title="All time">
126
144
  <Metric
@@ -144,6 +162,12 @@ export function StatsDialog(props: { api: ViewApi; report: StatsReport; onBack:
144
162
  label="Sessions with DCP history"
145
163
  value={`${props.report.allTime.sessionCount}`}
146
164
  />
165
+ <Metric
166
+ theme={theme}
167
+ label="Summary requests"
168
+ value={`${props.report.allTime.compressionUsage.calls}`}
169
+ hint={`~${formatTokenCount(props.report.allTime.compressionUsage.inputTokens)} in · ~${formatTokenCount(props.report.allTime.compressionUsage.outputTokens)} out`}
170
+ />
147
171
  </Card>
148
172
  </DcpFrame>
149
173
  )