@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
package/lib/tui/ui.tsx ADDED
@@ -0,0 +1,245 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core"
4
+ import type { JSX } from "solid-js"
5
+ import { pct } from "./format"
6
+ import type { Theme, ThemeColor, ViewApi } from "./types"
7
+
8
+ // header (1-2) + divider (1) + footer (2) + gaps (3) + bottom padding (1)
9
+ const FRAME_OVERHEAD = 9
10
+
11
+ export function DcpFrame(props: {
12
+ api: ViewApi
13
+ title?: string
14
+ eyebrow: string
15
+ children: JSX.Element
16
+ onBack?: () => void
17
+ }) {
18
+ const theme = props.api.theme.current
19
+ let body: ScrollBoxRenderable | undefined
20
+ let inner: BoxRenderable | undefined
21
+ function available() {
22
+ const height = props.api.renderer.height
23
+ return Math.max(10, height - Math.floor(height / 4) - 2) - FRAME_OVERHEAD
24
+ }
25
+ function resize() {
26
+ if (body) body.height = Math.min(inner?.height || available(), available())
27
+ }
28
+ return (
29
+ <box paddingLeft={3} paddingRight={3} paddingBottom={1} gap={1}>
30
+ <box flexDirection="row" justifyContent="space-between">
31
+ <box flexDirection="column">
32
+ <text fg={theme.primary}>
33
+ <b>{props.eyebrow}</b>
34
+ </text>
35
+ {props.title ? (
36
+ <text fg={theme.text}>
37
+ <b>{props.title}</b>
38
+ </text>
39
+ ) : null}
40
+ </box>
41
+ <text fg={theme.textMuted} onMouseUp={() => props.api.ui.dialog.clear()}>
42
+ esc
43
+ </text>
44
+ </box>
45
+ <box height={1} border={["bottom"]} borderColor={theme.borderSubtle} />
46
+ <scrollbox
47
+ height={available()}
48
+ scrollY
49
+ ref={(r: ScrollBoxRenderable) => {
50
+ body = r
51
+ // Use the host renderer rather than a separate OpenTUI/Solid context.
52
+ props.api.renderer.on("resize", resize)
53
+ r.once("destroyed", () => props.api.renderer.off("resize", resize))
54
+ resize()
55
+ }}
56
+ >
57
+ <box ref={(r: BoxRenderable) => (inner = r)} onSizeChange={resize}>
58
+ {props.children}
59
+ </box>
60
+ </scrollbox>
61
+ <box flexDirection="row" justifyContent="space-between" paddingTop={1}>
62
+ {props.onBack ? (
63
+ <FooterButton
64
+ theme={theme}
65
+ label="back"
66
+ variant="muted"
67
+ onClick={props.onBack}
68
+ />
69
+ ) : (
70
+ <box />
71
+ )}
72
+ <FooterButton
73
+ theme={theme}
74
+ label="close"
75
+ variant="primary"
76
+ onClick={() => props.api.ui.dialog.clear()}
77
+ />
78
+ </box>
79
+ </box>
80
+ )
81
+ }
82
+
83
+ function FooterButton(props: {
84
+ theme: Theme
85
+ label: string
86
+ variant: "muted" | "primary"
87
+ onClick: () => void
88
+ }) {
89
+ const primary = props.variant === "primary"
90
+ return (
91
+ <box
92
+ paddingLeft={2}
93
+ paddingRight={2}
94
+ backgroundColor={primary ? props.theme.primary : props.theme.backgroundElement}
95
+ onMouseUp={props.onClick}
96
+ >
97
+ <text fg={primary ? props.theme.selectedListItemText : props.theme.text}>
98
+ {props.label}
99
+ </text>
100
+ </box>
101
+ )
102
+ }
103
+
104
+ export function Card(props: { theme: Theme; title: string; children: JSX.Element }) {
105
+ const accent = props.theme.primary
106
+ return (
107
+ <box
108
+ flexDirection="column"
109
+ paddingLeft={2}
110
+ paddingRight={2}
111
+ paddingTop={1}
112
+ paddingBottom={1}
113
+ backgroundColor={props.theme.backgroundElement}
114
+ border={["left"]}
115
+ borderColor={accent}
116
+ gap={1}
117
+ >
118
+ <text fg={accent}>
119
+ <b>{props.title}</b>
120
+ </text>
121
+ {props.children}
122
+ </box>
123
+ )
124
+ }
125
+
126
+ export function Metric(props: { theme: Theme; label: string; value: string; hint?: string }) {
127
+ return (
128
+ <box flexDirection="row" gap={2}>
129
+ <box width={24}>
130
+ <text fg={props.theme.textMuted}>{props.label}</text>
131
+ </box>
132
+ <box flexDirection="row" gap={1} flexGrow={1}>
133
+ <text fg={props.theme.text}>
134
+ <b>{props.value}</b>
135
+ </text>
136
+ {props.hint ? <text fg={props.theme.textMuted}>{props.hint}</text> : null}
137
+ </box>
138
+ </box>
139
+ )
140
+ }
141
+
142
+ export function Progress(props: {
143
+ theme: Theme
144
+ label: string
145
+ value: number
146
+ total: number
147
+ color: ThemeColor
148
+ detail: string
149
+ }) {
150
+ const width = 32
151
+ const filled =
152
+ props.total > 0 ? Math.max(0, Math.round((props.value / props.total) * width)) : 0
153
+ const empty = Math.max(0, width - filled)
154
+ return (
155
+ <box flexDirection="column" gap={0}>
156
+ <box flexDirection="row" gap={2}>
157
+ <box width={20}>
158
+ <text fg={props.theme.text}>{props.label}</text>
159
+ </box>
160
+ <box flexDirection="row" gap={1} flexGrow={1}>
161
+ <text fg={props.theme.text}>
162
+ <b>{pct(props.value, props.total)}</b>
163
+ </text>
164
+ <text fg={props.theme.textMuted}>{props.detail}</text>
165
+ </box>
166
+ </box>
167
+ <box flexDirection="row">
168
+ <text fg={props.theme[props.color]}>{"█".repeat(filled)}</text>
169
+ <text fg={props.theme.borderSubtle}>{"░".repeat(empty)}</text>
170
+ </box>
171
+ </box>
172
+ )
173
+ }
174
+
175
+ export function PromptRow(props: {
176
+ theme: Theme
177
+ command: string
178
+ description: string
179
+ accent?: ThemeColor
180
+ }) {
181
+ const accent = props.theme[props.accent ?? "accent"]
182
+ return (
183
+ <box flexDirection="row" gap={2}>
184
+ <box width={22}>
185
+ <text fg={accent}>
186
+ <b>{props.command}</b>
187
+ </text>
188
+ </box>
189
+ <box flexGrow={1}>
190
+ <text fg={props.theme.text}>{props.description}</text>
191
+ </box>
192
+ </box>
193
+ )
194
+ }
195
+
196
+ export function StatusPill(props: {
197
+ theme: Theme
198
+ label: string
199
+ value: string
200
+ accent: ThemeColor
201
+ }) {
202
+ const accent = props.theme[props.accent]
203
+ return (
204
+ <box flexDirection="row" justifyContent="space-between" paddingLeft={1} paddingRight={1}>
205
+ <box width={22}>
206
+ <text fg={props.theme.primary}>
207
+ <b>{props.label}</b>
208
+ </text>
209
+ </box>
210
+ <text fg={accent}>
211
+ <b>{props.value}</b>
212
+ </text>
213
+ </box>
214
+ )
215
+ }
216
+
217
+ export function ActionRow(props: {
218
+ theme: Theme
219
+ title: string
220
+ detail: string
221
+ onClick: () => void
222
+ }) {
223
+ const accent = props.theme.primary
224
+ return (
225
+ <box
226
+ flexDirection="row"
227
+ justifyContent="space-between"
228
+ paddingLeft={1}
229
+ paddingRight={1}
230
+ onMouseUp={props.onClick}
231
+ >
232
+ <box flexDirection="row" gap={2}>
233
+ <box width={12}>
234
+ <text fg={accent}>
235
+ <b>{props.title}</b>
236
+ </text>
237
+ </box>
238
+ <text fg={props.theme.text}>{props.detail}</text>
239
+ </box>
240
+ <box paddingLeft={2} paddingRight={2} backgroundColor={accent}>
241
+ <text fg={props.theme.selectedListItemText}>open</text>
242
+ </box>
243
+ </box>
244
+ )
245
+ }
@@ -0,0 +1,347 @@
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
+
253
+ const activePrunedMessages = new Map<string, number>()
254
+ for (const [messageId, entry] of state.prune.messages.byMessageId) {
255
+ if (entry.activeBlockIds.length > 0) {
256
+ activePrunedMessages.set(messageId, entry.tokenCount)
257
+ }
258
+ }
259
+ const progressBar = formatProgressBar(
260
+ sessionMessageIds,
261
+ activePrunedMessages,
262
+ newlyCompressedMessageIds,
263
+ 50,
264
+ )
265
+ message += `\n\n${progressBar}`
266
+ message += `\n▣ ${compressionLabel} ${formatCompressionMetrics(compressedTokens, summaryTokens)}`
267
+ message += `\n→ Topic: ${topic}`
268
+ message += `\n→ Items: ${newlyCompressedMessageIds.length} messages`
269
+ if (newlyCompressedToolIds.length > 0) {
270
+ message += ` and ${newlyCompressedToolIds.length} tools compressed`
271
+ } else {
272
+ message += ` compressed`
273
+ }
274
+ if (config.compress.showCompression) {
275
+ message += `\n→ Compression (~${summaryTokensStr}): ${summary}`
276
+ }
277
+ }
278
+
279
+ if (config.pruneNotificationType === "toast") {
280
+ let toastMessage = message
281
+ if (config.compress.showCompression) {
282
+ const truncatedSummary = truncateToastSummary(summary)
283
+ if (truncatedSummary !== summary) {
284
+ toastMessage = toastMessage.replace(
285
+ `\n→ Compression (~${summaryTokensStr}): ${summary}`,
286
+ `\n→ Compression (~${summaryTokensStr}): ${truncatedSummary}`,
287
+ )
288
+ }
289
+ }
290
+ toastMessage =
291
+ config.pruneNotification === "minimal" ? toastMessage : truncateToastBody(toastMessage)
292
+
293
+ await client.tui.showToast({
294
+ body: {
295
+ title: "DCP: Compress Notification",
296
+ message: toastMessage,
297
+ variant: "info",
298
+ duration: 5000,
299
+ },
300
+ })
301
+ return true
302
+ }
303
+
304
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
305
+ return true
306
+ }
307
+
308
+ export async function sendIgnoredMessage(
309
+ client: any,
310
+ sessionID: string,
311
+ text: string,
312
+ params: any,
313
+ logger: Logger,
314
+ ): Promise<void> {
315
+ const agent = params.agent || undefined
316
+ const variant = params.variant || undefined
317
+ const model =
318
+ params.providerId && params.modelId
319
+ ? {
320
+ providerID: params.providerId,
321
+ modelID: params.modelId,
322
+ }
323
+ : undefined
324
+
325
+ try {
326
+ await client.session.prompt({
327
+ path: {
328
+ id: sessionID,
329
+ },
330
+ body: {
331
+ noReply: true,
332
+ agent: agent,
333
+ model: model,
334
+ variant: variant,
335
+ parts: [
336
+ {
337
+ type: "text",
338
+ text: text,
339
+ ignored: true,
340
+ },
341
+ ],
342
+ },
343
+ })
344
+ } catch (error: any) {
345
+ logger.error("Failed to send notification", { error: error.message })
346
+ }
347
+ }