@tarquinen/opencode-dcp 3.2.5-beta0 → 3.2.6-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.
- package/index.ts +141 -0
- package/lib/analysis/tokens.ts +225 -0
- package/lib/auth.ts +37 -0
- package/lib/commands/compression-targets.ts +137 -0
- package/lib/commands/context.ts +132 -0
- package/lib/commands/decompress.ts +275 -0
- package/lib/commands/help.ts +76 -0
- package/lib/commands/index.ts +11 -0
- package/lib/commands/manual.ts +125 -0
- package/lib/commands/recompress.ts +224 -0
- package/lib/commands/stats.ts +148 -0
- package/lib/commands/sweep.ts +268 -0
- package/lib/compress/index.ts +3 -0
- package/lib/compress/message-utils.ts +250 -0
- package/lib/compress/message.ts +137 -0
- package/lib/compress/pipeline.ts +106 -0
- package/lib/compress/protected-content.ts +154 -0
- package/lib/compress/range-utils.ts +308 -0
- package/lib/compress/range.ts +180 -0
- package/lib/compress/search.ts +267 -0
- package/lib/compress/state.ts +268 -0
- package/lib/compress/timing.ts +77 -0
- package/lib/compress/types.ts +108 -0
- package/lib/compress-permission.ts +25 -0
- package/lib/config.ts +1071 -0
- package/lib/hooks.ts +378 -0
- package/lib/host-permissions.ts +101 -0
- package/lib/logger.ts +235 -0
- package/lib/message-ids.ts +172 -0
- package/lib/messages/index.ts +8 -0
- package/lib/messages/inject/inject.ts +215 -0
- package/lib/messages/inject/subagent-results.ts +82 -0
- package/lib/messages/inject/utils.ts +374 -0
- package/lib/messages/priority.ts +102 -0
- package/lib/messages/prune.ts +238 -0
- package/lib/messages/query.ts +56 -0
- package/lib/messages/reasoning-strip.ts +40 -0
- package/lib/messages/sync.ts +124 -0
- package/lib/messages/utils.ts +187 -0
- package/lib/prompts/compress-message.ts +42 -0
- package/lib/prompts/compress-range.ts +60 -0
- package/lib/prompts/context-limit-nudge.ts +18 -0
- package/lib/prompts/extensions/nudge.ts +43 -0
- package/lib/prompts/extensions/system.ts +32 -0
- package/lib/prompts/extensions/tool.ts +35 -0
- package/lib/prompts/index.ts +29 -0
- package/lib/prompts/iteration-nudge.ts +6 -0
- package/lib/prompts/store.ts +467 -0
- package/lib/prompts/system.ts +33 -0
- package/lib/prompts/turn-nudge.ts +10 -0
- package/lib/protected-patterns.ts +128 -0
- package/lib/state/index.ts +4 -0
- package/lib/state/persistence.ts +256 -0
- package/lib/state/state.ts +190 -0
- package/lib/state/tool-cache.ts +98 -0
- package/lib/state/types.ts +112 -0
- package/lib/state/utils.ts +334 -0
- package/lib/strategies/deduplication.ts +127 -0
- package/lib/strategies/index.ts +2 -0
- package/lib/strategies/purge-errors.ts +88 -0
- package/lib/subagents/subagent-results.ts +74 -0
- package/lib/token-utils.ts +162 -0
- package/lib/ui/notification.ts +346 -0
- package/lib/ui/utils.ts +287 -0
- package/package.json +9 -2
- package/tui/data/context.ts +177 -0
- package/tui/index.tsx +34 -0
- package/tui/routes/summary.tsx +175 -0
- package/tui/shared/names.ts +9 -0
- package/tui/shared/theme.ts +58 -0
- package/tui/shared/types.ts +38 -0
- package/tui/slots/sidebar-content.tsx +502 -0
package/index.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
import { getConfig } from "./lib/config"
|
|
3
|
+
import { createCompressMessageTool, createCompressRangeTool } from "./lib/compress"
|
|
4
|
+
import {
|
|
5
|
+
compressDisabledByOpencode,
|
|
6
|
+
hasExplicitToolPermission,
|
|
7
|
+
type HostPermissionSnapshot,
|
|
8
|
+
} from "./lib/host-permissions"
|
|
9
|
+
import { Logger } from "./lib/logger"
|
|
10
|
+
import { createSessionState } from "./lib/state"
|
|
11
|
+
import { PromptStore } from "./lib/prompts/store"
|
|
12
|
+
import {
|
|
13
|
+
createChatMessageHandler,
|
|
14
|
+
createChatMessageTransformHandler,
|
|
15
|
+
createCommandExecuteHandler,
|
|
16
|
+
createEventHandler,
|
|
17
|
+
createSystemPromptHandler,
|
|
18
|
+
createTextCompleteHandler,
|
|
19
|
+
} from "./lib/hooks"
|
|
20
|
+
import { configureClientAuth, isSecureMode } from "./lib/auth"
|
|
21
|
+
|
|
22
|
+
const id = "opencode-dynamic-context-pruning"
|
|
23
|
+
|
|
24
|
+
const server: Plugin = (async (ctx) => {
|
|
25
|
+
const config = getConfig(ctx)
|
|
26
|
+
|
|
27
|
+
if (!config.enabled) {
|
|
28
|
+
return {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const logger = new Logger(config.debug)
|
|
32
|
+
const state = createSessionState()
|
|
33
|
+
const prompts = new PromptStore(logger, ctx.directory, config.experimental.customPrompts)
|
|
34
|
+
const hostPermissions: HostPermissionSnapshot = {
|
|
35
|
+
global: undefined,
|
|
36
|
+
agents: {},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (isSecureMode()) {
|
|
40
|
+
configureClientAuth(ctx.client)
|
|
41
|
+
// logger.info("Secure mode detected, configured client authentication")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
logger.info("DCP initialized", {
|
|
45
|
+
strategies: config.strategies,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const compressToolContext = {
|
|
49
|
+
client: ctx.client,
|
|
50
|
+
state,
|
|
51
|
+
logger,
|
|
52
|
+
config,
|
|
53
|
+
prompts,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
"experimental.chat.system.transform": createSystemPromptHandler(
|
|
58
|
+
state,
|
|
59
|
+
logger,
|
|
60
|
+
config,
|
|
61
|
+
prompts,
|
|
62
|
+
),
|
|
63
|
+
"experimental.chat.messages.transform": createChatMessageTransformHandler(
|
|
64
|
+
ctx.client,
|
|
65
|
+
state,
|
|
66
|
+
logger,
|
|
67
|
+
config,
|
|
68
|
+
prompts,
|
|
69
|
+
hostPermissions,
|
|
70
|
+
) as any,
|
|
71
|
+
"chat.message": createChatMessageHandler(state, logger, config, hostPermissions),
|
|
72
|
+
"experimental.text.complete": createTextCompleteHandler(),
|
|
73
|
+
"command.execute.before": createCommandExecuteHandler(
|
|
74
|
+
ctx.client,
|
|
75
|
+
state,
|
|
76
|
+
logger,
|
|
77
|
+
config,
|
|
78
|
+
ctx.directory,
|
|
79
|
+
hostPermissions,
|
|
80
|
+
),
|
|
81
|
+
event: createEventHandler(state, logger),
|
|
82
|
+
tool: {
|
|
83
|
+
...(config.compress.permission !== "deny" && {
|
|
84
|
+
compress:
|
|
85
|
+
config.compress.mode === "message"
|
|
86
|
+
? createCompressMessageTool(compressToolContext)
|
|
87
|
+
: createCompressRangeTool(compressToolContext),
|
|
88
|
+
}),
|
|
89
|
+
},
|
|
90
|
+
config: async (opencodeConfig) => {
|
|
91
|
+
if (
|
|
92
|
+
config.compress.permission !== "deny" &&
|
|
93
|
+
compressDisabledByOpencode(opencodeConfig.permission)
|
|
94
|
+
) {
|
|
95
|
+
config.compress.permission = "deny"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (config.commands.enabled && config.compress.permission !== "deny") {
|
|
99
|
+
opencodeConfig.command ??= {}
|
|
100
|
+
opencodeConfig.command["dcp"] = {
|
|
101
|
+
template: "",
|
|
102
|
+
description: "Show available DCP commands",
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const toolsToAdd: string[] = []
|
|
107
|
+
if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) {
|
|
108
|
+
toolsToAdd.push("compress")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (toolsToAdd.length > 0) {
|
|
112
|
+
const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? []
|
|
113
|
+
opencodeConfig.experimental = {
|
|
114
|
+
...opencodeConfig.experimental,
|
|
115
|
+
primary_tools: [...existingPrimaryTools, ...toolsToAdd],
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!hasExplicitToolPermission(opencodeConfig.permission, "compress")) {
|
|
120
|
+
const permission = opencodeConfig.permission ?? {}
|
|
121
|
+
opencodeConfig.permission = {
|
|
122
|
+
...permission,
|
|
123
|
+
compress: config.compress.permission,
|
|
124
|
+
} as typeof permission
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
hostPermissions.global = opencodeConfig.permission
|
|
128
|
+
hostPermissions.agents = Object.fromEntries(
|
|
129
|
+
Object.entries(opencodeConfig.agent ?? {}).map(([name, agent]) => [
|
|
130
|
+
name,
|
|
131
|
+
agent?.permission,
|
|
132
|
+
]),
|
|
133
|
+
)
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}) satisfies Plugin
|
|
137
|
+
|
|
138
|
+
export default {
|
|
139
|
+
id,
|
|
140
|
+
server,
|
|
141
|
+
}
|
|
@@ -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
|
+
}
|
package/lib/auth.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function isSecureMode(): boolean {
|
|
2
|
+
return !!process.env.OPENCODE_SERVER_PASSWORD
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function getAuthorizationHeader(): string | undefined {
|
|
6
|
+
const password = process.env.OPENCODE_SERVER_PASSWORD
|
|
7
|
+
if (!password) return undefined
|
|
8
|
+
|
|
9
|
+
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
|
|
10
|
+
// Use Buffer for Node.js base64 encoding (btoa may not be available in all Node versions)
|
|
11
|
+
const credentials = Buffer.from(`${username}:${password}`).toString("base64")
|
|
12
|
+
return `Basic ${credentials}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function configureClientAuth(client: any): any {
|
|
16
|
+
const authHeader = getAuthorizationHeader()
|
|
17
|
+
|
|
18
|
+
if (!authHeader) {
|
|
19
|
+
return client
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The SDK client has an internal client with request interceptors
|
|
23
|
+
// Access the underlying client to add the interceptor
|
|
24
|
+
const innerClient = client._client || client.client
|
|
25
|
+
|
|
26
|
+
if (innerClient?.interceptors?.request) {
|
|
27
|
+
innerClient.interceptors.request.use((request: Request) => {
|
|
28
|
+
// Only add auth header if not already present
|
|
29
|
+
if (!request.headers.has("Authorization")) {
|
|
30
|
+
request.headers.set("Authorization", authHeader)
|
|
31
|
+
}
|
|
32
|
+
return request
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return client
|
|
37
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { CompressionBlock, PruneMessagesState } from "../state"
|
|
2
|
+
|
|
3
|
+
export interface CompressionTarget {
|
|
4
|
+
displayId: number
|
|
5
|
+
runId: number
|
|
6
|
+
topic: string
|
|
7
|
+
compressedTokens: number
|
|
8
|
+
durationMs: number
|
|
9
|
+
grouped: boolean
|
|
10
|
+
blocks: CompressionBlock[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function byBlockId(a: CompressionBlock, b: CompressionBlock): number {
|
|
14
|
+
return a.blockId - b.blockId
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildTarget(blocks: CompressionBlock[]): CompressionTarget {
|
|
18
|
+
const ordered = [...blocks].sort(byBlockId)
|
|
19
|
+
const first = ordered[0]
|
|
20
|
+
if (!first) {
|
|
21
|
+
throw new Error("Cannot build compression target from empty block list.")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const grouped = first.mode === "message"
|
|
25
|
+
return {
|
|
26
|
+
displayId: first.blockId,
|
|
27
|
+
runId: first.runId,
|
|
28
|
+
topic: grouped ? first.batchTopic || first.topic : first.topic,
|
|
29
|
+
compressedTokens: ordered.reduce((total, block) => total + block.compressedTokens, 0),
|
|
30
|
+
durationMs: ordered.reduce((total, block) => Math.max(total, block.durationMs), 0),
|
|
31
|
+
grouped,
|
|
32
|
+
blocks: ordered,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function groupMessageBlocks(blocks: CompressionBlock[]): CompressionTarget[] {
|
|
37
|
+
const grouped = new Map<number, CompressionBlock[]>()
|
|
38
|
+
|
|
39
|
+
for (const block of blocks) {
|
|
40
|
+
const existing = grouped.get(block.runId)
|
|
41
|
+
if (existing) {
|
|
42
|
+
existing.push(block)
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
grouped.set(block.runId, [block])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return Array.from(grouped.values()).map(buildTarget)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function splitTargets(blocks: CompressionBlock[]): CompressionTarget[] {
|
|
52
|
+
const messageBlocks: CompressionBlock[] = []
|
|
53
|
+
const singleBlocks: CompressionBlock[] = []
|
|
54
|
+
|
|
55
|
+
for (const block of blocks) {
|
|
56
|
+
if (block.mode === "message") {
|
|
57
|
+
messageBlocks.push(block)
|
|
58
|
+
} else {
|
|
59
|
+
singleBlocks.push(block)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const targets = [
|
|
64
|
+
...singleBlocks.map((block) => buildTarget([block])),
|
|
65
|
+
...groupMessageBlocks(messageBlocks),
|
|
66
|
+
]
|
|
67
|
+
return targets.sort((a, b) => a.displayId - b.displayId)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getActiveCompressionTargets(
|
|
71
|
+
messagesState: PruneMessagesState,
|
|
72
|
+
): CompressionTarget[] {
|
|
73
|
+
const activeBlocks = Array.from(messagesState.activeBlockIds)
|
|
74
|
+
.map((blockId) => messagesState.blocksById.get(blockId))
|
|
75
|
+
.filter((block): block is CompressionBlock => !!block && block.active)
|
|
76
|
+
|
|
77
|
+
return splitTargets(activeBlocks)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getRecompressibleCompressionTargets(
|
|
81
|
+
messagesState: PruneMessagesState,
|
|
82
|
+
availableMessageIds: Set<string>,
|
|
83
|
+
): CompressionTarget[] {
|
|
84
|
+
const allBlocks = Array.from(messagesState.blocksById.values()).filter((block) => {
|
|
85
|
+
return availableMessageIds.has(block.compressMessageId)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const messageGroups = new Map<number, CompressionBlock[]>()
|
|
89
|
+
const singleTargets: CompressionTarget[] = []
|
|
90
|
+
|
|
91
|
+
for (const block of allBlocks) {
|
|
92
|
+
if (block.mode === "message") {
|
|
93
|
+
const existing = messageGroups.get(block.runId)
|
|
94
|
+
if (existing) {
|
|
95
|
+
existing.push(block)
|
|
96
|
+
} else {
|
|
97
|
+
messageGroups.set(block.runId, [block])
|
|
98
|
+
}
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (block.deactivatedByUser && !block.active) {
|
|
103
|
+
singleTargets.push(buildTarget([block]))
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const blocks of messageGroups.values()) {
|
|
108
|
+
if (blocks.some((block) => block.deactivatedByUser && !block.active)) {
|
|
109
|
+
singleTargets.push(buildTarget(blocks))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return singleTargets.sort((a, b) => a.displayId - b.displayId)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function resolveCompressionTarget(
|
|
117
|
+
messagesState: PruneMessagesState,
|
|
118
|
+
blockId: number,
|
|
119
|
+
): CompressionTarget | null {
|
|
120
|
+
const block = messagesState.blocksById.get(blockId)
|
|
121
|
+
if (!block) {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (block.mode !== "message") {
|
|
126
|
+
return buildTarget([block])
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const blocks = Array.from(messagesState.blocksById.values()).filter(
|
|
130
|
+
(candidate) => candidate.mode === "message" && candidate.runId === block.runId,
|
|
131
|
+
)
|
|
132
|
+
if (blocks.length === 0) {
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return buildTarget(blocks)
|
|
137
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DCP Context Command
|
|
3
|
+
* Shows a visual breakdown of token usage in the current session.
|
|
4
|
+
* Token calculation logic lives in ../analysis/tokens.ts
|
|
5
|
+
*
|
|
6
|
+
* TOKEN CALCULATION STRATEGY
|
|
7
|
+
* ==========================
|
|
8
|
+
* We minimize tokenizer estimation by leveraging API-reported values wherever possible.
|
|
9
|
+
*
|
|
10
|
+
* WHAT WE GET FROM THE API (exact):
|
|
11
|
+
* - tokens.input : Input tokens for each assistant response
|
|
12
|
+
* - tokens.output : Output tokens generated (includes text + tool calls)
|
|
13
|
+
* - tokens.reasoning: Reasoning tokens used
|
|
14
|
+
* - tokens.cache : Cache read/write tokens
|
|
15
|
+
*
|
|
16
|
+
* HOW WE CALCULATE EACH CATEGORY:
|
|
17
|
+
*
|
|
18
|
+
* SYSTEM = firstAssistant.input + cache.read + cache.write - tokenizer(firstUserMessage)
|
|
19
|
+
* The first response's total input (input + cache.read + cache.write)
|
|
20
|
+
* contains system + first user message. On the first request of a
|
|
21
|
+
* session, the system prompt appears in cache.write (cache creation),
|
|
22
|
+
* not cache.read.
|
|
23
|
+
*
|
|
24
|
+
* TOOLS = tokenizer(toolInputs + toolOutputs) - prunedTokens
|
|
25
|
+
* We must tokenize tools anyway for pruning decisions.
|
|
26
|
+
*
|
|
27
|
+
* USER = tokenizer(all user messages)
|
|
28
|
+
* User messages are typically small, so estimation is acceptable.
|
|
29
|
+
*
|
|
30
|
+
* ASSISTANT = total - system - user - tools
|
|
31
|
+
* Calculated as residual. This absorbs:
|
|
32
|
+
* - Assistant text output tokens
|
|
33
|
+
* - Reasoning tokens (if persisted by the model)
|
|
34
|
+
* - Any estimation errors
|
|
35
|
+
*
|
|
36
|
+
* TOTAL = input + output + reasoning + cache.read + cache.write
|
|
37
|
+
* Matches opencode's UI display.
|
|
38
|
+
*
|
|
39
|
+
* WHY ASSISTANT IS THE RESIDUAL:
|
|
40
|
+
* If reasoning tokens persist in context (model-dependent), they semantically
|
|
41
|
+
* belong with "Assistant" since reasoning IS assistant-generated content.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import type { Logger } from "../logger"
|
|
45
|
+
import type { SessionState, WithParts } from "../state"
|
|
46
|
+
import { sendIgnoredMessage } from "../ui/notification"
|
|
47
|
+
import { formatTokenCount } from "../ui/utils"
|
|
48
|
+
import { getCurrentParams } from "../token-utils"
|
|
49
|
+
import { analyzeTokens, type TokenBreakdown } from "../analysis/tokens"
|
|
50
|
+
|
|
51
|
+
export interface ContextCommandContext {
|
|
52
|
+
client: any
|
|
53
|
+
state: SessionState
|
|
54
|
+
logger: Logger
|
|
55
|
+
sessionId: string
|
|
56
|
+
messages: WithParts[]
|
|
57
|
+
}
|
|
58
|
+
function createBar(value: number, maxValue: number, width: number, char: string = "█"): string {
|
|
59
|
+
if (maxValue === 0) return ""
|
|
60
|
+
const filled = Math.round((value / maxValue) * width)
|
|
61
|
+
const bar = char.repeat(Math.max(0, filled))
|
|
62
|
+
return bar
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatContextMessage(breakdown: TokenBreakdown): string {
|
|
66
|
+
const lines: string[] = []
|
|
67
|
+
const barWidth = 30
|
|
68
|
+
|
|
69
|
+
const toolsLabel = `Tools (${breakdown.toolsInContextCount})`
|
|
70
|
+
|
|
71
|
+
const categories = [
|
|
72
|
+
{ label: "System", value: breakdown.system, char: "█" },
|
|
73
|
+
{ label: "User", value: breakdown.user, char: "▓" },
|
|
74
|
+
{ label: "Assistant", value: breakdown.assistant, char: "▒" },
|
|
75
|
+
{ label: toolsLabel, value: breakdown.tools, char: "░" },
|
|
76
|
+
] as const
|
|
77
|
+
|
|
78
|
+
const maxLabelLen = Math.max(...categories.map((c) => c.label.length))
|
|
79
|
+
|
|
80
|
+
lines.push("╭───────────────────────────────────────────────────────────╮")
|
|
81
|
+
lines.push("│ DCP Context Analysis │")
|
|
82
|
+
lines.push("╰───────────────────────────────────────────────────────────╯")
|
|
83
|
+
lines.push("")
|
|
84
|
+
lines.push("Session Context Breakdown:")
|
|
85
|
+
lines.push("─".repeat(60))
|
|
86
|
+
lines.push("")
|
|
87
|
+
|
|
88
|
+
for (const cat of categories) {
|
|
89
|
+
const bar = createBar(cat.value, breakdown.total, barWidth, cat.char)
|
|
90
|
+
const percentage =
|
|
91
|
+
breakdown.total > 0 ? ((cat.value / breakdown.total) * 100).toFixed(1) : "0.0"
|
|
92
|
+
const labelWithPct = `${cat.label.padEnd(maxLabelLen)} ${percentage.padStart(5)}% `
|
|
93
|
+
const valueStr = formatTokenCount(cat.value).padStart(13)
|
|
94
|
+
lines.push(`${labelWithPct}│${bar.padEnd(barWidth)}│${valueStr}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
lines.push("")
|
|
98
|
+
lines.push("─".repeat(60))
|
|
99
|
+
lines.push("")
|
|
100
|
+
|
|
101
|
+
lines.push("Summary:")
|
|
102
|
+
|
|
103
|
+
if (breakdown.prunedTokens > 0) {
|
|
104
|
+
const withoutPruning = breakdown.total + breakdown.prunedTokens
|
|
105
|
+
const pruned = []
|
|
106
|
+
if (breakdown.prunedToolCount > 0) pruned.push(`${breakdown.prunedToolCount} tools`)
|
|
107
|
+
if (breakdown.prunedMessageCount > 0)
|
|
108
|
+
pruned.push(`${breakdown.prunedMessageCount} messages`)
|
|
109
|
+
lines.push(
|
|
110
|
+
` Pruned: ${pruned.join(", ")} (~${formatTokenCount(breakdown.prunedTokens)})`,
|
|
111
|
+
)
|
|
112
|
+
lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`)
|
|
113
|
+
lines.push(` Without DCP: ~${formatTokenCount(withoutPruning)}`)
|
|
114
|
+
} else {
|
|
115
|
+
lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
lines.push("")
|
|
119
|
+
|
|
120
|
+
return lines.join("\n")
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function handleContextCommand(ctx: ContextCommandContext): Promise<void> {
|
|
124
|
+
const { client, state, logger, sessionId, messages } = ctx
|
|
125
|
+
|
|
126
|
+
const { breakdown } = analyzeTokens(state, messages)
|
|
127
|
+
|
|
128
|
+
const message = formatContextMessage(breakdown)
|
|
129
|
+
|
|
130
|
+
const params = getCurrentParams(state, messages, logger)
|
|
131
|
+
await sendIgnoredMessage(client, sessionId, message, params, logger)
|
|
132
|
+
}
|