@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.
Files changed (106) hide show
  1. package/README.md +14 -9
  2. package/dist/index.js +105 -39
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/commands/context.d.ts +15 -0
  5. package/dist/lib/commands/context.d.ts.map +1 -1
  6. package/dist/lib/commands/help.d.ts +1 -0
  7. package/dist/lib/commands/help.d.ts.map +1 -1
  8. package/dist/lib/commands/manual.d.ts +1 -1
  9. package/dist/lib/commands/manual.d.ts.map +1 -1
  10. package/dist/lib/commands/stats.d.ts +10 -0
  11. package/dist/lib/commands/stats.d.ts.map +1 -1
  12. package/dist/lib/compress/pipeline.d.ts.map +1 -1
  13. package/dist/lib/hooks.d.ts.map +1 -1
  14. package/dist/lib/state/persistence.d.ts +3 -0
  15. package/dist/lib/state/persistence.d.ts.map +1 -1
  16. package/dist/lib/state/state.d.ts +1 -0
  17. package/dist/lib/state/state.d.ts.map +1 -1
  18. package/dist/lib/tui/commands.d.ts +3 -0
  19. package/dist/lib/tui/commands.d.ts.map +1 -0
  20. package/dist/lib/tui/data.d.ts +14 -0
  21. package/dist/lib/tui/data.d.ts.map +1 -0
  22. package/dist/lib/tui/dialogs.d.ts +30 -0
  23. package/dist/lib/tui/dialogs.d.ts.map +1 -0
  24. package/dist/lib/tui/format.d.ts +4 -0
  25. package/dist/lib/tui/format.d.ts.map +1 -0
  26. package/dist/lib/tui/modals.d.ts +10 -0
  27. package/dist/lib/tui/modals.d.ts.map +1 -0
  28. package/dist/lib/tui/types.d.ts +15 -0
  29. package/dist/lib/tui/types.d.ts.map +1 -0
  30. package/dist/lib/tui/ui.d.ts +48 -0
  31. package/dist/lib/tui/ui.d.ts.map +1 -0
  32. package/dist/tui.d.ts +7 -0
  33. package/dist/tui.d.ts.map +1 -0
  34. package/lib/auth.ts +37 -0
  35. package/lib/commands/compression-targets.ts +137 -0
  36. package/lib/commands/context.ts +305 -0
  37. package/lib/commands/decompress.ts +275 -0
  38. package/lib/commands/help.ts +76 -0
  39. package/lib/commands/index.ts +11 -0
  40. package/lib/commands/manual.ts +127 -0
  41. package/lib/commands/recompress.ts +224 -0
  42. package/lib/commands/stats.ts +159 -0
  43. package/lib/commands/sweep.ts +268 -0
  44. package/lib/compress/index.ts +3 -0
  45. package/lib/compress/message-utils.ts +250 -0
  46. package/lib/compress/message.ts +145 -0
  47. package/lib/compress/pipeline.ts +108 -0
  48. package/lib/compress/protected-content.ts +208 -0
  49. package/lib/compress/range-utils.ts +308 -0
  50. package/lib/compress/range.ts +192 -0
  51. package/lib/compress/search.ts +267 -0
  52. package/lib/compress/state.ts +268 -0
  53. package/lib/compress/timing.ts +77 -0
  54. package/lib/compress/types.ts +108 -0
  55. package/lib/compress-permission.ts +25 -0
  56. package/lib/config.ts +1007 -0
  57. package/lib/hooks.ts +375 -0
  58. package/lib/host-permissions.ts +101 -0
  59. package/lib/logger.ts +226 -0
  60. package/lib/message-ids.ts +172 -0
  61. package/lib/messages/index.ts +8 -0
  62. package/lib/messages/inject/inject.ts +215 -0
  63. package/lib/messages/inject/subagent-results.ts +82 -0
  64. package/lib/messages/inject/utils.ts +374 -0
  65. package/lib/messages/priority.ts +102 -0
  66. package/lib/messages/prune.ts +233 -0
  67. package/lib/messages/query.ts +72 -0
  68. package/lib/messages/reasoning-strip.ts +40 -0
  69. package/lib/messages/shape.ts +50 -0
  70. package/lib/messages/sync.ts +124 -0
  71. package/lib/messages/utils.ts +185 -0
  72. package/lib/prompts/compress-message.ts +43 -0
  73. package/lib/prompts/compress-range.ts +60 -0
  74. package/lib/prompts/context-limit-nudge.ts +18 -0
  75. package/lib/prompts/extensions/nudge.ts +43 -0
  76. package/lib/prompts/extensions/system.ts +32 -0
  77. package/lib/prompts/extensions/tool.ts +35 -0
  78. package/lib/prompts/index.ts +29 -0
  79. package/lib/prompts/iteration-nudge.ts +6 -0
  80. package/lib/prompts/store.ts +467 -0
  81. package/lib/prompts/system.ts +33 -0
  82. package/lib/prompts/turn-nudge.ts +10 -0
  83. package/lib/protected-patterns.ts +128 -0
  84. package/lib/state/index.ts +4 -0
  85. package/lib/state/persistence.ts +305 -0
  86. package/lib/state/state.ts +208 -0
  87. package/lib/state/tool-cache.ts +98 -0
  88. package/lib/state/types.ts +111 -0
  89. package/lib/state/utils.ts +345 -0
  90. package/lib/strategies/deduplication.ts +127 -0
  91. package/lib/strategies/index.ts +2 -0
  92. package/lib/strategies/purge-errors.ts +88 -0
  93. package/lib/subagents/subagent-results.ts +74 -0
  94. package/lib/token-utils.ts +164 -0
  95. package/lib/tui/commands.ts +31 -0
  96. package/lib/tui/data.ts +73 -0
  97. package/lib/tui/dialogs.tsx +235 -0
  98. package/lib/tui/format.ts +25 -0
  99. package/lib/tui/modals.tsx +92 -0
  100. package/lib/tui/types.ts +16 -0
  101. package/lib/tui/ui.tsx +219 -0
  102. package/lib/ui/notification.ts +347 -0
  103. package/lib/ui/utils.ts +304 -0
  104. package/lib/update.ts +185 -0
  105. package/package.json +21 -5
  106. 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"