@tarquinen/opencode-dcp 3.2.5-beta0 → 3.2.7-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.
Files changed (74) hide show
  1. package/dist/lib/token-utils.js +2 -2
  2. package/dist/lib/token-utils.js.map +1 -1
  3. package/index.ts +141 -0
  4. package/lib/analysis/tokens.ts +225 -0
  5. package/lib/auth.ts +37 -0
  6. package/lib/commands/compression-targets.ts +137 -0
  7. package/lib/commands/context.ts +132 -0
  8. package/lib/commands/decompress.ts +275 -0
  9. package/lib/commands/help.ts +76 -0
  10. package/lib/commands/index.ts +11 -0
  11. package/lib/commands/manual.ts +125 -0
  12. package/lib/commands/recompress.ts +224 -0
  13. package/lib/commands/stats.ts +148 -0
  14. package/lib/commands/sweep.ts +268 -0
  15. package/lib/compress/index.ts +3 -0
  16. package/lib/compress/message-utils.ts +250 -0
  17. package/lib/compress/message.ts +137 -0
  18. package/lib/compress/pipeline.ts +106 -0
  19. package/lib/compress/protected-content.ts +154 -0
  20. package/lib/compress/range-utils.ts +308 -0
  21. package/lib/compress/range.ts +180 -0
  22. package/lib/compress/search.ts +267 -0
  23. package/lib/compress/state.ts +268 -0
  24. package/lib/compress/timing.ts +77 -0
  25. package/lib/compress/types.ts +108 -0
  26. package/lib/compress-permission.ts +25 -0
  27. package/lib/config.ts +1071 -0
  28. package/lib/hooks.ts +378 -0
  29. package/lib/host-permissions.ts +101 -0
  30. package/lib/logger.ts +235 -0
  31. package/lib/message-ids.ts +172 -0
  32. package/lib/messages/index.ts +8 -0
  33. package/lib/messages/inject/inject.ts +215 -0
  34. package/lib/messages/inject/subagent-results.ts +82 -0
  35. package/lib/messages/inject/utils.ts +374 -0
  36. package/lib/messages/priority.ts +102 -0
  37. package/lib/messages/prune.ts +238 -0
  38. package/lib/messages/query.ts +56 -0
  39. package/lib/messages/reasoning-strip.ts +40 -0
  40. package/lib/messages/sync.ts +124 -0
  41. package/lib/messages/utils.ts +187 -0
  42. package/lib/prompts/compress-message.ts +42 -0
  43. package/lib/prompts/compress-range.ts +60 -0
  44. package/lib/prompts/context-limit-nudge.ts +18 -0
  45. package/lib/prompts/extensions/nudge.ts +43 -0
  46. package/lib/prompts/extensions/system.ts +32 -0
  47. package/lib/prompts/extensions/tool.ts +35 -0
  48. package/lib/prompts/index.ts +29 -0
  49. package/lib/prompts/iteration-nudge.ts +6 -0
  50. package/lib/prompts/store.ts +467 -0
  51. package/lib/prompts/system.ts +33 -0
  52. package/lib/prompts/turn-nudge.ts +10 -0
  53. package/lib/protected-patterns.ts +128 -0
  54. package/lib/state/index.ts +4 -0
  55. package/lib/state/persistence.ts +256 -0
  56. package/lib/state/state.ts +190 -0
  57. package/lib/state/tool-cache.ts +98 -0
  58. package/lib/state/types.ts +112 -0
  59. package/lib/state/utils.ts +334 -0
  60. package/lib/strategies/deduplication.ts +127 -0
  61. package/lib/strategies/index.ts +2 -0
  62. package/lib/strategies/purge-errors.ts +88 -0
  63. package/lib/subagents/subagent-results.ts +74 -0
  64. package/lib/token-utils.ts +162 -0
  65. package/lib/ui/notification.ts +346 -0
  66. package/lib/ui/utils.ts +287 -0
  67. package/package.json +12 -3
  68. package/tui/data/context.ts +177 -0
  69. package/tui/index.tsx +34 -0
  70. package/tui/routes/summary.tsx +175 -0
  71. package/tui/shared/names.ts +9 -0
  72. package/tui/shared/theme.ts +58 -0
  73. package/tui/shared/types.ts +38 -0
  74. package/tui/slots/sidebar-content.tsx +502 -0
@@ -0,0 +1,162 @@
1
+ import { SessionState, WithParts } from "./state"
2
+ import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
3
+ import { Logger } from "./logger"
4
+ import * as anthropicTokenizer from "@anthropic-ai/tokenizer"
5
+ import { getLastUserMessage } from "./messages/query"
6
+
7
+ export function getCurrentTokenUsage(state: SessionState, messages: WithParts[]): number {
8
+ for (let i = messages.length - 1; i >= 0; i--) {
9
+ const msg = messages[i]
10
+ if (msg.info.role !== "assistant") {
11
+ continue
12
+ }
13
+
14
+ const assistantInfo = msg.info as AssistantMessage
15
+ if ((assistantInfo.tokens?.output || 0) <= 0) {
16
+ continue
17
+ }
18
+
19
+ if (
20
+ state.lastCompaction > 0 &&
21
+ (msg.info.time.created < state.lastCompaction ||
22
+ (msg.info.summary === true && msg.info.time.created === state.lastCompaction))
23
+ ) {
24
+ return 0
25
+ }
26
+
27
+ const input = assistantInfo.tokens?.input || 0
28
+ const output = assistantInfo.tokens?.output || 0
29
+ const reasoning = assistantInfo.tokens?.reasoning || 0
30
+ const cacheRead = assistantInfo.tokens?.cache?.read || 0
31
+ const cacheWrite = assistantInfo.tokens?.cache?.write || 0
32
+ return input + output + reasoning + cacheRead + cacheWrite
33
+ }
34
+
35
+ return 0
36
+ }
37
+
38
+ export function getCurrentParams(
39
+ state: SessionState,
40
+ messages: WithParts[],
41
+ logger: Logger,
42
+ ): {
43
+ providerId: string | undefined
44
+ modelId: string | undefined
45
+ agent: string | undefined
46
+ variant: string | undefined
47
+ } {
48
+ const userMsg = getLastUserMessage(messages)
49
+ if (!userMsg) {
50
+ logger.debug("No user message found when determining current params")
51
+ return {
52
+ providerId: undefined,
53
+ modelId: undefined,
54
+ agent: undefined,
55
+ variant: state.variant,
56
+ }
57
+ }
58
+ const userInfo = userMsg.info as UserMessage
59
+ const agent: string = userInfo.agent
60
+ const providerId: string | undefined = userInfo.model.providerID
61
+ const modelId: string | undefined = userInfo.model.modelID
62
+ const variant: string | undefined = state.variant ?? userInfo.variant
63
+
64
+ return { providerId, modelId, agent, variant }
65
+ }
66
+
67
+ export function countTokens(text: string): number {
68
+ if (!text) return 0
69
+ try {
70
+ return anthropicTokenizer.countTokens(text)
71
+ } catch {
72
+ return Math.round(text.length / 4)
73
+ }
74
+ }
75
+
76
+ export function estimateTokensBatch(texts: string[]): number {
77
+ if (texts.length === 0) return 0
78
+ return countTokens(texts.join(" "))
79
+ }
80
+
81
+ export const COMPACTED_TOOL_OUTPUT_PLACEHOLDER = "[Old tool result content cleared]"
82
+
83
+ function stringifyToolContent(value: unknown): string {
84
+ return typeof value === "string" ? value : JSON.stringify(value)
85
+ }
86
+
87
+ export function extractCompletedToolOutput(part: any): string | undefined {
88
+ if (
89
+ part?.type !== "tool" ||
90
+ part.state?.status !== "completed" ||
91
+ part.state?.output === undefined
92
+ ) {
93
+ return undefined
94
+ }
95
+
96
+ if (part.state?.time?.compacted) {
97
+ return COMPACTED_TOOL_OUTPUT_PLACEHOLDER
98
+ }
99
+
100
+ return stringifyToolContent(part.state.output)
101
+ }
102
+
103
+ export function extractToolContent(part: any): string[] {
104
+ const contents: string[] = []
105
+
106
+ if (part?.type !== "tool") {
107
+ return contents
108
+ }
109
+
110
+ if (part.state?.input !== undefined) {
111
+ contents.push(stringifyToolContent(part.state.input))
112
+ }
113
+
114
+ const completedOutput = extractCompletedToolOutput(part)
115
+ if (completedOutput !== undefined) {
116
+ contents.push(completedOutput)
117
+ } else if (part.state?.status === "error" && part.state?.error) {
118
+ contents.push(stringifyToolContent(part.state.error))
119
+ }
120
+
121
+ return contents
122
+ }
123
+
124
+ export function countToolTokens(part: any): number {
125
+ const contents = extractToolContent(part)
126
+ return estimateTokensBatch(contents)
127
+ }
128
+
129
+ export function getTotalToolTokens(state: SessionState, toolIds: string[]): number {
130
+ let total = 0
131
+ for (const id of toolIds) {
132
+ const entry = state.toolParameters.get(id)
133
+ total += entry?.tokenCount ?? 0
134
+ }
135
+ return total
136
+ }
137
+
138
+ export function countMessageTextTokens(msg: WithParts): number {
139
+ const texts: string[] = []
140
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
141
+ for (const part of parts) {
142
+ if (part.type === "text") {
143
+ texts.push(part.text)
144
+ }
145
+ }
146
+ if (texts.length === 0) return 0
147
+ return estimateTokensBatch(texts)
148
+ }
149
+
150
+ export function countAllMessageTokens(msg: WithParts): number {
151
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
152
+ const texts: string[] = []
153
+ for (const part of parts) {
154
+ if (part.type === "text") {
155
+ texts.push(part.text)
156
+ } else {
157
+ texts.push(...extractToolContent(part))
158
+ }
159
+ }
160
+ if (texts.length === 0) return 0
161
+ return estimateTokensBatch(texts)
162
+ }
@@ -0,0 +1,346 @@
1
+ import type { Logger } from "../logger"
2
+ import type { SessionState } from "../state"
3
+ import {
4
+ formatPrunedItemsList,
5
+ formatProgressBar,
6
+ formatStatsHeader,
7
+ formatTokenCount,
8
+ } from "./utils"
9
+ import { ToolParameterEntry } from "../state"
10
+ import { PluginConfig } from "../config"
11
+ import { getActiveSummaryTokenUsage } from "../state/utils"
12
+
13
+ export type PruneReason = "completion" | "noise" | "extraction"
14
+ export const PRUNE_REASON_LABELS: Record<PruneReason, string> = {
15
+ completion: "Task Complete",
16
+ noise: "Noise Removal",
17
+ extraction: "Extraction",
18
+ }
19
+
20
+ interface CompressionNotificationEntry {
21
+ blockId: number
22
+ runId: number
23
+ summary: string
24
+ summaryTokens: number
25
+ }
26
+
27
+ function buildMinimalMessage(state: SessionState, reason: PruneReason | undefined): string {
28
+ const reasonSuffix = reason ? ` — ${PRUNE_REASON_LABELS[reason]}` : ""
29
+ return (
30
+ formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) +
31
+ reasonSuffix
32
+ )
33
+ }
34
+
35
+ function buildDetailedMessage(
36
+ state: SessionState,
37
+ reason: PruneReason | undefined,
38
+ pruneToolIds: string[],
39
+ toolMetadata: Map<string, ToolParameterEntry>,
40
+ workingDirectory: string,
41
+ ): string {
42
+ let message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter)
43
+
44
+ if (pruneToolIds.length > 0) {
45
+ const pruneTokenCounterStr = `~${formatTokenCount(state.stats.pruneTokenCounter)}`
46
+ const reasonLabel = reason ? ` — ${PRUNE_REASON_LABELS[reason]}` : ""
47
+ message += `\n\n▣ Pruning (${pruneTokenCounterStr})${reasonLabel}`
48
+
49
+ const itemLines = formatPrunedItemsList(pruneToolIds, toolMetadata, workingDirectory)
50
+ message += "\n" + itemLines.join("\n")
51
+ }
52
+
53
+ return message.trim()
54
+ }
55
+
56
+ const TOAST_BODY_MAX_LINES = 12
57
+ const TOAST_SUMMARY_MAX_CHARS = 600
58
+
59
+ function truncateToastBody(body: string, maxLines: number = TOAST_BODY_MAX_LINES): string {
60
+ const lines = body.split("\n")
61
+ if (lines.length <= maxLines) {
62
+ return body
63
+ }
64
+ const kept = lines.slice(0, maxLines - 1)
65
+ const remaining = lines.length - maxLines + 1
66
+ return kept.join("\n") + `\n... and ${remaining} more`
67
+ }
68
+
69
+ function truncateToastSummary(summary: string, maxChars: number = TOAST_SUMMARY_MAX_CHARS): string {
70
+ if (summary.length <= maxChars) {
71
+ return summary
72
+ }
73
+ return summary.slice(0, maxChars - 3) + "..."
74
+ }
75
+
76
+ function truncateExtractedSection(
77
+ message: string,
78
+ maxChars: number = TOAST_SUMMARY_MAX_CHARS,
79
+ ): string {
80
+ const marker = "\n\n▣ Extracted"
81
+ const index = message.indexOf(marker)
82
+ if (index === -1) {
83
+ return message
84
+ }
85
+ const extracted = message.slice(index)
86
+ if (extracted.length <= maxChars) {
87
+ return message
88
+ }
89
+ return message.slice(0, index) + truncateToastSummary(extracted, maxChars)
90
+ }
91
+
92
+ export async function sendUnifiedNotification(
93
+ client: any,
94
+ logger: Logger,
95
+ config: PluginConfig,
96
+ state: SessionState,
97
+ sessionId: string,
98
+ pruneToolIds: string[],
99
+ toolMetadata: Map<string, ToolParameterEntry>,
100
+ reason: PruneReason | undefined,
101
+ params: any,
102
+ workingDirectory: string,
103
+ ): Promise<boolean> {
104
+ const hasPruned = pruneToolIds.length > 0
105
+ if (!hasPruned) {
106
+ return false
107
+ }
108
+
109
+ if (config.pruneNotification === "off") {
110
+ return false
111
+ }
112
+
113
+ const message =
114
+ config.pruneNotification === "minimal"
115
+ ? buildMinimalMessage(state, reason)
116
+ : buildDetailedMessage(state, reason, pruneToolIds, toolMetadata, workingDirectory)
117
+
118
+ if (config.pruneNotificationType === "toast") {
119
+ let toastMessage = truncateExtractedSection(message)
120
+ toastMessage =
121
+ config.pruneNotification === "minimal" ? toastMessage : truncateToastBody(toastMessage)
122
+
123
+ await client.tui.showToast({
124
+ body: {
125
+ title: "DCP: Compress Notification",
126
+ message: toastMessage,
127
+ variant: "info",
128
+ duration: 5000,
129
+ },
130
+ })
131
+ return true
132
+ }
133
+
134
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
135
+ return true
136
+ }
137
+
138
+ function buildCompressionSummary(
139
+ entries: CompressionNotificationEntry[],
140
+ state: SessionState,
141
+ ): string {
142
+ if (entries.length === 1) {
143
+ return entries[0]?.summary ?? ""
144
+ }
145
+
146
+ return entries
147
+ .map((entry) => {
148
+ const topic =
149
+ state.prune.messages.blocksById.get(entry.blockId)?.topic ?? "(unknown topic)"
150
+ return `### ${topic}\n${entry.summary}`
151
+ })
152
+ .join("\n\n")
153
+ }
154
+
155
+ function getCompressionLabel(entries: CompressionNotificationEntry[]): string {
156
+ const runId = entries[0]?.runId
157
+ if (runId === undefined) {
158
+ return "Compression"
159
+ }
160
+
161
+ return `Compression #${runId}`
162
+ }
163
+
164
+ function formatCompressionMetrics(removedTokens: number, summaryTokens: number): string {
165
+ const metrics = [`-${formatTokenCount(removedTokens, true)} removed`]
166
+ if (summaryTokens > 0) {
167
+ metrics.push(`+${formatTokenCount(summaryTokens, true)} summary`)
168
+ }
169
+ return metrics.join(", ")
170
+ }
171
+
172
+ export async function sendCompressNotification(
173
+ client: any,
174
+ logger: Logger,
175
+ config: PluginConfig,
176
+ state: SessionState,
177
+ sessionId: string,
178
+ entries: CompressionNotificationEntry[],
179
+ batchTopic: string | undefined,
180
+ sessionMessageIds: string[],
181
+ params: any,
182
+ ): Promise<boolean> {
183
+ if (config.pruneNotification === "off") {
184
+ return false
185
+ }
186
+
187
+ if (entries.length === 0) {
188
+ return false
189
+ }
190
+
191
+ let message: string
192
+ const compressionLabel = getCompressionLabel(entries)
193
+ const summary = buildCompressionSummary(entries, state)
194
+ const summaryTokens = entries.reduce((total, entry) => total + entry.summaryTokens, 0)
195
+ const summaryTokensStr = formatTokenCount(summaryTokens)
196
+ const compressedTokens = entries.reduce((total, entry) => {
197
+ const compressionBlock = state.prune.messages.blocksById.get(entry.blockId)
198
+ if (!compressionBlock) {
199
+ logger.error("Compression block missing for notification", {
200
+ compressionId: entry.blockId,
201
+ sessionId,
202
+ })
203
+ return total
204
+ }
205
+
206
+ return total + compressionBlock.compressedTokens
207
+ }, 0)
208
+
209
+ const newlyCompressedMessageIds: string[] = []
210
+ const newlyCompressedToolIds: string[] = []
211
+ const seenMessageIds = new Set<string>()
212
+ const seenToolIds = new Set<string>()
213
+
214
+ for (const entry of entries) {
215
+ const compressionBlock = state.prune.messages.blocksById.get(entry.blockId)
216
+ if (!compressionBlock) {
217
+ continue
218
+ }
219
+
220
+ for (const messageId of compressionBlock.directMessageIds) {
221
+ if (seenMessageIds.has(messageId)) {
222
+ continue
223
+ }
224
+ seenMessageIds.add(messageId)
225
+ newlyCompressedMessageIds.push(messageId)
226
+ }
227
+
228
+ for (const toolId of compressionBlock.directToolIds) {
229
+ if (seenToolIds.has(toolId)) {
230
+ continue
231
+ }
232
+ seenToolIds.add(toolId)
233
+ newlyCompressedToolIds.push(toolId)
234
+ }
235
+ }
236
+
237
+ const topic =
238
+ batchTopic ??
239
+ (entries.length === 1
240
+ ? (state.prune.messages.blocksById.get(entries[0]?.blockId ?? -1)?.topic ??
241
+ "(unknown topic)")
242
+ : "(unknown topic)")
243
+
244
+ const totalActiveSummaryTkns = getActiveSummaryTokenUsage(state)
245
+ const totalGross = state.stats.totalPruneTokens + state.stats.pruneTokenCounter
246
+ const notificationHeader = `▣ DCP | ${formatCompressionMetrics(totalGross, totalActiveSummaryTkns)}`
247
+
248
+ if (config.pruneNotification === "minimal") {
249
+ message = `${notificationHeader} — ${compressionLabel}`
250
+ } else {
251
+ message = notificationHeader
252
+ const activePrunedMessages = new Map<string, number>()
253
+ for (const [messageId, entry] of state.prune.messages.byMessageId) {
254
+ if (entry.activeBlockIds.length > 0) {
255
+ activePrunedMessages.set(messageId, entry.tokenCount)
256
+ }
257
+ }
258
+ const progressBar = formatProgressBar(
259
+ sessionMessageIds,
260
+ activePrunedMessages,
261
+ newlyCompressedMessageIds,
262
+ 50,
263
+ )
264
+ message += `\n\n${progressBar}`
265
+ message += `\n▣ ${compressionLabel} ${formatCompressionMetrics(compressedTokens, summaryTokens)}`
266
+ message += `\n→ Topic: ${topic}`
267
+ message += `\n→ Items: ${newlyCompressedMessageIds.length} messages`
268
+ if (newlyCompressedToolIds.length > 0) {
269
+ message += ` and ${newlyCompressedToolIds.length} tools compressed`
270
+ } else {
271
+ message += ` compressed`
272
+ }
273
+ if (config.compress.showCompression) {
274
+ message += `\n→ Compression (~${summaryTokensStr}): ${summary}`
275
+ }
276
+ }
277
+
278
+ if (config.pruneNotificationType === "toast") {
279
+ let toastMessage = message
280
+ if (config.compress.showCompression) {
281
+ const truncatedSummary = truncateToastSummary(summary)
282
+ if (truncatedSummary !== summary) {
283
+ toastMessage = toastMessage.replace(
284
+ `\n→ Compression (~${summaryTokensStr}): ${summary}`,
285
+ `\n→ Compression (~${summaryTokensStr}): ${truncatedSummary}`,
286
+ )
287
+ }
288
+ }
289
+ toastMessage =
290
+ config.pruneNotification === "minimal" ? toastMessage : truncateToastBody(toastMessage)
291
+
292
+ await client.tui.showToast({
293
+ body: {
294
+ title: "DCP: Compress Notification",
295
+ message: toastMessage,
296
+ variant: "info",
297
+ duration: 5000,
298
+ },
299
+ })
300
+ return true
301
+ }
302
+
303
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
304
+ return true
305
+ }
306
+
307
+ export async function sendIgnoredMessage(
308
+ client: any,
309
+ sessionID: string,
310
+ text: string,
311
+ params: any,
312
+ logger: Logger,
313
+ ): Promise<void> {
314
+ const agent = params.agent || undefined
315
+ const variant = params.variant || undefined
316
+ const model =
317
+ params.providerId && params.modelId
318
+ ? {
319
+ providerID: params.providerId,
320
+ modelID: params.modelId,
321
+ }
322
+ : undefined
323
+
324
+ try {
325
+ await client.session.prompt({
326
+ path: {
327
+ id: sessionID,
328
+ },
329
+ body: {
330
+ noReply: true,
331
+ agent: agent,
332
+ model: model,
333
+ variant: variant,
334
+ parts: [
335
+ {
336
+ type: "text",
337
+ text: text,
338
+ ignored: true,
339
+ },
340
+ ],
341
+ },
342
+ })
343
+ } catch (error: any) {
344
+ logger.error("Failed to send notification", { error: error.message })
345
+ }
346
+ }