@royalcat/opencode-dcp-rc 4.0.0 → 4.0.1

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.
@@ -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
+ }
@@ -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
  )
package/lib/v2/index.ts CHANGED
@@ -4,8 +4,13 @@ import { getConfig } from "../config"
4
4
  import { Logger } from "../logger"
5
5
  import { PromptStore } from "../prompts/store"
6
6
  import { createRcCompressTool } from "../compress"
7
- import { RC_SUMMARY_MARKER } from "../compress/summary"
7
+ import { extractSummaryCallId, hasSummaryMarker } from "../compress/summary"
8
8
  import { attachCompressionDuration } from "../compress/state"
9
+ import {
10
+ createCompressionUsageTracker,
11
+ installCompressionUsageHook,
12
+ installHttpUsageHook,
13
+ } from "./usage"
9
14
  import { createCommandExecuteHandler, createSystemPromptHandler } from "../hooks"
10
15
  import {
11
16
  createSessionState,
@@ -56,6 +61,9 @@ export async function setup(ctx: Plugin.Context) {
56
61
  return
57
62
  }
58
63
  const logger = new Logger(config.debug)
64
+ const usageTracker = createCompressionUsageTracker()
65
+ await installCompressionUsageHook(ctx, usageTracker, logger)
66
+ await installHttpUsageHook(ctx, usageTracker, logger)
59
67
  const prompts = new PromptStore(
60
68
  logger,
61
69
  ctx.location.directory,
@@ -251,7 +259,7 @@ export async function setup(ctx: Plugin.Context) {
251
259
  (part: any) =>
252
260
  part?.type === "text" &&
253
261
  typeof part.text === "string" &&
254
- part.text.includes(RC_SUMMARY_MARKER),
262
+ hasSummaryMarker(part.text),
255
263
  ),
256
264
  )
257
265
  if (own.length === 0) {
@@ -261,6 +269,32 @@ export async function setup(ctx: Plugin.Context) {
261
269
  for (const name of Object.keys(event.tools ?? {})) {
262
270
  delete (event.tools as Record<string, unknown>)[name]
263
271
  }
272
+
273
+ // Local fallback estimate of the request input, used only when the
274
+ // provider usage cannot be captured from the language model wrapper.
275
+ const callId = own
276
+ .flatMap((message) =>
277
+ Array.isArray(message.content) ? (message.content as any[]) : [],
278
+ )
279
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
280
+ .map((part) => extractSummaryCallId(part.text))
281
+ .find((id): id is string => !!id)
282
+ if (callId) {
283
+ const texts: string[] = []
284
+ for (const part of event.system ?? []) {
285
+ if (typeof (part as any)?.text === "string") {
286
+ texts.push((part as any).text)
287
+ }
288
+ }
289
+ for (const message of own) {
290
+ for (const part of (message.content as any[]) ?? []) {
291
+ if (part?.type === "text" && typeof part.text === "string") {
292
+ texts.push(part.text)
293
+ }
294
+ }
295
+ }
296
+ usageTracker.recordEstimatedInput(callId, countTokens(texts.join("\n")))
297
+ }
264
298
  })
265
299
 
266
300
  if (config.compress.permission !== "deny") {
@@ -271,9 +305,22 @@ export async function setup(ctx: Plugin.Context) {
271
305
  logger,
272
306
  config,
273
307
  prompts,
274
- generate: async (input: { sessionID: string; prompt: string }) => {
275
- const result = await ctx.session.generate(input)
276
- return result?.text ?? ""
308
+ generate: async (input: { sessionID: string; prompt: string; callId?: string }) => {
309
+ try {
310
+ const result = await ctx.session.generate({
311
+ sessionID: input.sessionID,
312
+ prompt: input.prompt,
313
+ })
314
+ const text = result?.text ?? ""
315
+ const usage = input.callId
316
+ ? usageTracker.resolve(input.callId, text)
317
+ : undefined
318
+ return { text, usage }
319
+ } finally {
320
+ if (input.callId) {
321
+ usageTracker.discard(input.callId)
322
+ }
323
+ }
277
324
  },
278
325
  }
279
326
  return createRcCompressTool(context)
package/lib/v2/rpc.ts CHANGED
@@ -2,17 +2,29 @@ import { tool } from "@opencode-ai/plugin"
2
2
 
3
3
  const z = tool.schema
4
4
  const session = z.object({ sessionID: z.string() })
5
+ const compressionUsage = z.object({
6
+ calls: z.number(),
7
+ providerCalls: z.number(),
8
+ estimatedCalls: z.number(),
9
+ inputTokens: z.number(),
10
+ outputTokens: z.number(),
11
+ cacheReadTokens: z.number(),
12
+ cacheWriteTokens: z.number(),
13
+ reasoningTokens: z.number(),
14
+ })
5
15
  const stats = z.object({
6
16
  sessionTokens: z.number(),
7
17
  sessionSummaryTokens: z.number(),
8
18
  sessionDurationMs: z.number(),
9
19
  sessionTools: z.number(),
10
20
  sessionMessages: z.number(),
21
+ sessionCompressionUsage: compressionUsage,
11
22
  allTime: z.object({
12
23
  totalTokens: z.number(),
13
24
  totalTools: z.number(),
14
25
  totalMessages: z.number(),
15
26
  sessionCount: z.number(),
27
+ compressionUsage,
16
28
  }),
17
29
  })
18
30
  const context = z.object({