@tarquinen/opencode-dcp 3.2.1-beta0 → 3.2.2-beta0

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,225 @@
1
+ /**
2
+ * Shared Token Analysis
3
+ * Computes a breakdown of token usage across categories for a session.
4
+ *
5
+ * TOKEN CALCULATION STRATEGY
6
+ * ==========================
7
+ * We minimize tokenizer estimation by leveraging API-reported values wherever possible.
8
+ *
9
+ * WHAT WE GET FROM THE API (exact):
10
+ * - tokens.input : Input tokens for each assistant response
11
+ * - tokens.output : Output tokens generated (includes text + tool calls)
12
+ * - tokens.reasoning: Reasoning tokens used
13
+ * - tokens.cache : Cache read/write tokens
14
+ *
15
+ * HOW WE CALCULATE EACH CATEGORY:
16
+ *
17
+ * SYSTEM = firstAssistant.input + cache.read + cache.write - tokenizer(firstUserMessage)
18
+ * The first response's total input (input + cache.read + cache.write)
19
+ * contains system + first user message. On the first request of a
20
+ * session, the system prompt appears in cache.write (cache creation),
21
+ * not cache.read.
22
+ *
23
+ * TOOLS = tokenizer(toolInputs + toolOutputs) - prunedTokens
24
+ * We must tokenize tools anyway for pruning decisions.
25
+ *
26
+ * USER = tokenizer(all user messages)
27
+ * User messages are typically small, so estimation is acceptable.
28
+ *
29
+ * ASSISTANT = total - system - user - tools
30
+ * Calculated as residual. This absorbs:
31
+ * - Assistant text output tokens
32
+ * - Reasoning tokens (if persisted by the model)
33
+ * - Any estimation errors
34
+ *
35
+ * TOTAL = input + output + reasoning + cache.read + cache.write
36
+ * Matches opencode's UI display.
37
+ *
38
+ * WHY ASSISTANT IS THE RESIDUAL:
39
+ * If reasoning tokens persist in context (model-dependent), they semantically
40
+ * belong with "Assistant" since reasoning IS assistant-generated content.
41
+ */
42
+
43
+ import type { AssistantMessage, TextPart, ToolPart } from "@opencode-ai/sdk/v2"
44
+ import type { SessionState, WithParts } from "../state"
45
+ import { isIgnoredUserMessage } from "../messages/query"
46
+ import { isMessageCompacted } from "../state/utils"
47
+ import { countTokens, extractCompletedToolOutput } from "../token-utils"
48
+
49
+ export type MessageStatus = "active" | "pruned"
50
+
51
+ export interface TokenBreakdown {
52
+ system: number
53
+ user: number
54
+ assistant: number
55
+ tools: number
56
+ toolCount: number
57
+ toolsInContextCount: number
58
+ prunedTokens: number
59
+ prunedToolCount: number
60
+ prunedMessageCount: number
61
+ total: number
62
+ messageCount: number
63
+ }
64
+
65
+ export interface TokenAnalysis {
66
+ breakdown: TokenBreakdown
67
+ messageStatuses: MessageStatus[]
68
+ }
69
+
70
+ export function emptyBreakdown(): TokenBreakdown {
71
+ return {
72
+ system: 0,
73
+ user: 0,
74
+ assistant: 0,
75
+ tools: 0,
76
+ toolCount: 0,
77
+ toolsInContextCount: 0,
78
+ prunedTokens: 0,
79
+ prunedToolCount: 0,
80
+ prunedMessageCount: 0,
81
+ total: 0,
82
+ messageCount: 0,
83
+ }
84
+ }
85
+
86
+ export function analyzeTokens(state: SessionState, messages: WithParts[]): TokenAnalysis {
87
+ const breakdown = emptyBreakdown()
88
+ const messageStatuses: MessageStatus[] = []
89
+ breakdown.prunedTokens = state.stats.totalPruneTokens
90
+
91
+ let firstAssistant: AssistantMessage | undefined
92
+ for (const msg of messages) {
93
+ if (msg.info.role !== "assistant") continue
94
+ const assistantInfo = msg.info as AssistantMessage
95
+ if (
96
+ assistantInfo.tokens?.input > 0 ||
97
+ assistantInfo.tokens?.cache?.read > 0 ||
98
+ assistantInfo.tokens?.cache?.write > 0
99
+ ) {
100
+ firstAssistant = assistantInfo
101
+ break
102
+ }
103
+ }
104
+
105
+ let lastAssistant: AssistantMessage | undefined
106
+ for (let i = messages.length - 1; i >= 0; i--) {
107
+ const msg = messages[i]
108
+ if (msg.info.role !== "assistant") continue
109
+ const assistantInfo = msg.info as AssistantMessage
110
+ if (assistantInfo.tokens?.output > 0) {
111
+ lastAssistant = assistantInfo
112
+ break
113
+ }
114
+ }
115
+
116
+ const apiInput = lastAssistant?.tokens?.input || 0
117
+ const apiOutput = lastAssistant?.tokens?.output || 0
118
+ const apiReasoning = lastAssistant?.tokens?.reasoning || 0
119
+ const apiCacheRead = lastAssistant?.tokens?.cache?.read || 0
120
+ const apiCacheWrite = lastAssistant?.tokens?.cache?.write || 0
121
+ breakdown.total = apiInput + apiOutput + apiReasoning + apiCacheRead + apiCacheWrite
122
+
123
+ const userTextParts: string[] = []
124
+ const toolInputParts: string[] = []
125
+ const toolOutputParts: string[] = []
126
+ const allToolIds = new Set<string>()
127
+ const activeToolIds = new Set<string>()
128
+ const prunedByMessageToolIds = new Set<string>()
129
+ const allMessageIds = new Set<string>()
130
+
131
+ let firstUserText = ""
132
+ let foundFirstUser = false
133
+
134
+ for (const msg of messages) {
135
+ const ignoredUser = msg.info.role === "user" && isIgnoredUserMessage(msg)
136
+ if (ignoredUser) continue
137
+
138
+ allMessageIds.add(msg.info.id)
139
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
140
+ const compacted = isMessageCompacted(state, msg)
141
+ const pruneEntry = state.prune.messages.byMessageId.get(msg.info.id)
142
+ const messagePruned = !!pruneEntry && pruneEntry.activeBlockIds.length > 0
143
+ const messageActive = !compacted && !messagePruned
144
+
145
+ breakdown.messageCount += 1
146
+ messageStatuses.push(messageActive ? "active" : "pruned")
147
+
148
+ for (const part of parts) {
149
+ if (part.type === "tool") {
150
+ const toolPart = part as ToolPart
151
+ if (toolPart.callID) {
152
+ allToolIds.add(toolPart.callID)
153
+ if (!compacted) activeToolIds.add(toolPart.callID)
154
+ if (messagePruned) prunedByMessageToolIds.add(toolPart.callID)
155
+ }
156
+
157
+ const toolPruned = toolPart.callID && state.prune.tools.has(toolPart.callID)
158
+ if (!compacted && !toolPruned) {
159
+ if (toolPart.state?.input) {
160
+ const inputText =
161
+ typeof toolPart.state.input === "string"
162
+ ? toolPart.state.input
163
+ : JSON.stringify(toolPart.state.input)
164
+ toolInputParts.push(inputText)
165
+ }
166
+ const outputText = extractCompletedToolOutput(toolPart)
167
+ if (outputText !== undefined) {
168
+ toolOutputParts.push(outputText)
169
+ }
170
+ }
171
+ continue
172
+ }
173
+
174
+ if (part.type === "text" && msg.info.role === "user" && !compacted) {
175
+ const textPart = part as TextPart
176
+ const text = textPart.text || ""
177
+ userTextParts.push(text)
178
+ if (!foundFirstUser) firstUserText += text
179
+ }
180
+ }
181
+
182
+ if (msg.info.role === "user" && !foundFirstUser) {
183
+ foundFirstUser = true
184
+ }
185
+ }
186
+
187
+ const prunedByToolIds = new Set<string>()
188
+ for (const toolID of allToolIds) {
189
+ if (state.prune.tools.has(toolID)) prunedByToolIds.add(toolID)
190
+ }
191
+
192
+ const prunedToolIds = new Set<string>([...prunedByToolIds, ...prunedByMessageToolIds])
193
+ breakdown.toolCount = allToolIds.size
194
+ breakdown.toolsInContextCount = [...activeToolIds].filter(
195
+ (id) => !prunedByToolIds.has(id),
196
+ ).length
197
+ breakdown.prunedToolCount = prunedToolIds.size
198
+
199
+ for (const [messageID, entry] of state.prune.messages.byMessageId) {
200
+ if (allMessageIds.has(messageID) && entry.activeBlockIds.length > 0) {
201
+ breakdown.prunedMessageCount += 1
202
+ }
203
+ }
204
+
205
+ const firstUserTokens = countTokens(firstUserText)
206
+ breakdown.user = countTokens(userTextParts.join("\n"))
207
+ const toolInputTokens = countTokens(toolInputParts.join("\n"))
208
+ const toolOutputTokens = countTokens(toolOutputParts.join("\n"))
209
+
210
+ if (firstAssistant) {
211
+ const firstInput =
212
+ (firstAssistant.tokens?.input || 0) +
213
+ (firstAssistant.tokens?.cache?.read || 0) +
214
+ (firstAssistant.tokens?.cache?.write || 0)
215
+ breakdown.system = Math.max(0, firstInput - firstUserTokens)
216
+ }
217
+
218
+ breakdown.tools = toolInputTokens + toolOutputTokens
219
+ breakdown.assistant = Math.max(
220
+ 0,
221
+ breakdown.total - breakdown.system - breakdown.user - breakdown.tools,
222
+ )
223
+
224
+ return { breakdown, messageStatuses }
225
+ }