@tarquinen/opencode-dcp 3.1.12 → 3.1.14
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/README.md +14 -9
- package/dist/index.js +105 -39
- package/dist/index.js.map +1 -1
- package/dist/lib/commands/context.d.ts +15 -0
- package/dist/lib/commands/context.d.ts.map +1 -1
- package/dist/lib/commands/help.d.ts +1 -0
- package/dist/lib/commands/help.d.ts.map +1 -1
- package/dist/lib/commands/manual.d.ts +1 -1
- package/dist/lib/commands/manual.d.ts.map +1 -1
- package/dist/lib/commands/stats.d.ts +10 -0
- package/dist/lib/commands/stats.d.ts.map +1 -1
- package/dist/lib/compress/pipeline.d.ts.map +1 -1
- package/dist/lib/hooks.d.ts.map +1 -1
- package/dist/lib/state/persistence.d.ts +3 -0
- package/dist/lib/state/persistence.d.ts.map +1 -1
- package/dist/lib/state/state.d.ts +1 -0
- package/dist/lib/state/state.d.ts.map +1 -1
- package/dist/lib/tui/commands.d.ts +3 -0
- package/dist/lib/tui/commands.d.ts.map +1 -0
- package/dist/lib/tui/data.d.ts +14 -0
- package/dist/lib/tui/data.d.ts.map +1 -0
- package/dist/lib/tui/dialogs.d.ts +30 -0
- package/dist/lib/tui/dialogs.d.ts.map +1 -0
- package/dist/lib/tui/format.d.ts +4 -0
- package/dist/lib/tui/format.d.ts.map +1 -0
- package/dist/lib/tui/modals.d.ts +10 -0
- package/dist/lib/tui/modals.d.ts.map +1 -0
- package/dist/lib/tui/types.d.ts +15 -0
- package/dist/lib/tui/types.d.ts.map +1 -0
- package/dist/lib/tui/ui.d.ts +48 -0
- package/dist/lib/tui/ui.d.ts.map +1 -0
- package/dist/tui.d.ts +7 -0
- package/dist/tui.d.ts.map +1 -0
- package/lib/auth.ts +37 -0
- package/lib/commands/compression-targets.ts +137 -0
- package/lib/commands/context.ts +305 -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 +127 -0
- package/lib/commands/recompress.ts +224 -0
- package/lib/commands/stats.ts +159 -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 +145 -0
- package/lib/compress/pipeline.ts +108 -0
- package/lib/compress/protected-content.ts +208 -0
- package/lib/compress/range-utils.ts +308 -0
- package/lib/compress/range.ts +192 -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 +1007 -0
- package/lib/hooks.ts +375 -0
- package/lib/host-permissions.ts +101 -0
- package/lib/logger.ts +226 -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 +233 -0
- package/lib/messages/query.ts +72 -0
- package/lib/messages/reasoning-strip.ts +40 -0
- package/lib/messages/shape.ts +50 -0
- package/lib/messages/sync.ts +124 -0
- package/lib/messages/utils.ts +185 -0
- package/lib/prompts/compress-message.ts +43 -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 +305 -0
- package/lib/state/state.ts +208 -0
- package/lib/state/tool-cache.ts +98 -0
- package/lib/state/types.ts +111 -0
- package/lib/state/utils.ts +345 -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 +164 -0
- package/lib/tui/commands.ts +31 -0
- package/lib/tui/data.ts +73 -0
- package/lib/tui/dialogs.tsx +235 -0
- package/lib/tui/format.ts +25 -0
- package/lib/tui/modals.tsx +92 -0
- package/lib/tui/types.ts +16 -0
- package/lib/tui/ui.tsx +219 -0
- package/lib/ui/notification.ts +347 -0
- package/lib/ui/utils.ts +304 -0
- package/lib/update.ts +185 -0
- package/package.json +21 -5
- package/tui.tsx +26 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DCP Context Command
|
|
3
|
+
* Shows a visual breakdown of token usage in the current 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 { Logger } from "../logger"
|
|
44
|
+
import type { SessionState, WithParts } from "../state"
|
|
45
|
+
import { sendIgnoredMessage } from "../ui/notification"
|
|
46
|
+
import { formatTokenCount } from "../ui/utils"
|
|
47
|
+
import { isIgnoredUserMessage } from "../messages/query"
|
|
48
|
+
import { isMessageCompacted } from "../state/utils"
|
|
49
|
+
import { countTokens, extractCompletedToolOutput, getCurrentParams } from "../token-utils"
|
|
50
|
+
import type { AssistantMessage, TextPart, ToolPart } from "@opencode-ai/sdk/v2"
|
|
51
|
+
|
|
52
|
+
export interface ContextCommandContext {
|
|
53
|
+
client: any
|
|
54
|
+
state: SessionState
|
|
55
|
+
logger: Logger
|
|
56
|
+
sessionId: string
|
|
57
|
+
messages: WithParts[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface TokenBreakdown {
|
|
61
|
+
system: number
|
|
62
|
+
user: number
|
|
63
|
+
assistant: number
|
|
64
|
+
tools: number
|
|
65
|
+
toolCount: number
|
|
66
|
+
toolsInContextCount: number
|
|
67
|
+
prunedTokens: number
|
|
68
|
+
prunedToolCount: number
|
|
69
|
+
prunedMessageCount: number
|
|
70
|
+
total: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function analyzeContextTokens(state: SessionState, messages: WithParts[]): TokenBreakdown {
|
|
74
|
+
const breakdown: TokenBreakdown = {
|
|
75
|
+
system: 0,
|
|
76
|
+
user: 0,
|
|
77
|
+
assistant: 0,
|
|
78
|
+
tools: 0,
|
|
79
|
+
toolCount: 0,
|
|
80
|
+
toolsInContextCount: 0,
|
|
81
|
+
prunedTokens: state.stats.totalPruneTokens,
|
|
82
|
+
prunedToolCount: 0,
|
|
83
|
+
prunedMessageCount: 0,
|
|
84
|
+
total: 0,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let firstAssistant: AssistantMessage | undefined
|
|
88
|
+
for (const msg of messages) {
|
|
89
|
+
if (msg.info.role === "assistant") {
|
|
90
|
+
const assistantInfo = msg.info as AssistantMessage
|
|
91
|
+
if (
|
|
92
|
+
assistantInfo.tokens?.input > 0 ||
|
|
93
|
+
assistantInfo.tokens?.cache?.read > 0 ||
|
|
94
|
+
assistantInfo.tokens?.cache?.write > 0
|
|
95
|
+
) {
|
|
96
|
+
firstAssistant = assistantInfo
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let lastAssistant: AssistantMessage | undefined
|
|
103
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
104
|
+
const msg = messages[i]
|
|
105
|
+
if (msg.info.role === "assistant") {
|
|
106
|
+
const assistantInfo = msg.info as AssistantMessage
|
|
107
|
+
if (assistantInfo.tokens?.output > 0) {
|
|
108
|
+
lastAssistant = assistantInfo
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const apiInput = lastAssistant?.tokens?.input || 0
|
|
115
|
+
const apiOutput = lastAssistant?.tokens?.output || 0
|
|
116
|
+
const apiReasoning = lastAssistant?.tokens?.reasoning || 0
|
|
117
|
+
const apiCacheRead = lastAssistant?.tokens?.cache?.read || 0
|
|
118
|
+
const apiCacheWrite = lastAssistant?.tokens?.cache?.write || 0
|
|
119
|
+
breakdown.total = apiInput + apiOutput + apiReasoning + apiCacheRead + apiCacheWrite
|
|
120
|
+
|
|
121
|
+
const userTextParts: string[] = []
|
|
122
|
+
const toolInputParts: string[] = []
|
|
123
|
+
const toolOutputParts: string[] = []
|
|
124
|
+
let firstUserText = ""
|
|
125
|
+
let foundFirstUser = false
|
|
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
|
+
for (const msg of messages) {
|
|
132
|
+
allMessageIds.add(msg.info.id)
|
|
133
|
+
const parts = Array.isArray(msg.parts) ? msg.parts : []
|
|
134
|
+
const isCompacted = isMessageCompacted(state, msg)
|
|
135
|
+
const pruneEntry = state.prune.messages.byMessageId.get(msg.info.id)
|
|
136
|
+
const isMessagePruned = !!pruneEntry && pruneEntry.activeBlockIds.length > 0
|
|
137
|
+
const isIgnoredUser = isIgnoredUserMessage(msg)
|
|
138
|
+
|
|
139
|
+
for (const part of parts) {
|
|
140
|
+
if (part.type === "tool") {
|
|
141
|
+
const toolPart = part as ToolPart
|
|
142
|
+
if (toolPart.callID) {
|
|
143
|
+
allToolIds.add(toolPart.callID)
|
|
144
|
+
if (!isCompacted) {
|
|
145
|
+
activeToolIds.add(toolPart.callID)
|
|
146
|
+
}
|
|
147
|
+
if (isMessagePruned) {
|
|
148
|
+
prunedByMessageToolIds.add(toolPart.callID)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const isPruned = toolPart.callID && state.prune.tools.has(toolPart.callID)
|
|
153
|
+
if (!isCompacted && !isPruned) {
|
|
154
|
+
if (toolPart.state?.input) {
|
|
155
|
+
const inputStr =
|
|
156
|
+
typeof toolPart.state.input === "string"
|
|
157
|
+
? toolPart.state.input
|
|
158
|
+
: JSON.stringify(toolPart.state.input)
|
|
159
|
+
toolInputParts.push(inputStr)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const outputStr = extractCompletedToolOutput(toolPart)
|
|
163
|
+
if (outputStr !== undefined) {
|
|
164
|
+
toolOutputParts.push(outputStr)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} else if (
|
|
168
|
+
part.type === "text" &&
|
|
169
|
+
msg.info.role === "user" &&
|
|
170
|
+
!isCompacted &&
|
|
171
|
+
!isIgnoredUser
|
|
172
|
+
) {
|
|
173
|
+
const textPart = part as TextPart
|
|
174
|
+
const text = textPart.text || ""
|
|
175
|
+
userTextParts.push(text)
|
|
176
|
+
if (!foundFirstUser) {
|
|
177
|
+
firstUserText += text
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (msg.info.role === "user" && !isIgnoredUser && !foundFirstUser) {
|
|
183
|
+
foundFirstUser = true
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const prunedByToolIds = new Set<string>()
|
|
188
|
+
for (const id of allToolIds) {
|
|
189
|
+
if (state.prune.tools.has(id)) {
|
|
190
|
+
prunedByToolIds.add(id)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const prunedToolIds = new Set<string>([...prunedByToolIds, ...prunedByMessageToolIds])
|
|
195
|
+
const toolsInContextCount = [...activeToolIds].filter((id) => !prunedByToolIds.has(id)).length
|
|
196
|
+
|
|
197
|
+
let prunedMessageCount = 0
|
|
198
|
+
for (const [id, entry] of state.prune.messages.byMessageId) {
|
|
199
|
+
if (allMessageIds.has(id) && entry.activeBlockIds.length > 0) {
|
|
200
|
+
prunedMessageCount++
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
breakdown.toolCount = allToolIds.size
|
|
205
|
+
breakdown.toolsInContextCount = toolsInContextCount
|
|
206
|
+
breakdown.prunedToolCount = prunedToolIds.size
|
|
207
|
+
breakdown.prunedMessageCount = prunedMessageCount
|
|
208
|
+
|
|
209
|
+
const firstUserTokens = countTokens(firstUserText)
|
|
210
|
+
breakdown.user = countTokens(userTextParts.join("\n"))
|
|
211
|
+
const toolInputTokens = countTokens(toolInputParts.join("\n"))
|
|
212
|
+
const toolOutputTokens = countTokens(toolOutputParts.join("\n"))
|
|
213
|
+
|
|
214
|
+
if (firstAssistant) {
|
|
215
|
+
const firstInput =
|
|
216
|
+
(firstAssistant.tokens?.input || 0) +
|
|
217
|
+
(firstAssistant.tokens?.cache?.read || 0) +
|
|
218
|
+
(firstAssistant.tokens?.cache?.write || 0)
|
|
219
|
+
breakdown.system = Math.max(0, firstInput - firstUserTokens)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
breakdown.tools = toolInputTokens + toolOutputTokens
|
|
223
|
+
breakdown.assistant = Math.max(
|
|
224
|
+
0,
|
|
225
|
+
breakdown.total - breakdown.system - breakdown.user - breakdown.tools,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return breakdown
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function createBar(value: number, maxValue: number, width: number, char: string = "█"): string {
|
|
232
|
+
if (maxValue === 0) return ""
|
|
233
|
+
const filled = Math.round((value / maxValue) * width)
|
|
234
|
+
const bar = char.repeat(Math.max(0, filled))
|
|
235
|
+
return bar
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function formatContextMessage(breakdown: TokenBreakdown): string {
|
|
239
|
+
const lines: string[] = []
|
|
240
|
+
const barWidth = 30
|
|
241
|
+
|
|
242
|
+
const toolsLabel = `Tools (${breakdown.toolsInContextCount})`
|
|
243
|
+
|
|
244
|
+
const categories = [
|
|
245
|
+
{ label: "System", value: breakdown.system, char: "█" },
|
|
246
|
+
{ label: "User", value: breakdown.user, char: "▓" },
|
|
247
|
+
{ label: "Assistant", value: breakdown.assistant, char: "▒" },
|
|
248
|
+
{ label: toolsLabel, value: breakdown.tools, char: "░" },
|
|
249
|
+
] as const
|
|
250
|
+
|
|
251
|
+
const maxLabelLen = Math.max(...categories.map((c) => c.label.length))
|
|
252
|
+
|
|
253
|
+
lines.push("╭───────────────────────────────────────────────────────────╮")
|
|
254
|
+
lines.push("│ DCP Context Analysis │")
|
|
255
|
+
lines.push("╰───────────────────────────────────────────────────────────╯")
|
|
256
|
+
lines.push("")
|
|
257
|
+
lines.push("Session Context Breakdown:")
|
|
258
|
+
lines.push("─".repeat(60))
|
|
259
|
+
lines.push("")
|
|
260
|
+
|
|
261
|
+
for (const cat of categories) {
|
|
262
|
+
const bar = createBar(cat.value, breakdown.total, barWidth, cat.char)
|
|
263
|
+
const percentage =
|
|
264
|
+
breakdown.total > 0 ? ((cat.value / breakdown.total) * 100).toFixed(1) : "0.0"
|
|
265
|
+
const labelWithPct = `${cat.label.padEnd(maxLabelLen)} ${percentage.padStart(5)}% `
|
|
266
|
+
const valueStr = formatTokenCount(cat.value).padStart(13)
|
|
267
|
+
lines.push(`${labelWithPct}│${bar.padEnd(barWidth)}│${valueStr}`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
lines.push("")
|
|
271
|
+
lines.push("─".repeat(60))
|
|
272
|
+
lines.push("")
|
|
273
|
+
|
|
274
|
+
lines.push("Summary:")
|
|
275
|
+
|
|
276
|
+
if (breakdown.prunedTokens > 0) {
|
|
277
|
+
const withoutPruning = breakdown.total + breakdown.prunedTokens
|
|
278
|
+
const pruned = []
|
|
279
|
+
if (breakdown.prunedToolCount > 0) pruned.push(`${breakdown.prunedToolCount} tools`)
|
|
280
|
+
if (breakdown.prunedMessageCount > 0)
|
|
281
|
+
pruned.push(`${breakdown.prunedMessageCount} messages`)
|
|
282
|
+
lines.push(
|
|
283
|
+
` Pruned: ${pruned.join(", ")} (~${formatTokenCount(breakdown.prunedTokens)})`,
|
|
284
|
+
)
|
|
285
|
+
lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`)
|
|
286
|
+
lines.push(` Without DCP: ~${formatTokenCount(withoutPruning)}`)
|
|
287
|
+
} else {
|
|
288
|
+
lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
lines.push("")
|
|
292
|
+
|
|
293
|
+
return lines.join("\n")
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function handleContextCommand(ctx: ContextCommandContext): Promise<void> {
|
|
297
|
+
const { client, state, logger, sessionId, messages } = ctx
|
|
298
|
+
|
|
299
|
+
const breakdown = analyzeContextTokens(state, messages)
|
|
300
|
+
|
|
301
|
+
const message = formatContextMessage(breakdown)
|
|
302
|
+
|
|
303
|
+
const params = getCurrentParams(state, messages, logger)
|
|
304
|
+
await sendIgnoredMessage(client, sessionId, message, params, logger)
|
|
305
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type { Logger } from "../logger"
|
|
2
|
+
import type { CompressionBlock, PruneMessagesState, SessionState, WithParts } from "../state"
|
|
3
|
+
import { syncCompressionBlocks } from "../messages"
|
|
4
|
+
import { parseBlockRef } from "../message-ids"
|
|
5
|
+
import { getCurrentParams } from "../token-utils"
|
|
6
|
+
import { saveSessionState } from "../state/persistence"
|
|
7
|
+
import { sendIgnoredMessage } from "../ui/notification"
|
|
8
|
+
import { formatTokenCount } from "../ui/utils"
|
|
9
|
+
import {
|
|
10
|
+
getActiveCompressionTargets,
|
|
11
|
+
resolveCompressionTarget,
|
|
12
|
+
type CompressionTarget,
|
|
13
|
+
} from "./compression-targets"
|
|
14
|
+
|
|
15
|
+
export interface DecompressCommandContext {
|
|
16
|
+
client: any
|
|
17
|
+
state: SessionState
|
|
18
|
+
logger: Logger
|
|
19
|
+
sessionId: string
|
|
20
|
+
messages: WithParts[]
|
|
21
|
+
args: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseBlockIdArg(arg: string): number | null {
|
|
25
|
+
const normalized = arg.trim().toLowerCase()
|
|
26
|
+
const blockRef = parseBlockRef(normalized)
|
|
27
|
+
if (blockRef !== null) {
|
|
28
|
+
return blockRef
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!/^[1-9]\d*$/.test(normalized)) {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const parsed = Number.parseInt(normalized, 10)
|
|
36
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findActiveParentBlockId(
|
|
40
|
+
messagesState: PruneMessagesState,
|
|
41
|
+
block: CompressionBlock,
|
|
42
|
+
): number | null {
|
|
43
|
+
const queue = [...block.parentBlockIds]
|
|
44
|
+
const visited = new Set<number>()
|
|
45
|
+
|
|
46
|
+
while (queue.length > 0) {
|
|
47
|
+
const parentBlockId = queue.shift()
|
|
48
|
+
if (parentBlockId === undefined || visited.has(parentBlockId)) {
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
visited.add(parentBlockId)
|
|
52
|
+
|
|
53
|
+
const parent = messagesState.blocksById.get(parentBlockId)
|
|
54
|
+
if (!parent) {
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (parent.active) {
|
|
59
|
+
return parent.blockId
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const ancestorId of parent.parentBlockIds) {
|
|
63
|
+
if (!visited.has(ancestorId)) {
|
|
64
|
+
queue.push(ancestorId)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function findActiveAncestorBlockId(
|
|
73
|
+
messagesState: PruneMessagesState,
|
|
74
|
+
target: CompressionTarget,
|
|
75
|
+
): number | null {
|
|
76
|
+
for (const block of target.blocks) {
|
|
77
|
+
const activeAncestorBlockId = findActiveParentBlockId(messagesState, block)
|
|
78
|
+
if (activeAncestorBlockId !== null) {
|
|
79
|
+
return activeAncestorBlockId
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function snapshotActiveMessages(messagesState: PruneMessagesState): Map<string, number> {
|
|
87
|
+
const activeMessages = new Map<string, number>()
|
|
88
|
+
for (const [messageId, entry] of messagesState.byMessageId) {
|
|
89
|
+
if (entry.activeBlockIds.length > 0) {
|
|
90
|
+
activeMessages.set(messageId, entry.tokenCount)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return activeMessages
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatDecompressMessage(
|
|
97
|
+
target: CompressionTarget,
|
|
98
|
+
restoredMessageCount: number,
|
|
99
|
+
restoredTokens: number,
|
|
100
|
+
reactivatedBlockIds: number[],
|
|
101
|
+
): string {
|
|
102
|
+
const lines: string[] = []
|
|
103
|
+
|
|
104
|
+
lines.push(`Restored compression ${target.displayId}.`)
|
|
105
|
+
if (target.runId !== target.displayId || target.grouped) {
|
|
106
|
+
lines.push(`Tool call label: Compression #${target.runId}.`)
|
|
107
|
+
}
|
|
108
|
+
if (reactivatedBlockIds.length > 0) {
|
|
109
|
+
const refs = reactivatedBlockIds.map((id) => String(id)).join(", ")
|
|
110
|
+
lines.push(`Also restored nested compression(s): ${refs}.`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (restoredMessageCount > 0) {
|
|
114
|
+
lines.push(
|
|
115
|
+
`Restored ${restoredMessageCount} message(s) (~${formatTokenCount(restoredTokens)}).`,
|
|
116
|
+
)
|
|
117
|
+
} else {
|
|
118
|
+
lines.push("No messages were restored.")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return lines.join("\n")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatAvailableBlocksMessage(availableTargets: CompressionTarget[]): string {
|
|
125
|
+
const lines: string[] = []
|
|
126
|
+
|
|
127
|
+
lines.push("Usage: /dcp decompress <n>")
|
|
128
|
+
lines.push("")
|
|
129
|
+
|
|
130
|
+
if (availableTargets.length === 0) {
|
|
131
|
+
lines.push("No compressions are available to restore.")
|
|
132
|
+
return lines.join("\n")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
lines.push("Available compressions:")
|
|
136
|
+
const entries = availableTargets.map((target) => {
|
|
137
|
+
const topic = target.topic.replace(/\s+/g, " ").trim() || "(no topic)"
|
|
138
|
+
const label = `${target.displayId} (${formatTokenCount(target.compressedTokens)})`
|
|
139
|
+
const details = target.grouped
|
|
140
|
+
? `Compression #${target.runId} - ${target.blocks.length} messages`
|
|
141
|
+
: `Compression #${target.runId}`
|
|
142
|
+
return { label, topic: `${details} - ${topic}` }
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const labelWidth = Math.max(...entries.map((entry) => entry.label.length)) + 4
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
lines.push(` ${entry.label.padEnd(labelWidth)}${entry.topic}`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return lines.join("\n")
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function handleDecompressCommand(ctx: DecompressCommandContext): Promise<void> {
|
|
154
|
+
const { client, state, logger, sessionId, messages, args } = ctx
|
|
155
|
+
|
|
156
|
+
const params = getCurrentParams(state, messages, logger)
|
|
157
|
+
const targetArg = args[0]
|
|
158
|
+
|
|
159
|
+
if (args.length > 1) {
|
|
160
|
+
await sendIgnoredMessage(
|
|
161
|
+
client,
|
|
162
|
+
sessionId,
|
|
163
|
+
"Invalid arguments. Usage: /dcp decompress <n>",
|
|
164
|
+
params,
|
|
165
|
+
logger,
|
|
166
|
+
)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
syncCompressionBlocks(state, logger, messages)
|
|
171
|
+
const messagesState = state.prune.messages
|
|
172
|
+
|
|
173
|
+
if (!targetArg) {
|
|
174
|
+
const availableTargets = getActiveCompressionTargets(messagesState)
|
|
175
|
+
const message = formatAvailableBlocksMessage(availableTargets)
|
|
176
|
+
await sendIgnoredMessage(client, sessionId, message, params, logger)
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const targetBlockId = parseBlockIdArg(targetArg)
|
|
181
|
+
if (targetBlockId === null) {
|
|
182
|
+
await sendIgnoredMessage(
|
|
183
|
+
client,
|
|
184
|
+
sessionId,
|
|
185
|
+
`Please enter a compression number. Example: /dcp decompress 2`,
|
|
186
|
+
params,
|
|
187
|
+
logger,
|
|
188
|
+
)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const target = resolveCompressionTarget(messagesState, targetBlockId)
|
|
193
|
+
if (!target) {
|
|
194
|
+
await sendIgnoredMessage(
|
|
195
|
+
client,
|
|
196
|
+
sessionId,
|
|
197
|
+
`Compression ${targetBlockId} does not exist.`,
|
|
198
|
+
params,
|
|
199
|
+
logger,
|
|
200
|
+
)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const activeBlocks = target.blocks.filter((block) => block.active)
|
|
205
|
+
if (activeBlocks.length === 0) {
|
|
206
|
+
const activeAncestorBlockId = findActiveAncestorBlockId(messagesState, target)
|
|
207
|
+
if (activeAncestorBlockId !== null) {
|
|
208
|
+
await sendIgnoredMessage(
|
|
209
|
+
client,
|
|
210
|
+
sessionId,
|
|
211
|
+
`Compression ${target.displayId} is inside compression ${activeAncestorBlockId}. Restore compression ${activeAncestorBlockId} first.`,
|
|
212
|
+
params,
|
|
213
|
+
logger,
|
|
214
|
+
)
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await sendIgnoredMessage(
|
|
219
|
+
client,
|
|
220
|
+
sessionId,
|
|
221
|
+
`Compression ${target.displayId} is not active.`,
|
|
222
|
+
params,
|
|
223
|
+
logger,
|
|
224
|
+
)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const activeMessagesBefore = snapshotActiveMessages(messagesState)
|
|
229
|
+
const activeBlockIdsBefore = new Set(messagesState.activeBlockIds)
|
|
230
|
+
const deactivatedAt = Date.now()
|
|
231
|
+
|
|
232
|
+
for (const block of target.blocks) {
|
|
233
|
+
block.active = false
|
|
234
|
+
block.deactivatedByUser = true
|
|
235
|
+
block.deactivatedAt = deactivatedAt
|
|
236
|
+
block.deactivatedByBlockId = undefined
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
syncCompressionBlocks(state, logger, messages)
|
|
240
|
+
|
|
241
|
+
let restoredMessageCount = 0
|
|
242
|
+
let restoredTokens = 0
|
|
243
|
+
for (const [messageId, tokenCount] of activeMessagesBefore) {
|
|
244
|
+
const entry = messagesState.byMessageId.get(messageId)
|
|
245
|
+
const isActiveNow = entry ? entry.activeBlockIds.length > 0 : false
|
|
246
|
+
if (!isActiveNow) {
|
|
247
|
+
restoredMessageCount++
|
|
248
|
+
restoredTokens += tokenCount
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
state.stats.totalPruneTokens = Math.max(0, state.stats.totalPruneTokens - restoredTokens)
|
|
253
|
+
|
|
254
|
+
const reactivatedBlockIds = Array.from(messagesState.activeBlockIds)
|
|
255
|
+
.filter((blockId) => !activeBlockIdsBefore.has(blockId))
|
|
256
|
+
.sort((a, b) => a - b)
|
|
257
|
+
|
|
258
|
+
await saveSessionState(state, logger)
|
|
259
|
+
|
|
260
|
+
const message = formatDecompressMessage(
|
|
261
|
+
target,
|
|
262
|
+
restoredMessageCount,
|
|
263
|
+
restoredTokens,
|
|
264
|
+
reactivatedBlockIds,
|
|
265
|
+
)
|
|
266
|
+
await sendIgnoredMessage(client, sessionId, message, params, logger)
|
|
267
|
+
|
|
268
|
+
logger.info("Decompress command completed", {
|
|
269
|
+
targetBlockId: target.displayId,
|
|
270
|
+
targetRunId: target.runId,
|
|
271
|
+
restoredMessageCount,
|
|
272
|
+
restoredTokens,
|
|
273
|
+
reactivatedBlockIds,
|
|
274
|
+
})
|
|
275
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DCP Help command handler.
|
|
3
|
+
* Shows available DCP commands and their descriptions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Logger } from "../logger"
|
|
7
|
+
import type { PluginConfig } from "../config"
|
|
8
|
+
import type { SessionState, WithParts } from "../state"
|
|
9
|
+
import { compressPermission } from "../compress-permission"
|
|
10
|
+
import { sendIgnoredMessage } from "../ui/notification"
|
|
11
|
+
import { getCurrentParams } from "../token-utils"
|
|
12
|
+
|
|
13
|
+
export interface HelpCommandContext {
|
|
14
|
+
client: any
|
|
15
|
+
state: SessionState
|
|
16
|
+
config: PluginConfig
|
|
17
|
+
logger: Logger
|
|
18
|
+
sessionId: string
|
|
19
|
+
messages: WithParts[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TUI_COMMANDS: [string, string][] = [
|
|
23
|
+
["DCP Context", "Show token usage breakdown for current session"],
|
|
24
|
+
["DCP Stats", "Show DCP pruning statistics"],
|
|
25
|
+
["DCP Help", "Show this help in a modal"],
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const TOOL_COMMANDS: Record<string, [string, string]> = {
|
|
29
|
+
compress: ["/dcp-compress [focus]", "Trigger manual compress tool execution"],
|
|
30
|
+
decompress: ["/dcp decompress <n>", "Restore selected compression"],
|
|
31
|
+
recompress: ["/dcp recompress <n>", "Re-apply a user-decompressed compression"],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getVisibleCommands(state: SessionState, config: PluginConfig): [string, string][] {
|
|
35
|
+
const commands = [...TUI_COMMANDS]
|
|
36
|
+
|
|
37
|
+
if (compressPermission(state, config) !== "deny") {
|
|
38
|
+
commands.push(TOOL_COMMANDS.compress)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return commands
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatHelpMessage(state: SessionState, config: PluginConfig): string {
|
|
45
|
+
const commands = getVisibleCommands(state, config)
|
|
46
|
+
const colWidth = Math.max(...commands.map(([cmd]) => cmd.length)) + 4
|
|
47
|
+
const lines: string[] = []
|
|
48
|
+
|
|
49
|
+
lines.push("╭─────────────────────────────────────────────────────────────────────────╮")
|
|
50
|
+
lines.push("│ DCP Commands │")
|
|
51
|
+
lines.push("╰─────────────────────────────────────────────────────────────────────────╯")
|
|
52
|
+
lines.push("")
|
|
53
|
+
lines.push(` ${"Manual mode:".padEnd(colWidth)}${state.manualMode ? "ON" : "OFF"}`)
|
|
54
|
+
lines.push("")
|
|
55
|
+
lines.push(" Open the command palette for DCP modal commands.")
|
|
56
|
+
lines.push(" Use /dcp-compress [focus] when you want DCP to ask the model to run compression.")
|
|
57
|
+
lines.push("")
|
|
58
|
+
for (const [cmd, desc] of commands) {
|
|
59
|
+
lines.push(` ${cmd.padEnd(colWidth)}${desc}`)
|
|
60
|
+
}
|
|
61
|
+
lines.push("")
|
|
62
|
+
|
|
63
|
+
return lines.join("\n")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function handleHelpCommand(ctx: HelpCommandContext): Promise<void> {
|
|
67
|
+
const { client, state, logger, sessionId, messages } = ctx
|
|
68
|
+
|
|
69
|
+
const { config } = ctx
|
|
70
|
+
const message = formatHelpMessage(state, config)
|
|
71
|
+
|
|
72
|
+
const params = getCurrentParams(state, messages, logger)
|
|
73
|
+
await sendIgnoredMessage(client, sessionId, message, params, logger)
|
|
74
|
+
|
|
75
|
+
logger.info("Help command executed")
|
|
76
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { handleContextCommand } from "./context"
|
|
2
|
+
export { handleDecompressCommand } from "./decompress"
|
|
3
|
+
export { handleHelpCommand } from "./help"
|
|
4
|
+
export {
|
|
5
|
+
applyPendingManualTrigger,
|
|
6
|
+
handleManualToggleCommand,
|
|
7
|
+
handleManualTriggerCommand,
|
|
8
|
+
} from "./manual"
|
|
9
|
+
export { handleRecompressCommand } from "./recompress"
|
|
10
|
+
export { handleStatsCommand } from "./stats"
|
|
11
|
+
export { handleSweepCommand } from "./sweep"
|