@royalcat/opencode-dcp-rc 0.0.0-stage → 4.0.0

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 (209) hide show
  1. package/LICENSE +619 -0
  2. package/README.md +348 -2
  3. package/dist/index.d.ts +7 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +7611 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/lib/commands/compression-targets.d.ts +13 -0
  8. package/dist/lib/commands/compression-targets.d.ts.map +1 -0
  9. package/dist/lib/commands/context.d.ts +67 -0
  10. package/dist/lib/commands/context.d.ts.map +1 -0
  11. package/dist/lib/commands/decompress.d.ts +12 -0
  12. package/dist/lib/commands/decompress.d.ts.map +1 -0
  13. package/dist/lib/commands/help.d.ts +18 -0
  14. package/dist/lib/commands/help.d.ts.map +1 -0
  15. package/dist/lib/commands/index.d.ts +8 -0
  16. package/dist/lib/commands/index.d.ts.map +1 -0
  17. package/dist/lib/commands/manual.d.ts +23 -0
  18. package/dist/lib/commands/manual.d.ts.map +1 -0
  19. package/dist/lib/commands/recompress.d.ts +12 -0
  20. package/dist/lib/commands/recompress.d.ts.map +1 -0
  21. package/dist/lib/commands/stats.d.ts +25 -0
  22. package/dist/lib/commands/stats.d.ts.map +1 -0
  23. package/dist/lib/commands/sweep.d.ts +23 -0
  24. package/dist/lib/commands/sweep.d.ts.map +1 -0
  25. package/dist/lib/compress/index.d.ts +3 -0
  26. package/dist/lib/compress/index.d.ts.map +1 -0
  27. package/dist/lib/compress/pipeline.d.ts +29 -0
  28. package/dist/lib/compress/pipeline.d.ts.map +1 -0
  29. package/dist/lib/compress/protected-content.d.ts +6 -0
  30. package/dist/lib/compress/protected-content.d.ts.map +1 -0
  31. package/dist/lib/compress/rc.d.ts +15 -0
  32. package/dist/lib/compress/rc.d.ts.map +1 -0
  33. package/dist/lib/compress/search.d.ts +12 -0
  34. package/dist/lib/compress/search.d.ts.map +1 -0
  35. package/dist/lib/compress/state.d.ts +10 -0
  36. package/dist/lib/compress/state.d.ts.map +1 -0
  37. package/dist/lib/compress/summary.d.ts +58 -0
  38. package/dist/lib/compress/summary.d.ts.map +1 -0
  39. package/dist/lib/compress/timing.d.ts +18 -0
  40. package/dist/lib/compress/timing.d.ts.map +1 -0
  41. package/dist/lib/compress/types.d.ts +55 -0
  42. package/dist/lib/compress/types.d.ts.map +1 -0
  43. package/dist/lib/compress-permission.d.ts +4 -0
  44. package/dist/lib/compress-permission.d.ts.map +1 -0
  45. package/dist/lib/config.d.ts +83 -0
  46. package/dist/lib/config.d.ts.map +1 -0
  47. package/dist/lib/hooks.d.ts +22 -0
  48. package/dist/lib/hooks.d.ts.map +1 -0
  49. package/dist/lib/logger.d.ts +31 -0
  50. package/dist/lib/logger.d.ts.map +1 -0
  51. package/dist/lib/message-ids.d.ts +20 -0
  52. package/dist/lib/message-ids.d.ts.map +1 -0
  53. package/dist/lib/messages/index.d.ts +8 -0
  54. package/dist/lib/messages/index.d.ts.map +1 -0
  55. package/dist/lib/messages/inject/inject.d.ts +7 -0
  56. package/dist/lib/messages/inject/inject.d.ts.map +1 -0
  57. package/dist/lib/messages/inject/subagent-results.d.ts +4 -0
  58. package/dist/lib/messages/inject/subagent-results.d.ts.map +1 -0
  59. package/dist/lib/messages/inject/utils.d.ts +23 -0
  60. package/dist/lib/messages/inject/utils.d.ts.map +1 -0
  61. package/dist/lib/messages/prune.d.ts +4 -0
  62. package/dist/lib/messages/prune.d.ts.map +1 -0
  63. package/dist/lib/messages/query.d.ts +7 -0
  64. package/dist/lib/messages/query.d.ts.map +1 -0
  65. package/dist/lib/messages/reasoning-strip.d.ts +8 -0
  66. package/dist/lib/messages/reasoning-strip.d.ts.map +1 -0
  67. package/dist/lib/messages/shape.d.ts +5 -0
  68. package/dist/lib/messages/shape.d.ts.map +1 -0
  69. package/dist/lib/messages/sync.d.ts +4 -0
  70. package/dist/lib/messages/sync.d.ts.map +1 -0
  71. package/dist/lib/messages/utils.d.ts +27 -0
  72. package/dist/lib/messages/utils.d.ts.map +1 -0
  73. package/dist/lib/prompts/compress-rc.d.ts +4 -0
  74. package/dist/lib/prompts/compress-rc.d.ts.map +1 -0
  75. package/dist/lib/prompts/context-limit-nudge.d.ts +2 -0
  76. package/dist/lib/prompts/context-limit-nudge.d.ts.map +1 -0
  77. package/dist/lib/prompts/extensions/nudge.d.ts +4 -0
  78. package/dist/lib/prompts/extensions/nudge.d.ts.map +1 -0
  79. package/dist/lib/prompts/extensions/system.d.ts +4 -0
  80. package/dist/lib/prompts/extensions/system.d.ts.map +1 -0
  81. package/dist/lib/prompts/extensions/tool.d.ts +3 -0
  82. package/dist/lib/prompts/extensions/tool.d.ts.map +1 -0
  83. package/dist/lib/prompts/index.d.ts +4 -0
  84. package/dist/lib/prompts/index.d.ts.map +1 -0
  85. package/dist/lib/prompts/iteration-nudge.d.ts +2 -0
  86. package/dist/lib/prompts/iteration-nudge.d.ts.map +1 -0
  87. package/dist/lib/prompts/store.d.ts +26 -0
  88. package/dist/lib/prompts/store.d.ts.map +1 -0
  89. package/dist/lib/prompts/system.d.ts +4 -0
  90. package/dist/lib/prompts/system.d.ts.map +1 -0
  91. package/dist/lib/prompts/turn-nudge.d.ts +2 -0
  92. package/dist/lib/prompts/turn-nudge.d.ts.map +1 -0
  93. package/dist/lib/protected-patterns.d.ts +6 -0
  94. package/dist/lib/protected-patterns.d.ts.map +1 -0
  95. package/dist/lib/state/index.d.ts +5 -0
  96. package/dist/lib/state/index.d.ts.map +1 -0
  97. package/dist/lib/state/persistence.d.ts +45 -0
  98. package/dist/lib/state/persistence.d.ts.map +1 -0
  99. package/dist/lib/state/state.d.ts +9 -0
  100. package/dist/lib/state/state.d.ts.map +1 -0
  101. package/dist/lib/state/tool-cache.d.ts +13 -0
  102. package/dist/lib/state/tool-cache.d.ts.map +1 -0
  103. package/dist/lib/state/types.d.ts +102 -0
  104. package/dist/lib/state/types.d.ts.map +1 -0
  105. package/dist/lib/state/utils.d.ts +22 -0
  106. package/dist/lib/state/utils.d.ts.map +1 -0
  107. package/dist/lib/strategies/deduplication.d.ts +10 -0
  108. package/dist/lib/strategies/deduplication.d.ts.map +1 -0
  109. package/dist/lib/strategies/index.d.ts +3 -0
  110. package/dist/lib/strategies/index.d.ts.map +1 -0
  111. package/dist/lib/strategies/purge-errors.d.ts +13 -0
  112. package/dist/lib/strategies/purge-errors.d.ts.map +1 -0
  113. package/dist/lib/subagents/subagent-results.d.ts +5 -0
  114. package/dist/lib/subagents/subagent-results.d.ts.map +1 -0
  115. package/dist/lib/token-utils.d.ts +19 -0
  116. package/dist/lib/token-utils.d.ts.map +1 -0
  117. package/dist/lib/tui/dialogs.d.ts +29 -0
  118. package/dist/lib/tui/dialogs.d.ts.map +1 -0
  119. package/dist/lib/tui/format.d.ts +4 -0
  120. package/dist/lib/tui/format.d.ts.map +1 -0
  121. package/dist/lib/tui/types.d.ts +18 -0
  122. package/dist/lib/tui/types.d.ts.map +1 -0
  123. package/dist/lib/tui/ui.d.ts +48 -0
  124. package/dist/lib/tui/ui.d.ts.map +1 -0
  125. package/dist/lib/ui/notification.d.ts +17 -0
  126. package/dist/lib/ui/notification.d.ts.map +1 -0
  127. package/dist/lib/ui/utils.d.ts +10 -0
  128. package/dist/lib/ui/utils.d.ts.map +1 -0
  129. package/dist/lib/v2/index.d.ts +5 -0
  130. package/dist/lib/v2/index.d.ts.map +1 -0
  131. package/dist/lib/v2/messages.d.ts +13 -0
  132. package/dist/lib/v2/messages.d.ts.map +1 -0
  133. package/dist/lib/v2/rpc.d.ts +55 -0
  134. package/dist/lib/v2/rpc.d.ts.map +1 -0
  135. package/dist/lib/v2/theme.d.ts +55 -0
  136. package/dist/lib/v2/theme.d.ts.map +1 -0
  137. package/dist/lib/v2/tui.d.ts +4 -0
  138. package/dist/lib/v2/tui.d.ts.map +1 -0
  139. package/dist/tui.d.ts +7 -0
  140. package/dist/tui.d.ts.map +1 -0
  141. package/lib/commands/compression-targets.ts +54 -0
  142. package/lib/commands/context.ts +305 -0
  143. package/lib/commands/decompress.ts +273 -0
  144. package/lib/commands/help.ts +76 -0
  145. package/lib/commands/index.ts +11 -0
  146. package/lib/commands/manual.ts +126 -0
  147. package/lib/commands/recompress.ts +222 -0
  148. package/lib/commands/stats.ts +159 -0
  149. package/lib/commands/sweep.ts +266 -0
  150. package/lib/compress/index.ts +2 -0
  151. package/lib/compress/pipeline.ts +116 -0
  152. package/lib/compress/protected-content.ts +163 -0
  153. package/lib/compress/rc.ts +458 -0
  154. package/lib/compress/search.ts +277 -0
  155. package/lib/compress/state.ts +272 -0
  156. package/lib/compress/summary.ts +293 -0
  157. package/lib/compress/timing.ts +77 -0
  158. package/lib/compress/types.ts +57 -0
  159. package/lib/compress-permission.ts +9 -0
  160. package/lib/config.ts +995 -0
  161. package/lib/hooks.ts +205 -0
  162. package/lib/logger.ts +226 -0
  163. package/lib/message-ids.ts +181 -0
  164. package/lib/messages/index.ts +7 -0
  165. package/lib/messages/inject/inject.ts +215 -0
  166. package/lib/messages/inject/subagent-results.ts +84 -0
  167. package/lib/messages/inject/utils.ts +304 -0
  168. package/lib/messages/prune.ts +238 -0
  169. package/lib/messages/query.ts +71 -0
  170. package/lib/messages/reasoning-strip.ts +40 -0
  171. package/lib/messages/shape.ts +50 -0
  172. package/lib/messages/sync.ts +124 -0
  173. package/lib/messages/utils.ts +190 -0
  174. package/lib/prompts/compress-rc.ts +25 -0
  175. package/lib/prompts/context-limit-nudge.ts +18 -0
  176. package/lib/prompts/extensions/nudge.ts +34 -0
  177. package/lib/prompts/extensions/system.ts +32 -0
  178. package/lib/prompts/extensions/tool.ts +21 -0
  179. package/lib/prompts/index.ts +29 -0
  180. package/lib/prompts/iteration-nudge.ts +6 -0
  181. package/lib/prompts/store.ts +452 -0
  182. package/lib/prompts/system.ts +41 -0
  183. package/lib/prompts/turn-nudge.ts +10 -0
  184. package/lib/protected-patterns.ts +159 -0
  185. package/lib/state/index.ts +4 -0
  186. package/lib/state/persistence.ts +305 -0
  187. package/lib/state/state.ts +210 -0
  188. package/lib/state/tool-cache.ts +99 -0
  189. package/lib/state/types.ts +114 -0
  190. package/lib/state/utils.ts +345 -0
  191. package/lib/strategies/deduplication.ts +125 -0
  192. package/lib/strategies/index.ts +2 -0
  193. package/lib/strategies/purge-errors.ts +86 -0
  194. package/lib/subagents/subagent-results.ts +75 -0
  195. package/lib/token-utils.ts +164 -0
  196. package/lib/tui/dialogs.tsx +237 -0
  197. package/lib/tui/format.ts +25 -0
  198. package/lib/tui/types.ts +25 -0
  199. package/lib/tui/ui.tsx +245 -0
  200. package/lib/ui/notification.ts +347 -0
  201. package/lib/ui/utils.ts +304 -0
  202. package/lib/v2/index.ts +401 -0
  203. package/lib/v2/messages.ts +252 -0
  204. package/lib/v2/rpc.ts +48 -0
  205. package/lib/v2/theme.ts +67 -0
  206. package/lib/v2/tui.tsx +115 -0
  207. package/package.json +91 -5
  208. package/server.js +1 -0
  209. package/tui.tsx +7 -0
@@ -0,0 +1,304 @@
1
+ import { SessionState, ToolParameterEntry, WithParts } from "../state"
2
+ import { countTokens } from "../token-utils"
3
+ import { isIgnoredUserMessage } from "../messages/query"
4
+
5
+ function extractParameterKey(tool: string, parameters: any): string {
6
+ if (!parameters) return ""
7
+
8
+ if (tool === "read" && parameters.filePath) {
9
+ const offset = parameters.offset
10
+ const limit = parameters.limit
11
+ if (offset !== undefined && limit !== undefined) {
12
+ return `${parameters.filePath} (lines ${offset}-${offset + limit})`
13
+ }
14
+ if (offset !== undefined) {
15
+ return `${parameters.filePath} (lines ${offset}+)`
16
+ }
17
+ if (limit !== undefined) {
18
+ return `${parameters.filePath} (lines 0-${limit})`
19
+ }
20
+ return parameters.filePath
21
+ }
22
+
23
+ if ((tool === "write" || tool === "edit" || tool === "multiedit") && parameters.filePath) {
24
+ return parameters.filePath
25
+ }
26
+
27
+ if (tool === "apply_patch" && typeof parameters.patchText === "string") {
28
+ const pathRegex = /\*\*\* (?:Add|Delete|Update) File: ([^\n\r]+)/g
29
+ const paths: string[] = []
30
+ let match
31
+ while ((match = pathRegex.exec(parameters.patchText)) !== null) {
32
+ paths.push(match[1].trim())
33
+ }
34
+ if (paths.length > 0) {
35
+ const uniquePaths = [...new Set(paths)]
36
+ const count = uniquePaths.length
37
+ const plural = count > 1 ? "s" : ""
38
+ if (count === 1) return uniquePaths[0]
39
+ if (count === 2) return uniquePaths.join(", ")
40
+ return `${count} file${plural}: ${uniquePaths[0]}, ${uniquePaths[1]}...`
41
+ }
42
+ return "patch"
43
+ }
44
+
45
+ if (tool === "list") {
46
+ return parameters.path || "(current directory)"
47
+ }
48
+
49
+ if (tool === "glob") {
50
+ if (parameters.pattern) {
51
+ const pathInfo = parameters.path ? ` in ${parameters.path}` : ""
52
+ return `"${parameters.pattern}"${pathInfo}`
53
+ }
54
+ return "(unknown pattern)"
55
+ }
56
+
57
+ if (tool === "grep") {
58
+ if (parameters.pattern) {
59
+ const pathInfo = parameters.path ? ` in ${parameters.path}` : ""
60
+ return `"${parameters.pattern}"${pathInfo}`
61
+ }
62
+ return "(unknown pattern)"
63
+ }
64
+
65
+ if (tool === "bash") {
66
+ if (parameters.description) return parameters.description
67
+ if (parameters.command) {
68
+ return parameters.command.length > 50
69
+ ? parameters.command.substring(0, 50) + "..."
70
+ : parameters.command
71
+ }
72
+ }
73
+
74
+ if (tool === "webfetch" && parameters.url) {
75
+ return parameters.url
76
+ }
77
+ if (tool === "websearch" && parameters.query) {
78
+ return `"${parameters.query}"`
79
+ }
80
+ if (tool === "codesearch" && parameters.query) {
81
+ return `"${parameters.query}"`
82
+ }
83
+
84
+ if (tool === "todowrite") {
85
+ return `${parameters.todos?.length || 0} todos`
86
+ }
87
+ if (tool === "todoread") {
88
+ return "read todo list"
89
+ }
90
+
91
+ if (tool === "task" && parameters.description) {
92
+ return parameters.description
93
+ }
94
+ if (tool === "skill" && parameters.name) {
95
+ return parameters.name
96
+ }
97
+
98
+ if (tool === "lsp") {
99
+ const op = parameters.operation || "lsp"
100
+ const path = parameters.filePath || ""
101
+ const line = parameters.line
102
+ const char = parameters.character
103
+ if (path && line !== undefined && char !== undefined) {
104
+ return `${op} ${path}:${line}:${char}`
105
+ }
106
+ if (path) {
107
+ return `${op} ${path}`
108
+ }
109
+ return op
110
+ }
111
+
112
+ if (tool === "question") {
113
+ const questions = parameters.questions
114
+ if (Array.isArray(questions) && questions.length > 0) {
115
+ const headers = questions
116
+ .map((q: any) => q.header || "")
117
+ .filter(Boolean)
118
+ .slice(0, 3)
119
+
120
+ const count = questions.length
121
+ const plural = count > 1 ? "s" : ""
122
+
123
+ if (headers.length > 0) {
124
+ const suffix = count > 3 ? ` (+${count - 3} more)` : ""
125
+ return `${count} question${plural}: ${headers.join(", ")}${suffix}`
126
+ }
127
+ return `${count} question${plural}`
128
+ }
129
+ return "question"
130
+ }
131
+
132
+ const paramStr = JSON.stringify(parameters)
133
+ if (paramStr === "{}" || paramStr === "[]" || paramStr === "null") {
134
+ return ""
135
+ }
136
+
137
+ return paramStr.substring(0, 50)
138
+ }
139
+
140
+ export function formatStatsHeader(totalTokensSaved: number, pruneTokenCounter: number): string {
141
+ const totalTokensSavedStr = `~${formatTokenCount(totalTokensSaved + pruneTokenCounter)}`
142
+ return [`▣ DCP | ${totalTokensSavedStr} saved total`].join("\n")
143
+ }
144
+
145
+ export function formatTokenCount(tokens: number, compact?: boolean): string {
146
+ const suffix = compact ? "" : " tokens"
147
+ if (tokens >= 1000) {
148
+ return `${(tokens / 1000).toFixed(1)}K`.replace(".0K", "K") + suffix
149
+ }
150
+ return tokens.toString() + suffix
151
+ }
152
+
153
+ export function truncate(str: string, maxLen: number = 60): string {
154
+ if (str.length <= maxLen) return str
155
+ return str.slice(0, maxLen - 3) + "..."
156
+ }
157
+
158
+ export function formatProgressBar(
159
+ messageIds: string[],
160
+ prunedMessages: Map<string, number>,
161
+ recentMessageIds: string[],
162
+ width: number = 50,
163
+ ): string {
164
+ const ACTIVE = "█"
165
+ const PRUNED = "░"
166
+ const RECENT = "⣿"
167
+ const recentSet = new Set(recentMessageIds)
168
+
169
+ const total = messageIds.length
170
+ if (total === 0) return `│${PRUNED.repeat(width)}│`
171
+
172
+ const bar = new Array(width).fill(ACTIVE)
173
+
174
+ for (let m = 0; m < total; m++) {
175
+ const msgId = messageIds[m]
176
+ const start = Math.floor((m / total) * width)
177
+ const end = Math.floor(((m + 1) / total) * width)
178
+
179
+ if (recentSet.has(msgId)) {
180
+ for (let i = start; i < end; i++) {
181
+ bar[i] = RECENT
182
+ }
183
+ } else if (prunedMessages.has(msgId)) {
184
+ for (let i = start; i < end; i++) {
185
+ bar[i] = PRUNED
186
+ }
187
+ }
188
+ }
189
+
190
+ return `│${bar.join("")}│`
191
+ }
192
+
193
+ export function cacheSystemPromptTokens(state: SessionState, messages: WithParts[]): void {
194
+ let firstInputTokens = 0
195
+ for (const msg of messages) {
196
+ if (msg.info.role !== "assistant") {
197
+ continue
198
+ }
199
+ const info = msg.info as any
200
+ const input = info?.tokens?.input || 0
201
+ const cacheRead = info?.tokens?.cache?.read || 0
202
+ const cacheWrite = info?.tokens?.cache?.write || 0
203
+ if (input > 0 || cacheRead > 0 || cacheWrite > 0) {
204
+ firstInputTokens = input + cacheRead + cacheWrite
205
+ break
206
+ }
207
+ }
208
+
209
+ if (firstInputTokens <= 0) {
210
+ state.systemPromptTokens = undefined
211
+ return
212
+ }
213
+
214
+ let firstUserText = ""
215
+ for (const msg of messages) {
216
+ if (msg.info.role !== "user" || isIgnoredUserMessage(msg)) {
217
+ continue
218
+ }
219
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
220
+ for (const part of parts) {
221
+ if (part.type === "text" && !(part as any).ignored) {
222
+ firstUserText += part.text
223
+ }
224
+ }
225
+ break
226
+ }
227
+
228
+ const estimatedSystemTokens = Math.max(0, firstInputTokens - countTokens(firstUserText))
229
+ state.systemPromptTokens = estimatedSystemTokens > 0 ? estimatedSystemTokens : undefined
230
+ }
231
+
232
+ export function shortenPath(input: string, workingDirectory?: string): string {
233
+ const inPathMatch = input.match(/^(.+) in (.+)$/)
234
+ if (inPathMatch) {
235
+ const prefix = inPathMatch[1]
236
+ const pathPart = inPathMatch[2]
237
+ const shortenedPath = shortenSinglePath(pathPart, workingDirectory)
238
+ return `${prefix} in ${shortenedPath}`
239
+ }
240
+
241
+ return shortenSinglePath(input, workingDirectory)
242
+ }
243
+
244
+ function shortenSinglePath(path: string, workingDirectory?: string): string {
245
+ if (workingDirectory) {
246
+ if (path.startsWith(workingDirectory + "/")) {
247
+ return path.slice(workingDirectory.length + 1)
248
+ }
249
+ if (path === workingDirectory) {
250
+ return "."
251
+ }
252
+ }
253
+
254
+ return path
255
+ }
256
+
257
+ export function formatPrunedItemsList(
258
+ pruneToolIds: string[],
259
+ toolMetadata: Map<string, ToolParameterEntry>,
260
+ workingDirectory?: string,
261
+ ): string[] {
262
+ const lines: string[] = []
263
+
264
+ for (const id of pruneToolIds) {
265
+ const metadata = toolMetadata.get(id)
266
+
267
+ if (metadata) {
268
+ const paramKey = extractParameterKey(metadata.tool, metadata.parameters)
269
+ if (paramKey) {
270
+ // Use 60 char limit to match notification style
271
+ const displayKey = truncate(shortenPath(paramKey, workingDirectory), 60)
272
+ lines.push(`→ ${metadata.tool}: ${displayKey}`)
273
+ } else {
274
+ lines.push(`→ ${metadata.tool}`)
275
+ }
276
+ }
277
+ }
278
+
279
+ const knownCount = pruneToolIds.filter((id) => toolMetadata.has(id)).length
280
+ const unknownCount = pruneToolIds.length - knownCount
281
+
282
+ if (unknownCount > 0) {
283
+ lines.push(`→ (${unknownCount} tool${unknownCount > 1 ? "s" : ""} with unknown metadata)`)
284
+ }
285
+
286
+ return lines
287
+ }
288
+
289
+ export function formatPruningResultForTool(
290
+ prunedIds: string[],
291
+ toolMetadata: Map<string, ToolParameterEntry>,
292
+ workingDirectory?: string,
293
+ ): string {
294
+ const lines: string[] = []
295
+ lines.push(`Context pruning complete. Pruned ${prunedIds.length} tool outputs.`)
296
+ lines.push("")
297
+
298
+ if (prunedIds.length > 0) {
299
+ lines.push(`Semantically pruned (${prunedIds.length}):`)
300
+ lines.push(...formatPrunedItemsList(prunedIds, toolMetadata, workingDirectory))
301
+ }
302
+
303
+ return lines.join("\n").trim()
304
+ }
@@ -0,0 +1,401 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin"
3
+ import { getConfig } from "../config"
4
+ import { Logger } from "../logger"
5
+ import { PromptStore } from "../prompts/store"
6
+ import { createRcCompressTool } from "../compress"
7
+ import { RC_SUMMARY_MARKER } from "../compress/summary"
8
+ import { attachCompressionDuration } from "../compress/state"
9
+ import { createCommandExecuteHandler, createSystemPromptHandler } from "../hooks"
10
+ import {
11
+ createSessionState,
12
+ ensureSessionInitialized,
13
+ checkSession,
14
+ saveSessionState,
15
+ syncToolCache,
16
+ type SessionState,
17
+ } from "../state"
18
+ import { assignMessageRefs } from "../message-ids"
19
+ import { applyPendingManualTrigger } from "../commands/manual"
20
+ import {
21
+ buildToolIdList,
22
+ injectCompressNudges,
23
+ injectMessageIds,
24
+ injectExtendedSubAgentResults,
25
+ prune,
26
+ stripHallucinations,
27
+ syncCompressionBlocks,
28
+ } from "../messages"
29
+ import { countTokens } from "../token-utils"
30
+ import { matchesGlob } from "../protected-patterns"
31
+ import { history, project } from "./messages"
32
+ import { analyzeContextTokens } from "../commands/context"
33
+ import { buildStatsReport } from "../commands/stats"
34
+ import { rpc } from "./rpc"
35
+
36
+ // Extension point for future model-invisible V2 reports. Never use synthetic()
37
+ // here: its text would enter the model's context, unlike V1 ignored messages.
38
+ export async function report(logger: Logger, text: string, sessionID?: string) {
39
+ logger.debug("V2 report (display pending)", { sessionID, text })
40
+ }
41
+
42
+ export async function setup(ctx: Plugin.Context) {
43
+ const warnings = {
44
+ tui: {
45
+ showToast: async (input: { body: { message: string } }) => {
46
+ console.warn(`DCP: ${input.body.message}`)
47
+ },
48
+ },
49
+ }
50
+ const config = getConfig({ directory: ctx.location.directory, client: warnings })
51
+ if (!config.enabled) {
52
+ await ctx.rpc.register(
53
+ { ...rpc, methods: { status: rpc.methods.status } },
54
+ { status: async () => ({ enabled: false }) },
55
+ )
56
+ return
57
+ }
58
+ const logger = new Logger(config.debug)
59
+ const prompts = new PromptStore(
60
+ logger,
61
+ ctx.location.directory,
62
+ config.experimental.customPrompts,
63
+ "compact",
64
+ )
65
+ const sessions = new Map<string, SessionState>()
66
+ const queues = new Map<string, Promise<unknown>>()
67
+ const limits = new Map<string, number>()
68
+ const aliases: Record<string, string> = {
69
+ task: "subagent",
70
+ bash: "shell",
71
+ apply_patch: "patch",
72
+ }
73
+ for (const list of [
74
+ config.compress.protectedTools,
75
+ config.commands.protectedTools,
76
+ config.strategies.deduplication.protectedTools,
77
+ config.strategies.purgeErrors.protectedTools,
78
+ ]) {
79
+ for (const name of [...list])
80
+ if (aliases[name] && !list.includes(aliases[name]!)) list.push(aliases[name]!)
81
+ }
82
+
83
+ function serial<T>(sessionID: string, operation: () => Promise<T>): Promise<T> {
84
+ const pending = (queues.get(sessionID) ?? Promise.resolve()).catch(() => {}).then(operation)
85
+ queues.set(sessionID, pending)
86
+ void pending
87
+ .finally(() => {
88
+ if (queues.get(sessionID) === pending) queues.delete(sessionID)
89
+ })
90
+ .catch(() => {})
91
+ return pending
92
+ }
93
+
94
+ const client = {
95
+ session: {
96
+ get: async ({ path }: { path: { id: string } }) => ({
97
+ data: await ctx.session.get({ sessionID: path.id }),
98
+ }),
99
+ messages: async ({ path }: { path: { id: string } }) => {
100
+ const [entries, session] = await Promise.all([
101
+ ctx.session.context({ sessionID: path.id }),
102
+ ctx.session.get({ sessionID: path.id }),
103
+ ])
104
+ return { data: history(entries, session) }
105
+ },
106
+ prompt: async (input: {
107
+ path: { id: string }
108
+ body: { parts: Array<{ text: string }> }
109
+ }) =>
110
+ report(logger, input.body.parts.map((part) => part.text).join("\n"), input.path.id),
111
+ },
112
+ tui: {
113
+ showToast: async (input: { body: { message: string } }) =>
114
+ report(logger, input.body.message),
115
+ },
116
+ }
117
+
118
+ async function load(sessionID: string, agentID?: string) {
119
+ const [session, entries] = await Promise.all([
120
+ ctx.session.get({ sessionID }),
121
+ ctx.session.context({ sessionID }),
122
+ ])
123
+ const selected =
124
+ agentID ??
125
+ session.agent ??
126
+ entries.findLast((entry) => entry.type === "assistant")?.agent
127
+ if (!selected) throw new Error("DCP commands require a session with a selected agent")
128
+ const { data: agent } = await ctx.agent.get({ agentID: selected })
129
+ let state = sessions.get(sessionID)
130
+ if (!state) {
131
+ state = createSessionState("compact")
132
+ sessions.set(sessionID, state)
133
+ }
134
+ const messages = history(entries, session)
135
+ await ensureSessionInitialized(
136
+ client,
137
+ state,
138
+ sessionID,
139
+ logger,
140
+ messages,
141
+ config.manualMode.enabled,
142
+ )
143
+ await checkSession(client, state, logger, messages, config.manualMode.enabled)
144
+ const rule = [...agent.permissions, ...(session.permissions ?? [])].findLast(
145
+ (rule) => matchesGlob("compress", rule.action) && matchesGlob("*", rule.resource),
146
+ )
147
+ state.compressPermission =
148
+ config.compress.permission === "deny"
149
+ ? "deny"
150
+ : rule?.effect === "deny"
151
+ ? "deny"
152
+ : config.compress.permission === "ask" || rule?.effect === "ask"
153
+ ? "ask"
154
+ : "allow"
155
+ return { state, entries, session, messages }
156
+ }
157
+
158
+ function allowed(state: SessionState) {
159
+ if (state.isSubAgent && !config.experimental.allowSubAgents)
160
+ throw new Error("DCP compression is disabled in subagents")
161
+ if (state.compressPermission === "deny") throw new Error("DCP compression is denied")
162
+ if (state.compressPermission === "ask")
163
+ throw new Error(
164
+ "DCP: compress permission 'ask' is not supported by OpenCode V2's public plugin API yet. Compression was not performed.",
165
+ )
166
+ }
167
+
168
+ await ctx.model.transform((editor) => {
169
+ limits.clear()
170
+ for (const model of editor.list())
171
+ limits.set(`${model.providerID}/${model.id}`, model.limit.context)
172
+ })
173
+ for (const kind of ["context", "compaction"] as const)
174
+ await ctx.session.hook(kind, (event) =>
175
+ serial(event.sessionID, async () => {
176
+ const { state, entries, session, messages } = await load(
177
+ event.sessionID,
178
+ event.agent,
179
+ )
180
+ if (state.isSubAgent && !config.experimental.allowSubAgents) {
181
+ delete event.tools.compress
182
+ return
183
+ }
184
+ if (state.compressPermission === "deny") delete event.tools.compress
185
+ state.modelContextLimit = limits.get(`${event.model.providerID}/${event.model.id}`)
186
+ state.systemPromptTokens = countTokens(
187
+ event.system.map((part) => part.text).join("\n"),
188
+ )
189
+ const view = project(event.messages, entries, {
190
+ ...session,
191
+ agent: event.agent,
192
+ model: event.model,
193
+ })
194
+ stripHallucinations(view.messages, state.idFormat)
195
+ assignMessageRefs(state, view.messages)
196
+ // Compaction may select only a prefix; block origins can be in the retained tail.
197
+ syncCompressionBlocks(state, logger, messages)
198
+ syncToolCache(state, config, logger, view.messages)
199
+ buildToolIdList(state, view.messages)
200
+ prune(state, logger, view.messages, view.summaryBase)
201
+ await injectExtendedSubAgentResults(
202
+ client,
203
+ state,
204
+ logger,
205
+ view.messages,
206
+ config.experimental.allowSubAgents,
207
+ )
208
+ prompts.reload()
209
+ injectCompressNudges(
210
+ state,
211
+ config,
212
+ logger,
213
+ view.messages,
214
+ prompts.getRuntimePrompts(),
215
+ kind === "context",
216
+ )
217
+ injectMessageIds(state, config, view.messages)
218
+ applyPendingManualTrigger(state, view.messages, logger)
219
+ event.messages = view.restore()
220
+ const system = { system: event.system.map((part) => part.text) }
221
+ await createSystemPromptHandler(
222
+ state,
223
+ logger,
224
+ config,
225
+ prompts,
226
+ )(
227
+ {
228
+ sessionID: event.sessionID,
229
+ model: { limit: { context: state.modelContextLimit ?? 0 } },
230
+ },
231
+ system,
232
+ )
233
+ event.system = system.system.map((text, index) => ({
234
+ ...event.system[index],
235
+ type: "text",
236
+ text,
237
+ }))
238
+ await logger.saveContext(event.sessionID, view.messages)
239
+ }),
240
+ )
241
+
242
+ // Hidden summary generation for rc mode: when our marker is present, drop
243
+ // the session context and every tool so the transient request contains
244
+ // only the prompt we built. This keeps the summarization request invisible
245
+ // and independent of any injected metadata.
246
+ await ctx.session.hook("generate", (event) => {
247
+ const own = event.messages.filter(
248
+ (message) =>
249
+ Array.isArray(message.content) &&
250
+ message.content.some(
251
+ (part: any) =>
252
+ part?.type === "text" &&
253
+ typeof part.text === "string" &&
254
+ part.text.includes(RC_SUMMARY_MARKER),
255
+ ),
256
+ )
257
+ if (own.length === 0) {
258
+ return
259
+ }
260
+ event.messages = own as typeof event.messages
261
+ for (const name of Object.keys(event.tools ?? {})) {
262
+ delete (event.tools as Record<string, unknown>)[name]
263
+ }
264
+ })
265
+
266
+ if (config.compress.permission !== "deny") {
267
+ const define = (state: SessionState): ToolDefinition => {
268
+ const context = {
269
+ client,
270
+ state,
271
+ logger,
272
+ config,
273
+ prompts,
274
+ generate: async (input: { sessionID: string; prompt: string }) => {
275
+ const result = await ctx.session.generate(input)
276
+ return result?.text ?? ""
277
+ },
278
+ }
279
+ return createRcCompressTool(context)
280
+ }
281
+ const definition = define(createSessionState("compact"))
282
+ await ctx.tool.transform((editor) =>
283
+ editor.add({
284
+ name: "compress",
285
+ description: definition.description,
286
+ input: tool.schema.object(definition.args),
287
+ options: { codemode: false, permission: "compress" },
288
+ execute: (input, context) =>
289
+ serial(context.sessionID, async () => {
290
+ const { state } = await load(context.sessionID, context.agent)
291
+ allowed(state)
292
+ const started = Date.now()
293
+ const legacy = define(state)
294
+ const content = await legacy.execute(input, {
295
+ sessionID: context.sessionID,
296
+ messageID: context.messageID,
297
+ callID: context.id,
298
+ agent: context.agent,
299
+ directory: ctx.location.directory,
300
+ worktree: ctx.location.directory,
301
+ abort: new AbortController().signal,
302
+ ask: async () => allowed(state),
303
+ metadata: ({ title }: { title?: string }) => {
304
+ void context.progress({ title })
305
+ },
306
+ } as Parameters<typeof legacy.execute>[1])
307
+ attachCompressionDuration(
308
+ state.prune.messages,
309
+ context.messageID,
310
+ context.id,
311
+ Date.now() - started,
312
+ )
313
+ await saveSessionState(state, logger)
314
+ // The rc executor returns the tool result text.
315
+ return { content: content as string }
316
+ }),
317
+ }),
318
+ )
319
+ }
320
+ if (config.commands.enabled)
321
+ await ctx.command.transform((editor) => {
322
+ for (const name of ["dcp", "dcp-compress"])
323
+ editor.add({
324
+ name,
325
+ description: name === "dcp" ? "DCP commands" : "Trigger DCP manual compression",
326
+ execute: async (invocation) => {
327
+ const prompt = await serial(invocation.sessionID, async () => {
328
+ const { state } = await load(invocation.sessionID)
329
+ const permission =
330
+ state.compressPermission ?? config.compress.permission
331
+ if (
332
+ name === "dcp-compress" ||
333
+ invocation.prompt.text.trim().split(/\s+/)[0] === "compress"
334
+ )
335
+ allowed(state)
336
+ const output = { parts: [] }
337
+ await createCommandExecuteHandler(
338
+ client,
339
+ state,
340
+ logger,
341
+ config,
342
+ ctx.location.directory,
343
+ )(
344
+ {
345
+ command: name,
346
+ sessionID: invocation.sessionID,
347
+ arguments: invocation.prompt.text,
348
+ },
349
+ output,
350
+ )
351
+ state.compressPermission = permission
352
+ const pending = state.pendingManualTrigger
353
+ if (!pending) return
354
+ allowed(state)
355
+ state.pendingManualTrigger = null
356
+ return pending.prompt
357
+ })
358
+ if (prompt)
359
+ await ctx.session.prompt({
360
+ sessionID: invocation.sessionID,
361
+ text: prompt,
362
+ delivery: invocation.delivery,
363
+ })
364
+ },
365
+ })
366
+ })
367
+ await ctx.rpc.register(rpc, {
368
+ status: async () => ({ enabled: config.commands.enabled }),
369
+ snapshot: ({ sessionID }) =>
370
+ serial(sessionID, async () => {
371
+ const { state, messages } = await load(sessionID)
372
+ syncCompressionBlocks(state, logger, messages)
373
+ return {
374
+ manualMode: !!state.manualMode,
375
+ canCompress:
376
+ state.compressPermission === "allow" &&
377
+ (!state.isSubAgent || config.experimental.allowSubAgents),
378
+ ...(state.compressPermission === "ask"
379
+ ? {
380
+ blockedReason:
381
+ "Permission 'ask' is not supported by the V2 plugin API yet.",
382
+ }
383
+ : {}),
384
+ context: analyzeContextTokens(state, messages),
385
+ stats: await buildStatsReport(state, logger),
386
+ }
387
+ }),
388
+ manual: ({ sessionID, enabled }) =>
389
+ serial(sessionID, async () => {
390
+ const { state } = await load(sessionID)
391
+ state.manualMode = enabled ? "active" : false
392
+ await saveSessionState(state, logger)
393
+ return {}
394
+ }),
395
+ })
396
+ logger.info("DCP V2 initialized")
397
+ return () => {
398
+ sessions.clear()
399
+ limits.clear()
400
+ }
401
+ }