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

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 (215) 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 +8129 -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 +27 -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 +68 -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 +64 -0
  42. package/dist/lib/compress/types.d.ts.map +1 -0
  43. package/dist/lib/compress/usage.d.ts +45 -0
  44. package/dist/lib/compress/usage.d.ts.map +1 -0
  45. package/dist/lib/compress-permission.d.ts +4 -0
  46. package/dist/lib/compress-permission.d.ts.map +1 -0
  47. package/dist/lib/config.d.ts +83 -0
  48. package/dist/lib/config.d.ts.map +1 -0
  49. package/dist/lib/hooks.d.ts +22 -0
  50. package/dist/lib/hooks.d.ts.map +1 -0
  51. package/dist/lib/logger.d.ts +31 -0
  52. package/dist/lib/logger.d.ts.map +1 -0
  53. package/dist/lib/message-ids.d.ts +20 -0
  54. package/dist/lib/message-ids.d.ts.map +1 -0
  55. package/dist/lib/messages/index.d.ts +8 -0
  56. package/dist/lib/messages/index.d.ts.map +1 -0
  57. package/dist/lib/messages/inject/inject.d.ts +7 -0
  58. package/dist/lib/messages/inject/inject.d.ts.map +1 -0
  59. package/dist/lib/messages/inject/subagent-results.d.ts +4 -0
  60. package/dist/lib/messages/inject/subagent-results.d.ts.map +1 -0
  61. package/dist/lib/messages/inject/utils.d.ts +23 -0
  62. package/dist/lib/messages/inject/utils.d.ts.map +1 -0
  63. package/dist/lib/messages/prune.d.ts +4 -0
  64. package/dist/lib/messages/prune.d.ts.map +1 -0
  65. package/dist/lib/messages/query.d.ts +7 -0
  66. package/dist/lib/messages/query.d.ts.map +1 -0
  67. package/dist/lib/messages/reasoning-strip.d.ts +8 -0
  68. package/dist/lib/messages/reasoning-strip.d.ts.map +1 -0
  69. package/dist/lib/messages/shape.d.ts +5 -0
  70. package/dist/lib/messages/shape.d.ts.map +1 -0
  71. package/dist/lib/messages/sync.d.ts +4 -0
  72. package/dist/lib/messages/sync.d.ts.map +1 -0
  73. package/dist/lib/messages/utils.d.ts +27 -0
  74. package/dist/lib/messages/utils.d.ts.map +1 -0
  75. package/dist/lib/prompts/compress-rc.d.ts +4 -0
  76. package/dist/lib/prompts/compress-rc.d.ts.map +1 -0
  77. package/dist/lib/prompts/context-limit-nudge.d.ts +2 -0
  78. package/dist/lib/prompts/context-limit-nudge.d.ts.map +1 -0
  79. package/dist/lib/prompts/extensions/nudge.d.ts +4 -0
  80. package/dist/lib/prompts/extensions/nudge.d.ts.map +1 -0
  81. package/dist/lib/prompts/extensions/system.d.ts +4 -0
  82. package/dist/lib/prompts/extensions/system.d.ts.map +1 -0
  83. package/dist/lib/prompts/extensions/tool.d.ts +3 -0
  84. package/dist/lib/prompts/extensions/tool.d.ts.map +1 -0
  85. package/dist/lib/prompts/index.d.ts +4 -0
  86. package/dist/lib/prompts/index.d.ts.map +1 -0
  87. package/dist/lib/prompts/iteration-nudge.d.ts +2 -0
  88. package/dist/lib/prompts/iteration-nudge.d.ts.map +1 -0
  89. package/dist/lib/prompts/store.d.ts +26 -0
  90. package/dist/lib/prompts/store.d.ts.map +1 -0
  91. package/dist/lib/prompts/system.d.ts +4 -0
  92. package/dist/lib/prompts/system.d.ts.map +1 -0
  93. package/dist/lib/prompts/turn-nudge.d.ts +2 -0
  94. package/dist/lib/prompts/turn-nudge.d.ts.map +1 -0
  95. package/dist/lib/protected-patterns.d.ts +6 -0
  96. package/dist/lib/protected-patterns.d.ts.map +1 -0
  97. package/dist/lib/state/index.d.ts +5 -0
  98. package/dist/lib/state/index.d.ts.map +1 -0
  99. package/dist/lib/state/persistence.d.ts +47 -0
  100. package/dist/lib/state/persistence.d.ts.map +1 -0
  101. package/dist/lib/state/state.d.ts +9 -0
  102. package/dist/lib/state/state.d.ts.map +1 -0
  103. package/dist/lib/state/tool-cache.d.ts +13 -0
  104. package/dist/lib/state/tool-cache.d.ts.map +1 -0
  105. package/dist/lib/state/types.d.ts +105 -0
  106. package/dist/lib/state/types.d.ts.map +1 -0
  107. package/dist/lib/state/utils.d.ts +22 -0
  108. package/dist/lib/state/utils.d.ts.map +1 -0
  109. package/dist/lib/strategies/deduplication.d.ts +10 -0
  110. package/dist/lib/strategies/deduplication.d.ts.map +1 -0
  111. package/dist/lib/strategies/index.d.ts +3 -0
  112. package/dist/lib/strategies/index.d.ts.map +1 -0
  113. package/dist/lib/strategies/purge-errors.d.ts +13 -0
  114. package/dist/lib/strategies/purge-errors.d.ts.map +1 -0
  115. package/dist/lib/subagents/subagent-results.d.ts +5 -0
  116. package/dist/lib/subagents/subagent-results.d.ts.map +1 -0
  117. package/dist/lib/token-utils.d.ts +19 -0
  118. package/dist/lib/token-utils.d.ts.map +1 -0
  119. package/dist/lib/tui/dialogs.d.ts +29 -0
  120. package/dist/lib/tui/dialogs.d.ts.map +1 -0
  121. package/dist/lib/tui/format.d.ts +4 -0
  122. package/dist/lib/tui/format.d.ts.map +1 -0
  123. package/dist/lib/tui/types.d.ts +18 -0
  124. package/dist/lib/tui/types.d.ts.map +1 -0
  125. package/dist/lib/tui/ui.d.ts +48 -0
  126. package/dist/lib/tui/ui.d.ts.map +1 -0
  127. package/dist/lib/ui/notification.d.ts +17 -0
  128. package/dist/lib/ui/notification.d.ts.map +1 -0
  129. package/dist/lib/ui/utils.d.ts +10 -0
  130. package/dist/lib/ui/utils.d.ts.map +1 -0
  131. package/dist/lib/v2/index.d.ts +5 -0
  132. package/dist/lib/v2/index.d.ts.map +1 -0
  133. package/dist/lib/v2/messages.d.ts +13 -0
  134. package/dist/lib/v2/messages.d.ts.map +1 -0
  135. package/dist/lib/v2/rpc.d.ts +75 -0
  136. package/dist/lib/v2/rpc.d.ts.map +1 -0
  137. package/dist/lib/v2/theme.d.ts +55 -0
  138. package/dist/lib/v2/theme.d.ts.map +1 -0
  139. package/dist/lib/v2/tui.d.ts +4 -0
  140. package/dist/lib/v2/tui.d.ts.map +1 -0
  141. package/dist/lib/v2/usage.d.ts +44 -0
  142. package/dist/lib/v2/usage.d.ts.map +1 -0
  143. package/dist/tui.d.ts +7 -0
  144. package/dist/tui.d.ts.map +1 -0
  145. package/lib/commands/compression-targets.ts +54 -0
  146. package/lib/commands/context.ts +305 -0
  147. package/lib/commands/decompress.ts +273 -0
  148. package/lib/commands/help.ts +76 -0
  149. package/lib/commands/index.ts +11 -0
  150. package/lib/commands/manual.ts +126 -0
  151. package/lib/commands/recompress.ts +222 -0
  152. package/lib/commands/stats.ts +180 -0
  153. package/lib/commands/sweep.ts +266 -0
  154. package/lib/compress/index.ts +2 -0
  155. package/lib/compress/pipeline.ts +116 -0
  156. package/lib/compress/protected-content.ts +163 -0
  157. package/lib/compress/rc.ts +494 -0
  158. package/lib/compress/search.ts +277 -0
  159. package/lib/compress/state.ts +272 -0
  160. package/lib/compress/summary.ts +320 -0
  161. package/lib/compress/timing.ts +77 -0
  162. package/lib/compress/types.ts +70 -0
  163. package/lib/compress/usage.ts +168 -0
  164. package/lib/compress-permission.ts +9 -0
  165. package/lib/config.ts +995 -0
  166. package/lib/hooks.ts +205 -0
  167. package/lib/logger.ts +226 -0
  168. package/lib/message-ids.ts +181 -0
  169. package/lib/messages/index.ts +7 -0
  170. package/lib/messages/inject/inject.ts +215 -0
  171. package/lib/messages/inject/subagent-results.ts +84 -0
  172. package/lib/messages/inject/utils.ts +304 -0
  173. package/lib/messages/prune.ts +238 -0
  174. package/lib/messages/query.ts +71 -0
  175. package/lib/messages/reasoning-strip.ts +40 -0
  176. package/lib/messages/shape.ts +50 -0
  177. package/lib/messages/sync.ts +124 -0
  178. package/lib/messages/utils.ts +190 -0
  179. package/lib/prompts/compress-rc.ts +25 -0
  180. package/lib/prompts/context-limit-nudge.ts +18 -0
  181. package/lib/prompts/extensions/nudge.ts +34 -0
  182. package/lib/prompts/extensions/system.ts +32 -0
  183. package/lib/prompts/extensions/tool.ts +21 -0
  184. package/lib/prompts/index.ts +29 -0
  185. package/lib/prompts/iteration-nudge.ts +6 -0
  186. package/lib/prompts/store.ts +452 -0
  187. package/lib/prompts/system.ts +41 -0
  188. package/lib/prompts/turn-nudge.ts +10 -0
  189. package/lib/protected-patterns.ts +159 -0
  190. package/lib/state/index.ts +4 -0
  191. package/lib/state/persistence.ts +321 -0
  192. package/lib/state/state.ts +214 -0
  193. package/lib/state/tool-cache.ts +99 -0
  194. package/lib/state/types.ts +117 -0
  195. package/lib/state/utils.ts +345 -0
  196. package/lib/strategies/deduplication.ts +125 -0
  197. package/lib/strategies/index.ts +2 -0
  198. package/lib/strategies/purge-errors.ts +86 -0
  199. package/lib/subagents/subagent-results.ts +75 -0
  200. package/lib/token-utils.ts +164 -0
  201. package/lib/tui/dialogs.tsx +261 -0
  202. package/lib/tui/format.ts +25 -0
  203. package/lib/tui/types.ts +25 -0
  204. package/lib/tui/ui.tsx +245 -0
  205. package/lib/ui/notification.ts +347 -0
  206. package/lib/ui/utils.ts +304 -0
  207. package/lib/v2/index.ts +448 -0
  208. package/lib/v2/messages.ts +252 -0
  209. package/lib/v2/rpc.ts +60 -0
  210. package/lib/v2/theme.ts +67 -0
  211. package/lib/v2/tui.tsx +115 -0
  212. package/lib/v2/usage.ts +423 -0
  213. package/package.json +91 -5
  214. package/server.js +1 -0
  215. package/tui.tsx +7 -0
@@ -0,0 +1,320 @@
1
+ import type { WithParts } from "../state"
2
+ import { formatBlockRef, formatMessageRef, parseBoundaryId, type IdFormat } from "../message-ids"
3
+
4
+ /**
5
+ * Marker embedded in the hidden summary prompt so the plugin can recognize its
6
+ * own transient (`session.generate`) request and strip everything else from it.
7
+ * Requests carry a per-call suffix (`[[DCP-RC-SUMMARY:<callId>]]`) so captured
8
+ * provider usage can be attributed to a specific request.
9
+ */
10
+ export const RC_SUMMARY_MARKER = "[[DCP-RC-SUMMARY]]"
11
+ /** Prefix shared by the static marker and its per-call variant. */
12
+ export const RC_SUMMARY_MARKER_PREFIX = "[[DCP-RC-SUMMARY"
13
+
14
+ function sanitizeCallId(callId: string): string {
15
+ return callId.replace(/[^A-Za-z0-9_-]/g, "-")
16
+ }
17
+
18
+ /** Marker line for a hidden summary request, optionally tagged with a call id. */
19
+ export function buildSummaryMarker(callId?: string): string {
20
+ if (!callId) {
21
+ return RC_SUMMARY_MARKER
22
+ }
23
+ return `${RC_SUMMARY_MARKER_PREFIX}:${sanitizeCallId(callId)}]]`
24
+ }
25
+
26
+ /** True when the text contains a hidden summary marker, with or without a call id. */
27
+ export function hasSummaryMarker(text: string): boolean {
28
+ return text.includes(RC_SUMMARY_MARKER_PREFIX)
29
+ }
30
+
31
+ /** Extract the call id from a hidden summary marker, when present. */
32
+ export function extractSummaryCallId(text: string): string | undefined {
33
+ return text.match(/\[\[DCP-RC-SUMMARY:([A-Za-z0-9_-]+)\]\]/)?.[1]
34
+ }
35
+
36
+ export interface PriorSummary {
37
+ /** Canonical block reference, e.g. `b2` or `@b2@`. */
38
+ ref: string
39
+ /** Summary body with the block header/footer stripped. */
40
+ body: string
41
+ }
42
+
43
+ export interface SummarySegment {
44
+ /** Canonical selector, e.g. `m0004`, `m0004-m0008`, or `b2-m0011`. */
45
+ selector: string
46
+ messages: WithParts[]
47
+ /** Summaries of active blocks consumed by this selection. */
48
+ priorSummaries?: PriorSummary[]
49
+ }
50
+
51
+ export interface SummarySection {
52
+ selector: string
53
+ summary: string
54
+ }
55
+
56
+ export interface ParsedSummarySections {
57
+ sections: SummarySection[]
58
+ missing: string[]
59
+ duplicates: string[]
60
+ empty: string[]
61
+ }
62
+
63
+ function stringify(value: unknown): string {
64
+ if (typeof value === "string") {
65
+ return value
66
+ }
67
+ try {
68
+ return JSON.stringify(value, null, 2)
69
+ } catch {
70
+ return String(value)
71
+ }
72
+ }
73
+
74
+ function readToolState(part: any): {
75
+ status?: string
76
+ input?: unknown
77
+ output?: unknown
78
+ error?: unknown
79
+ } {
80
+ const state = part?.state
81
+ if (!state || typeof state !== "object") {
82
+ return {}
83
+ }
84
+ return {
85
+ status: typeof state.status === "string" ? state.status : undefined,
86
+ input: state.input,
87
+ output: state.output,
88
+ error: state.error,
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Serialize one conversation message into plain text for the hidden summary
94
+ * request. Binary/file payloads are intentionally omitted.
95
+ */
96
+ export function serializeMessageForSummary(message: WithParts, selector: string): string {
97
+ const role = message.info?.role ?? "unknown"
98
+ const lines: string[] = []
99
+ const parts = Array.isArray(message.parts) ? message.parts : []
100
+
101
+ for (const part of parts as any[]) {
102
+ if (!part || typeof part !== "object") {
103
+ continue
104
+ }
105
+ switch (part.type) {
106
+ case "text":
107
+ if (typeof part.text === "string" && part.text.length > 0) {
108
+ lines.push(part.text)
109
+ }
110
+ break
111
+ case "reasoning":
112
+ if (typeof part.text === "string" && part.text.length > 0) {
113
+ lines.push(`[reasoning] ${part.text}`)
114
+ }
115
+ break
116
+ case "tool": {
117
+ const state = readToolState(part)
118
+ const name = part.tool ?? "tool"
119
+ lines.push(`[tool_call ${name}] ${stringify(state.input)}`)
120
+ if (state.status === "completed") {
121
+ lines.push(`[tool_result ${name}] ${stringify(state.output)}`)
122
+ } else if (state.status === "error") {
123
+ lines.push(`[tool_error ${name}] ${stringify(state.error)}`)
124
+ } else if (state.status) {
125
+ lines.push(`[tool_${state.status} ${name}]`)
126
+ }
127
+ break
128
+ }
129
+ case "file":
130
+ lines.push(`[file ${part.mime ?? ""} ${part.filename ?? part.url ?? ""}]`.trim())
131
+ break
132
+ case "patch":
133
+ lines.push(`[patch] ${stringify(part.hash ?? part.files ?? part)}`)
134
+ break
135
+ case "agent":
136
+ lines.push(`[agent ${part.name ?? ""}]`.trim())
137
+ break
138
+ default:
139
+ break
140
+ }
141
+ }
142
+
143
+ const body = lines.join("\n").trim()
144
+ return `### ${selector} (${role})\n${body.length > 0 ? body : "(empty message)"}`
145
+ }
146
+
147
+ const SUMMARY_RULES = `RULES
148
+ - Reply with one section per selector, in the same order as listed. Nothing else: no introduction, no code fences, no tool calls, no trailing prose.
149
+ - Each section starts with a header line exactly \`SLEEV-SUMMARY <selector>\` using the selector as listed.
150
+ - The summary body continues until the next header or the end of the reply.
151
+ - When a segment lists previous compressed summaries, merge their information into your summary: those blocks are replaced by the section you produce.
152
+ - Preserve every high-signal detail: user intent (quote short user messages exactly), decisions, constraints, file paths, function and symbol names, commands run, key findings, errors, verification results, and any subagent task IDs with their scope and completion state.
153
+ - Drop noise: failed attempts that led nowhere, repeated tool output, exploration that produced no durable result.
154
+ - Be dense and precise. Prefer terse technical wording over narrative.`
155
+
156
+ /**
157
+ * Build the hidden summarization prompt. The selected segments are embedded
158
+ * verbatim so the transient request does not depend on any injected metadata.
159
+ */
160
+ export function buildSummaryPrompt(
161
+ selectors: SummarySegment[],
162
+ idFormat: IdFormat = "xml",
163
+ callId?: string,
164
+ ): string {
165
+ const selectorList = selectors.map((segment) => segment.selector)
166
+ const example = selectorList[0] ?? (idFormat === "compact" ? "" : "m0004")
167
+ const body = selectors
168
+ .map((segment) => {
169
+ const messageBody = segment.messages
170
+ .map((message) => serializeMessageForSummary(message, segment.selector))
171
+ .join("\n\n")
172
+ const priorBody = (segment.priorSummaries ?? [])
173
+ .map(
174
+ (prior) =>
175
+ `### ${prior.ref} (previous compressed summary)\n${prior.body.trim()}`,
176
+ )
177
+ .join("\n\n")
178
+ return [messageBody, priorBody].filter((part) => part.length > 0).join("\n\n")
179
+ })
180
+ .join("\n\n")
181
+
182
+ return `${buildSummaryMarker(callId)}
183
+
184
+ You generate compressed-context entries for a coding session. Summarize ONLY the conversation segments listed below. Do not summarize anything else and do not ask questions.
185
+
186
+ SUMMARY FORMAT
187
+ SLEEV-SUMMARY ${example}
188
+ <summary body>
189
+
190
+ ${SUMMARY_RULES.replace("one section per selector", `${selectorList.length} section(s), one per selector`)}
191
+ - The selectors for this request are exactly: ${selectorList.map((selector) => `\`${selector}\``).join(", ")}
192
+ RC-SELECTORS: ${selectorList.join(", ")}
193
+
194
+ SEGMENTS
195
+ ${body}
196
+ `
197
+ }
198
+
199
+ function selectorPattern(): string {
200
+ const boundary = "(?:m[0-9]{4}|@[0-9]+@|b[1-9][0-9]*|@b[1-9][0-9]*@)"
201
+ return `(?:${boundary})(?:-${boundary})?`
202
+ }
203
+
204
+ /**
205
+ * Normalize a selector written in either ID format (`m0004` or ``) into the
206
+ * session's canonical format. Returns null when the selector is malformed.
207
+ */
208
+ export function canonicalizeSelector(selector: string, format: IdFormat = "xml"): string | null {
209
+ const parts = selector.trim().toLowerCase().split("-")
210
+ if (parts.length < 1 || parts.length > 2) {
211
+ return null
212
+ }
213
+ const parse = (part: string) => parseBoundaryId(part, "xml") ?? parseBoundaryId(part, "compact")
214
+ const toRef = (parsed: NonNullable<ReturnType<typeof parse>>): string =>
215
+ parsed.kind === "message"
216
+ ? formatMessageRef(parsed.index, format)
217
+ : formatBlockRef(parsed.blockId, format)
218
+
219
+ const start = parse(parts[0]!)
220
+ const end = parts.length === 2 ? parse(parts[1]!) : start
221
+ if (!start || !end) {
222
+ return null
223
+ }
224
+ if (start.kind === "message" && end.kind === "message" && start.index > end.index) {
225
+ return null
226
+ }
227
+ const startRef = toRef(start)
228
+ const endRef = toRef(end)
229
+ return startRef === endRef ? startRef : `${startRef}-${endRef}`
230
+ }
231
+
232
+ /**
233
+ * Strip the `[Compressed conversation section]` header and trailing block tag
234
+ * from a stored summary so it can be embedded in a new summary prompt.
235
+ */
236
+ export function restoreSummary(summary: string): string {
237
+ const headerMatch = summary.match(
238
+ /^[ \t]*\[Compressed conversation(?: section)?(?: b[0-9]+)?\]/i,
239
+ )
240
+ if (!headerMatch) {
241
+ return summary
242
+ }
243
+
244
+ const body = summary.slice(headerMatch[0].length).trimStart()
245
+ const lines = body.split("\n")
246
+ while (lines.length > 0 && (lines[lines.length - 1] ?? "").trim() === "") {
247
+ lines.pop()
248
+ }
249
+ const last = (lines[lines.length - 1] ?? "").trim()
250
+ const isXmlTag = new RegExp("^<dcp-message-id>b[0-9]+</dcp-message-id>$").test(last)
251
+ const isCompactTag = /^@b[1-9][0-9]*@$/.test(last)
252
+ if (isXmlTag || isCompactTag) {
253
+ lines.pop()
254
+ }
255
+ return lines.join("\n").trimEnd()
256
+ }
257
+
258
+ /**
259
+ * Parse `SLEEV-SUMMARY <selector>` sections from a model reply. Returns the
260
+ * accepted sections plus the selectors that are missing, duplicated, or have
261
+ * an empty body so the caller can retry only what is needed. Selectors are
262
+ * accepted in either ID format and normalized to the session format.
263
+ */
264
+ export function parseSummarySections(
265
+ text: string,
266
+ expected: string[],
267
+ idFormat: IdFormat = "xml",
268
+ ): ParsedSummarySections {
269
+ const header = new RegExp(`^SLEEV-SUMMARY (${selectorPattern()})[ \t]*$`, "gmi")
270
+ const matches: Array<{ selector: string; start: number; end: number }> = []
271
+ let match: RegExpExecArray | null
272
+
273
+ while ((match = header.exec(text)) !== null) {
274
+ const selector = canonicalizeSelector((match[1] ?? "").trim(), idFormat)
275
+ if (!selector) {
276
+ continue
277
+ }
278
+ matches.push({
279
+ selector,
280
+ start: match.index,
281
+ end: match.index + match[0].length,
282
+ })
283
+ }
284
+
285
+ const normalizedExpected = expected
286
+ .map((selector) => canonicalizeSelector(selector, idFormat))
287
+ .filter((selector): selector is string => Boolean(selector))
288
+ const expectedSet = new Set(normalizedExpected)
289
+ const sections: SummarySection[] = []
290
+ const seen = new Set<string>()
291
+ const duplicates: string[] = []
292
+ const empty: string[] = []
293
+
294
+ for (let index = 0; index < matches.length; index++) {
295
+ const current = matches[index]!
296
+ if (!expectedSet.has(current.selector)) {
297
+ continue
298
+ }
299
+ if (seen.has(current.selector)) {
300
+ if (!duplicates.includes(current.selector)) {
301
+ duplicates.push(current.selector)
302
+ }
303
+ continue
304
+ }
305
+ seen.add(current.selector)
306
+
307
+ const next = matches[index + 1]
308
+ const body = text.slice(current.end, next ? next.start : text.length).trim()
309
+ if (body.length === 0) {
310
+ empty.push(current.selector)
311
+ continue
312
+ }
313
+ sections.push({ selector: current.selector, summary: body })
314
+ }
315
+
316
+ const missing = normalizedExpected.filter(
317
+ (selector) => !seen.has(selector) && !empty.includes(selector),
318
+ )
319
+ return { sections, missing, duplicates, empty }
320
+ }
@@ -0,0 +1,77 @@
1
+ import type { SessionState } from "../state/types"
2
+ import { attachCompressionDuration } from "./state"
3
+
4
+ export interface PendingCompressionDuration {
5
+ messageId: string
6
+ callId: string
7
+ durationMs: number
8
+ }
9
+
10
+ export interface CompressionTimingState {
11
+ startsByCallId: Map<string, number>
12
+ pendingByCallId: Map<string, PendingCompressionDuration>
13
+ }
14
+
15
+ export function buildCompressionTimingKey(messageId: string, callId: string): string {
16
+ return `${messageId}:${callId}`
17
+ }
18
+
19
+ export function consumeCompressionStart(
20
+ state: SessionState,
21
+ messageId: string,
22
+ callId: string,
23
+ ): number | undefined {
24
+ const key = buildCompressionTimingKey(messageId, callId)
25
+ const start = state.compressionTiming.startsByCallId.get(key)
26
+ state.compressionTiming.startsByCallId.delete(key)
27
+ return start
28
+ }
29
+
30
+ export function resolveCompressionDuration(
31
+ startedAt: number | undefined,
32
+ eventTime: number | undefined,
33
+ partTime: { start?: unknown; end?: unknown } | undefined,
34
+ ): number | undefined {
35
+ const runningAt =
36
+ typeof partTime?.start === "number" && Number.isFinite(partTime.start)
37
+ ? partTime.start
38
+ : eventTime
39
+ const pendingToRunningMs =
40
+ typeof startedAt === "number" && typeof runningAt === "number"
41
+ ? Math.max(0, runningAt - startedAt)
42
+ : undefined
43
+
44
+ const toolStart = partTime?.start
45
+ const toolEnd = partTime?.end
46
+ const runtimeMs =
47
+ typeof toolStart === "number" &&
48
+ Number.isFinite(toolStart) &&
49
+ typeof toolEnd === "number" &&
50
+ Number.isFinite(toolEnd)
51
+ ? Math.max(0, toolEnd - toolStart)
52
+ : undefined
53
+
54
+ return typeof pendingToRunningMs === "number" ? pendingToRunningMs : runtimeMs
55
+ }
56
+
57
+ export function applyPendingCompressionDurations(state: SessionState): number {
58
+ if (state.compressionTiming.pendingByCallId.size === 0) {
59
+ return 0
60
+ }
61
+
62
+ let updates = 0
63
+ for (const [key, entry] of state.compressionTiming.pendingByCallId) {
64
+ const applied = attachCompressionDuration(
65
+ state.prune.messages,
66
+ entry.messageId,
67
+ entry.callId,
68
+ entry.durationMs,
69
+ )
70
+ if (applied > 0) {
71
+ updates += applied
72
+ state.compressionTiming.pendingByCallId.delete(key)
73
+ }
74
+ }
75
+
76
+ return updates
77
+ }
@@ -0,0 +1,70 @@
1
+ import type { PluginConfig } from "../config"
2
+ import type { Logger } from "../logger"
3
+ import type { PromptStore } from "../prompts/store"
4
+ import type { CompressionBlock, CompressionMode, SessionState, WithParts } from "../state"
5
+ import type { CompressionUsage } from "./usage"
6
+
7
+ /** Result of the hidden transient model call (OpenCode V2 `session.generate`). */
8
+ export interface GenerateSummaryResult {
9
+ text: string
10
+ /** Provider usage when captured, otherwise a local estimate. */
11
+ usage?: CompressionUsage
12
+ }
13
+
14
+ export interface ToolContext {
15
+ client: any
16
+ state: SessionState
17
+ logger: Logger
18
+ config: PluginConfig
19
+ prompts: PromptStore
20
+ /** Hidden transient model call (OpenCode V2 `session.generate`). */
21
+ generate: (input: {
22
+ sessionID: string
23
+ prompt: string
24
+ /** Per-request id embedded in the prompt marker for usage attribution. */
25
+ callId?: string
26
+ }) => Promise<GenerateSummaryResult>
27
+ }
28
+
29
+ export interface BoundaryReference {
30
+ kind: "message" | "compressed-block"
31
+ rawIndex: number
32
+ messageId?: string
33
+ blockId?: number
34
+ anchorMessageId?: string
35
+ }
36
+
37
+ export interface SearchContext {
38
+ rawMessages: WithParts[]
39
+ rawMessagesById: Map<string, WithParts>
40
+ rawIndexById: Map<string, number>
41
+ summaryByBlockId: Map<number, CompressionBlock>
42
+ }
43
+
44
+ export interface SelectionResolution {
45
+ startReference: BoundaryReference
46
+ endReference: BoundaryReference
47
+ messageIds: string[]
48
+ messageTokenById: Map<string, number>
49
+ toolIds: string[]
50
+ requiredBlockIds: number[]
51
+ }
52
+
53
+ export interface AppliedCompressionResult {
54
+ compressedTokens: number
55
+ messageIds: string[]
56
+ newlyCompressedMessageIds: string[]
57
+ newlyCompressedToolIds: string[]
58
+ }
59
+
60
+ export interface CompressionStateInput {
61
+ topic: string
62
+ batchTopic: string
63
+ startId: string
64
+ endId: string
65
+ mode: CompressionMode
66
+ runId: number
67
+ compressMessageId: string
68
+ compressCallId?: string
69
+ summaryTokens: number
70
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Usage accounting for the hidden compression (summary) request.
3
+ *
4
+ * Provider-reported usage is preferred: OpenCode V2 generates the summary
5
+ * through `session.generate`, which does not surface token usage to the
6
+ * caller, so the numbers are captured by wrapping the resolved language
7
+ * model (`ctx.aisdk.hook("language")`) and reading the `finish` usage.
8
+ * When that path is unavailable, local tokenizer estimates are recorded
9
+ * instead so the plugin can still report an approximate cost.
10
+ */
11
+
12
+ export type CompressionUsageSource = "provider" | "estimated"
13
+
14
+ /** Usage of a single hidden compression request. */
15
+ export interface CompressionUsage {
16
+ inputTokens: number
17
+ outputTokens: number
18
+ cacheReadTokens: number
19
+ cacheWriteTokens: number
20
+ reasoningTokens: number
21
+ source: CompressionUsageSource
22
+ }
23
+
24
+ /** Aggregated usage of all hidden compression requests in a session. */
25
+ export interface CompressionUsageTotals {
26
+ calls: number
27
+ providerCalls: number
28
+ estimatedCalls: number
29
+ inputTokens: number
30
+ outputTokens: number
31
+ cacheReadTokens: number
32
+ cacheWriteTokens: number
33
+ reasoningTokens: number
34
+ }
35
+
36
+ export function emptyCompressionUsage(): CompressionUsageTotals {
37
+ return {
38
+ calls: 0,
39
+ providerCalls: 0,
40
+ estimatedCalls: 0,
41
+ inputTokens: 0,
42
+ outputTokens: 0,
43
+ cacheReadTokens: 0,
44
+ cacheWriteTokens: 0,
45
+ reasoningTokens: 0,
46
+ }
47
+ }
48
+
49
+ function toCount(value: unknown, fallback = 0): number {
50
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
51
+ return fallback
52
+ }
53
+ return Math.round(value)
54
+ }
55
+
56
+ /**
57
+ * Normalize AI SDK v3 usage (`{ inputTokens: { total, noCache, cacheRead,
58
+ * cacheWrite }, outputTokens: { total, text, reasoning } }`) into the plugin
59
+ * shape. Returns undefined when there is nothing usable to record.
60
+ */
61
+ export function normalizeProviderUsage(raw: unknown): CompressionUsage | undefined {
62
+ if (!raw || typeof raw !== "object") {
63
+ return undefined
64
+ }
65
+
66
+ const value = raw as { inputTokens?: unknown; outputTokens?: unknown }
67
+ const input =
68
+ value.inputTokens && typeof value.inputTokens === "object"
69
+ ? (value.inputTokens as Record<string, unknown>)
70
+ : {}
71
+ const output =
72
+ value.outputTokens && typeof value.outputTokens === "object"
73
+ ? (value.outputTokens as Record<string, unknown>)
74
+ : {}
75
+
76
+ const cacheRead = toCount(input.cacheRead)
77
+ const cacheWrite = toCount(input.cacheWrite)
78
+ const uncached = toCount(input.noCache)
79
+ const inputFromParts = uncached + cacheRead + cacheWrite
80
+ const inputTotal = Math.max(toCount(input.total, inputFromParts), inputFromParts)
81
+
82
+ const reasoning = toCount(output.reasoning)
83
+ const outputTotal = Math.max(toCount(output.total, toCount(output.text) + reasoning), reasoning)
84
+
85
+ if (
86
+ inputTotal === 0 &&
87
+ outputTotal === 0 &&
88
+ cacheRead === 0 &&
89
+ cacheWrite === 0 &&
90
+ reasoning === 0
91
+ ) {
92
+ return undefined
93
+ }
94
+
95
+ return {
96
+ inputTokens: inputTotal,
97
+ outputTokens: outputTotal,
98
+ cacheReadTokens: cacheRead,
99
+ cacheWriteTokens: cacheWrite,
100
+ reasoningTokens: reasoning,
101
+ source: "provider",
102
+ }
103
+ }
104
+
105
+ /** Local fallback when the provider did not report usage. */
106
+ export function estimatedCompressionUsage(
107
+ inputTokens: number,
108
+ outputTokens: number,
109
+ ): CompressionUsage {
110
+ return {
111
+ inputTokens: toCount(inputTokens),
112
+ outputTokens: toCount(outputTokens),
113
+ cacheReadTokens: 0,
114
+ cacheWriteTokens: 0,
115
+ reasoningTokens: 0,
116
+ source: "estimated",
117
+ }
118
+ }
119
+
120
+ export function addCompressionUsage(
121
+ totals: CompressionUsageTotals,
122
+ usage: CompressionUsage,
123
+ ): CompressionUsageTotals {
124
+ return {
125
+ calls: totals.calls + 1,
126
+ providerCalls: totals.providerCalls + (usage.source === "provider" ? 1 : 0),
127
+ estimatedCalls: totals.estimatedCalls + (usage.source === "estimated" ? 1 : 0),
128
+ inputTokens: totals.inputTokens + usage.inputTokens,
129
+ outputTokens: totals.outputTokens + usage.outputTokens,
130
+ cacheReadTokens: totals.cacheReadTokens + usage.cacheReadTokens,
131
+ cacheWriteTokens: totals.cacheWriteTokens + usage.cacheWriteTokens,
132
+ reasoningTokens: totals.reasoningTokens + usage.reasoningTokens,
133
+ }
134
+ }
135
+
136
+ export function addCompressionUsageTotals(
137
+ totals: CompressionUsageTotals,
138
+ other: CompressionUsageTotals,
139
+ ): CompressionUsageTotals {
140
+ return {
141
+ calls: totals.calls + other.calls,
142
+ providerCalls: totals.providerCalls + other.providerCalls,
143
+ estimatedCalls: totals.estimatedCalls + other.estimatedCalls,
144
+ inputTokens: totals.inputTokens + other.inputTokens,
145
+ outputTokens: totals.outputTokens + other.outputTokens,
146
+ cacheReadTokens: totals.cacheReadTokens + other.cacheReadTokens,
147
+ cacheWriteTokens: totals.cacheWriteTokens + other.cacheWriteTokens,
148
+ reasoningTokens: totals.reasoningTokens + other.reasoningTokens,
149
+ }
150
+ }
151
+
152
+ /** Backward-compatible load of persisted totals. */
153
+ export function normalizeCompressionUsageTotals(value: unknown): CompressionUsageTotals {
154
+ if (!value || typeof value !== "object") {
155
+ return emptyCompressionUsage()
156
+ }
157
+ const raw = value as Record<string, unknown>
158
+ return {
159
+ calls: toCount(raw.calls),
160
+ providerCalls: toCount(raw.providerCalls),
161
+ estimatedCalls: toCount(raw.estimatedCalls),
162
+ inputTokens: toCount(raw.inputTokens),
163
+ outputTokens: toCount(raw.outputTokens),
164
+ cacheReadTokens: toCount(raw.cacheReadTokens),
165
+ cacheWriteTokens: toCount(raw.cacheWriteTokens),
166
+ reasoningTokens: toCount(raw.reasoningTokens),
167
+ }
168
+ }
@@ -0,0 +1,9 @@
1
+ import type { PluginConfig } from "./config"
2
+ import type { SessionState } from "./state"
3
+
4
+ export const compressPermission = (
5
+ state: SessionState,
6
+ config: PluginConfig,
7
+ ): "ask" | "allow" | "deny" => {
8
+ return state.compressPermission ?? config.compress.permission
9
+ }