@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,321 @@
1
+ /**
2
+ * State persistence module for DCP plugin.
3
+ * Persists pruned tool IDs across sessions so they survive OpenCode restarts.
4
+ * Storage location: ~/.local/share/opencode/storage/plugin/dcp/{sessionId}.json
5
+ */
6
+
7
+ import * as fs from "fs/promises"
8
+ import { existsSync } from "fs"
9
+ import { homedir } from "os"
10
+ import { join } from "path"
11
+ import type { CompressionBlock, PrunedMessageEntry, SessionState, SessionStats } from "./types"
12
+ import type { Logger } from "../logger"
13
+ import {
14
+ addCompressionUsageTotals,
15
+ emptyCompressionUsage,
16
+ normalizeCompressionUsageTotals,
17
+ type CompressionUsageTotals,
18
+ } from "../compress/usage"
19
+ import { serializePruneMessagesState } from "./utils"
20
+
21
+ /** Prune state as stored on disk */
22
+ export interface PersistedPruneMessagesState {
23
+ byMessageId: Record<string, PrunedMessageEntry>
24
+ blocksById: Record<string, CompressionBlock>
25
+ activeBlockIds: number[]
26
+ activeByAnchorMessageId: Record<string, number>
27
+ nextBlockId: number
28
+ nextRunId: number
29
+ }
30
+
31
+ export interface PersistedPrune {
32
+ tools?: Record<string, number>
33
+ messages?: PersistedPruneMessagesState
34
+ }
35
+
36
+ export interface PersistedNudges {
37
+ contextLimitAnchors: string[]
38
+ turnNudgeAnchors?: string[]
39
+ iterationNudgeAnchors?: string[]
40
+ }
41
+
42
+ export interface PersistedSessionState {
43
+ sessionName?: string
44
+ manualMode?: boolean
45
+ prune: PersistedPrune
46
+ nudges: PersistedNudges
47
+ stats: SessionStats
48
+ lastUpdated: string
49
+ }
50
+
51
+ const STORAGE_DIR = join(
52
+ process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"),
53
+ "opencode",
54
+ "storage",
55
+ "plugin",
56
+ "dcp",
57
+ )
58
+
59
+ async function ensureStorageDir(): Promise<void> {
60
+ if (!existsSync(STORAGE_DIR)) {
61
+ await fs.mkdir(STORAGE_DIR, { recursive: true })
62
+ }
63
+ }
64
+
65
+ function getSessionFilePath(sessionId: string): string {
66
+ return join(STORAGE_DIR, `${sessionId}.json`)
67
+ }
68
+
69
+ async function writePersistedSessionState(
70
+ sessionId: string,
71
+ state: PersistedSessionState,
72
+ logger: Logger,
73
+ ): Promise<void> {
74
+ await ensureStorageDir()
75
+
76
+ const filePath = getSessionFilePath(sessionId)
77
+ const content = JSON.stringify(state, null, 2)
78
+ await fs.writeFile(filePath, content, "utf-8")
79
+
80
+ logger.info("Saved session state to disk", {
81
+ sessionId,
82
+ totalTokensSaved: state.stats.totalPruneTokens,
83
+ })
84
+ }
85
+
86
+ export async function saveSessionState(
87
+ sessionState: SessionState,
88
+ logger: Logger,
89
+ sessionName?: string,
90
+ ): Promise<void> {
91
+ try {
92
+ if (!sessionState.sessionId) {
93
+ return
94
+ }
95
+
96
+ const state: PersistedSessionState = {
97
+ sessionName: sessionName,
98
+ manualMode: !!sessionState.manualMode,
99
+ prune: {
100
+ tools: Object.fromEntries(sessionState.prune.tools),
101
+ messages: serializePruneMessagesState(sessionState.prune.messages),
102
+ },
103
+ nudges: {
104
+ contextLimitAnchors: Array.from(sessionState.nudges.contextLimitAnchors),
105
+ turnNudgeAnchors: Array.from(sessionState.nudges.turnNudgeAnchors),
106
+ iterationNudgeAnchors: Array.from(sessionState.nudges.iterationNudgeAnchors),
107
+ },
108
+ stats: sessionState.stats,
109
+ lastUpdated: new Date().toISOString(),
110
+ }
111
+
112
+ await writePersistedSessionState(sessionState.sessionId, state, logger)
113
+ } catch (error: any) {
114
+ logger.error("Failed to save session state", {
115
+ sessionId: sessionState.sessionId,
116
+ error: error?.message,
117
+ })
118
+ }
119
+ }
120
+
121
+ export async function loadSessionState(
122
+ sessionId: string,
123
+ logger: Logger,
124
+ ): Promise<PersistedSessionState | null> {
125
+ try {
126
+ const filePath = getSessionFilePath(sessionId)
127
+
128
+ if (!existsSync(filePath)) {
129
+ return null
130
+ }
131
+
132
+ const content = await fs.readFile(filePath, "utf-8")
133
+ const state = JSON.parse(content) as PersistedSessionState
134
+
135
+ const hasPruneTools = state?.prune?.tools && typeof state.prune.tools === "object"
136
+ const hasPruneMessages = state?.prune?.messages && typeof state.prune.messages === "object"
137
+ const hasNudgeFormat = state?.nudges && typeof state.nudges === "object"
138
+ if (
139
+ !state ||
140
+ !state.prune ||
141
+ !hasPruneTools ||
142
+ !hasPruneMessages ||
143
+ !state.stats ||
144
+ !hasNudgeFormat
145
+ ) {
146
+ logger.warn("Invalid session state file, ignoring", {
147
+ sessionId: sessionId,
148
+ })
149
+ return null
150
+ }
151
+
152
+ const rawContextLimitAnchors = Array.isArray(state.nudges.contextLimitAnchors)
153
+ ? state.nudges.contextLimitAnchors
154
+ : []
155
+ const validAnchors = rawContextLimitAnchors.filter(
156
+ (entry): entry is string => typeof entry === "string",
157
+ )
158
+ const dedupedAnchors = [...new Set(validAnchors)]
159
+ if (validAnchors.length !== rawContextLimitAnchors.length) {
160
+ logger.warn("Filtered out malformed contextLimitAnchors entries", {
161
+ sessionId: sessionId,
162
+ original: rawContextLimitAnchors.length,
163
+ valid: validAnchors.length,
164
+ })
165
+ }
166
+ state.nudges.contextLimitAnchors = dedupedAnchors
167
+
168
+ const rawTurnNudgeAnchors = Array.isArray(state.nudges.turnNudgeAnchors)
169
+ ? state.nudges.turnNudgeAnchors
170
+ : []
171
+ const validSoftAnchors = rawTurnNudgeAnchors.filter(
172
+ (entry): entry is string => typeof entry === "string",
173
+ )
174
+ const dedupedSoftAnchors = [...new Set(validSoftAnchors)]
175
+ if (validSoftAnchors.length !== rawTurnNudgeAnchors.length) {
176
+ logger.warn("Filtered out malformed turnNudgeAnchors entries", {
177
+ sessionId: sessionId,
178
+ original: rawTurnNudgeAnchors.length,
179
+ valid: validSoftAnchors.length,
180
+ })
181
+ }
182
+ state.nudges.turnNudgeAnchors = dedupedSoftAnchors
183
+
184
+ const rawIterationNudgeAnchors = Array.isArray(state.nudges.iterationNudgeAnchors)
185
+ ? state.nudges.iterationNudgeAnchors
186
+ : []
187
+ const validIterationAnchors = rawIterationNudgeAnchors.filter(
188
+ (entry): entry is string => typeof entry === "string",
189
+ )
190
+ const dedupedIterationAnchors = [...new Set(validIterationAnchors)]
191
+ if (validIterationAnchors.length !== rawIterationNudgeAnchors.length) {
192
+ logger.warn("Filtered out malformed iterationNudgeAnchors entries", {
193
+ sessionId: sessionId,
194
+ original: rawIterationNudgeAnchors.length,
195
+ valid: validIterationAnchors.length,
196
+ })
197
+ }
198
+ state.nudges.iterationNudgeAnchors = dedupedIterationAnchors
199
+
200
+ logger.info("Loaded session state from disk", {
201
+ sessionId: sessionId,
202
+ })
203
+
204
+ return state
205
+ } catch (error: any) {
206
+ logger.warn("Failed to load session state", {
207
+ sessionId: sessionId,
208
+ error: error?.message,
209
+ })
210
+ return null
211
+ }
212
+ }
213
+
214
+ function emptyPersistedState(manualMode: boolean): PersistedSessionState {
215
+ return {
216
+ manualMode,
217
+ prune: {
218
+ tools: {},
219
+ messages: {
220
+ byMessageId: {},
221
+ blocksById: {},
222
+ activeBlockIds: [],
223
+ activeByAnchorMessageId: {},
224
+ nextBlockId: 1,
225
+ nextRunId: 1,
226
+ },
227
+ },
228
+ nudges: {
229
+ contextLimitAnchors: [],
230
+ turnNudgeAnchors: [],
231
+ iterationNudgeAnchors: [],
232
+ },
233
+ stats: {
234
+ pruneTokenCounter: 0,
235
+ totalPruneTokens: 0,
236
+ compressionUsage: emptyCompressionUsage(),
237
+ },
238
+ lastUpdated: new Date().toISOString(),
239
+ }
240
+ }
241
+
242
+ export async function loadManualModeSetting(
243
+ sessionId: string,
244
+ logger: Logger,
245
+ ): Promise<boolean | undefined> {
246
+ const state = await loadSessionState(sessionId, logger)
247
+ return typeof state?.manualMode === "boolean" ? state.manualMode : undefined
248
+ }
249
+
250
+ export async function saveManualModeSetting(
251
+ sessionId: string,
252
+ manualMode: boolean,
253
+ logger: Logger,
254
+ ): Promise<void> {
255
+ const existing = await loadSessionState(sessionId, logger)
256
+ const state = existing ?? emptyPersistedState(manualMode)
257
+ state.manualMode = manualMode
258
+ state.lastUpdated = new Date().toISOString()
259
+ await writePersistedSessionState(sessionId, state, logger)
260
+ }
261
+
262
+ export interface AggregatedStats {
263
+ totalTokens: number
264
+ totalTools: number
265
+ totalMessages: number
266
+ sessionCount: number
267
+ compressionUsage: CompressionUsageTotals
268
+ }
269
+
270
+ export async function loadAllSessionStats(logger: Logger): Promise<AggregatedStats> {
271
+ const result: AggregatedStats = {
272
+ totalTokens: 0,
273
+ totalTools: 0,
274
+ totalMessages: 0,
275
+ sessionCount: 0,
276
+ compressionUsage: emptyCompressionUsage(),
277
+ }
278
+
279
+ try {
280
+ if (!existsSync(STORAGE_DIR)) {
281
+ return result
282
+ }
283
+
284
+ const files = await fs.readdir(STORAGE_DIR)
285
+ const jsonFiles = files.filter((f) => f.endsWith(".json"))
286
+
287
+ for (const file of jsonFiles) {
288
+ try {
289
+ const filePath = join(STORAGE_DIR, file)
290
+ const content = await fs.readFile(filePath, "utf-8")
291
+ const state = JSON.parse(content) as PersistedSessionState
292
+
293
+ const usage = normalizeCompressionUsageTotals(state?.stats?.compressionUsage)
294
+ const hasUsage = usage.calls > 0
295
+
296
+ if (state?.prune && (state?.stats?.totalPruneTokens || hasUsage)) {
297
+ result.totalTokens += state.stats?.totalPruneTokens || 0
298
+ result.totalTools += state.prune.tools
299
+ ? Object.keys(state.prune.tools).length
300
+ : 0
301
+ result.totalMessages += state.prune.messages?.byMessageId
302
+ ? Object.keys(state.prune.messages.byMessageId).length
303
+ : 0
304
+ result.compressionUsage = addCompressionUsageTotals(
305
+ result.compressionUsage,
306
+ usage,
307
+ )
308
+ result.sessionCount++
309
+ }
310
+ } catch {
311
+ // Skip invalid files
312
+ }
313
+ }
314
+
315
+ logger.debug("Loaded all-time stats", result)
316
+ } catch (error: any) {
317
+ logger.warn("Failed to load all-time stats", { error: error?.message })
318
+ }
319
+
320
+ return result
321
+ }
@@ -0,0 +1,214 @@
1
+ import type { SessionState, ToolParameterEntry, WithParts } from "./types"
2
+ import type { Logger } from "../logger"
3
+ import { emptyCompressionUsage, normalizeCompressionUsageTotals } from "../compress/usage"
4
+ import { applyPendingCompressionDurations } from "../compress/timing"
5
+ import { loadManualModeSetting, loadSessionState, saveSessionState } from "./persistence"
6
+ import {
7
+ isSubAgentSession,
8
+ findLastCompactionTimestamp,
9
+ countTurns,
10
+ resetOnCompaction,
11
+ createPruneMessagesState,
12
+ loadPruneMessagesState,
13
+ loadPruneMap,
14
+ collectTurnNudgeAnchors,
15
+ } from "./utils"
16
+ import { getLastUserMessage } from "../messages/query"
17
+ import type { IdFormat } from "../message-ids"
18
+
19
+ export const checkSession = async (
20
+ client: any,
21
+ state: SessionState,
22
+ logger: Logger,
23
+ messages: WithParts[],
24
+ manualModeDefault: boolean,
25
+ ): Promise<void> => {
26
+ const lastUserMessage = getLastUserMessage(messages)
27
+ if (!lastUserMessage) {
28
+ return
29
+ }
30
+
31
+ const lastSessionId = lastUserMessage.info.sessionID
32
+
33
+ if (state.sessionId === null || state.sessionId !== lastSessionId) {
34
+ logger.info(`Session changed: ${state.sessionId} -> ${lastSessionId}`)
35
+ try {
36
+ await ensureSessionInitialized(
37
+ client,
38
+ state,
39
+ lastSessionId,
40
+ logger,
41
+ messages,
42
+ manualModeDefault,
43
+ )
44
+ } catch (err: any) {
45
+ logger.error("Failed to initialize session state", { error: err.message })
46
+ }
47
+ }
48
+
49
+ const lastCompactionTimestamp = findLastCompactionTimestamp(messages)
50
+ if (lastCompactionTimestamp > state.lastCompaction) {
51
+ state.lastCompaction = lastCompactionTimestamp
52
+ resetOnCompaction(state)
53
+ logger.info("Detected compaction - reset stale state", {
54
+ timestamp: lastCompactionTimestamp,
55
+ })
56
+
57
+ saveSessionState(state, logger).catch((error) => {
58
+ logger.warn("Failed to persist state reset after compaction", {
59
+ error: error instanceof Error ? error.message : String(error),
60
+ })
61
+ })
62
+ }
63
+
64
+ state.currentTurn = countTurns(state, messages)
65
+ await refreshManualMode(state, lastSessionId, logger, manualModeDefault)
66
+ }
67
+
68
+ export function createSessionState(idFormat: IdFormat = "xml"): SessionState {
69
+ return {
70
+ idFormat,
71
+ sessionId: null,
72
+ isSubAgent: false,
73
+ manualMode: false,
74
+ compressPermission: undefined,
75
+ pendingManualTrigger: null,
76
+ prune: {
77
+ tools: new Map<string, number>(),
78
+ messages: createPruneMessagesState(),
79
+ },
80
+ nudges: {
81
+ contextLimitAnchors: new Set<string>(),
82
+ turnNudgeAnchors: new Set<string>(),
83
+ iterationNudgeAnchors: new Set<string>(),
84
+ },
85
+ stats: {
86
+ pruneTokenCounter: 0,
87
+ totalPruneTokens: 0,
88
+ compressionUsage: emptyCompressionUsage(),
89
+ },
90
+ compressionTiming: {
91
+ startsByCallId: new Map<string, number>(),
92
+ pendingByCallId: new Map(),
93
+ },
94
+ toolParameters: new Map<string, ToolParameterEntry>(),
95
+ subAgentResultCache: new Map<string, string>(),
96
+ toolIdList: [],
97
+ messageIds: {
98
+ byRawId: new Map<string, string>(),
99
+ byRef: new Map<string, string>(),
100
+ nextRef: 1,
101
+ },
102
+ lastCompaction: 0,
103
+ currentTurn: 0,
104
+ modelContextLimit: undefined,
105
+ systemPromptTokens: undefined,
106
+ }
107
+ }
108
+
109
+ export function resetSessionState(state: SessionState): void {
110
+ state.sessionId = null
111
+ state.isSubAgent = false
112
+ state.manualMode = false
113
+ state.compressPermission = undefined
114
+ state.pendingManualTrigger = null
115
+ state.prune = {
116
+ tools: new Map<string, number>(),
117
+ messages: createPruneMessagesState(),
118
+ }
119
+ state.nudges = {
120
+ contextLimitAnchors: new Set<string>(),
121
+ turnNudgeAnchors: new Set<string>(),
122
+ iterationNudgeAnchors: new Set<string>(),
123
+ }
124
+ state.stats = {
125
+ pruneTokenCounter: 0,
126
+ totalPruneTokens: 0,
127
+ compressionUsage: emptyCompressionUsage(),
128
+ }
129
+ state.toolParameters.clear()
130
+ state.subAgentResultCache.clear()
131
+ state.toolIdList = []
132
+ state.messageIds = {
133
+ byRawId: new Map<string, string>(),
134
+ byRef: new Map<string, string>(),
135
+ nextRef: 1,
136
+ }
137
+ state.lastCompaction = 0
138
+ state.currentTurn = 0
139
+ state.modelContextLimit = undefined
140
+ state.systemPromptTokens = undefined
141
+ }
142
+
143
+ export async function ensureSessionInitialized(
144
+ client: any,
145
+ state: SessionState,
146
+ sessionId: string,
147
+ logger: Logger,
148
+ messages: WithParts[],
149
+ manualModeEnabled: boolean,
150
+ ): Promise<void> {
151
+ if (state.sessionId === sessionId) {
152
+ return
153
+ }
154
+
155
+ // logger.info("session ID = " + sessionId)
156
+ // logger.info("Initializing session state", { sessionId: sessionId })
157
+
158
+ resetSessionState(state)
159
+ state.manualMode = manualModeEnabled ? "active" : false
160
+ state.sessionId = sessionId
161
+
162
+ const isSubAgent = await isSubAgentSession(client, sessionId)
163
+ state.isSubAgent = isSubAgent
164
+ // logger.info("isSubAgent = " + isSubAgent)
165
+
166
+ state.lastCompaction = findLastCompactionTimestamp(messages)
167
+ state.currentTurn = countTurns(state, messages)
168
+ state.nudges.turnNudgeAnchors = collectTurnNudgeAnchors(messages)
169
+
170
+ const persisted = await loadSessionState(sessionId, logger)
171
+ if (persisted === null) {
172
+ return
173
+ }
174
+
175
+ if (typeof persisted.manualMode === "boolean") {
176
+ state.manualMode = persisted.manualMode ? "active" : false
177
+ }
178
+
179
+ state.prune.tools = loadPruneMap(persisted.prune.tools)
180
+ state.prune.messages = loadPruneMessagesState(persisted.prune.messages)
181
+ state.nudges.contextLimitAnchors = new Set<string>(persisted.nudges.contextLimitAnchors || [])
182
+ state.nudges.turnNudgeAnchors = new Set<string>([
183
+ ...state.nudges.turnNudgeAnchors,
184
+ ...(persisted.nudges.turnNudgeAnchors || []),
185
+ ])
186
+ state.nudges.iterationNudgeAnchors = new Set<string>(
187
+ persisted.nudges.iterationNudgeAnchors || [],
188
+ )
189
+ state.stats = {
190
+ pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0,
191
+ totalPruneTokens: persisted.stats?.totalPruneTokens || 0,
192
+ compressionUsage: normalizeCompressionUsageTotals(persisted.stats?.compressionUsage),
193
+ }
194
+
195
+ const applied = applyPendingCompressionDurations(state)
196
+ if (applied > 0) {
197
+ await saveSessionState(state, logger)
198
+ }
199
+ }
200
+
201
+ export async function refreshManualMode(
202
+ state: SessionState,
203
+ sessionId: string,
204
+ logger: Logger,
205
+ manualModeDefault: boolean,
206
+ ): Promise<void> {
207
+ if (state.manualMode === "compress-pending") {
208
+ return
209
+ }
210
+
211
+ const persisted = await loadManualModeSetting(sessionId, logger)
212
+ const enabled = persisted ?? manualModeDefault
213
+ state.manualMode = enabled ? "active" : false
214
+ }
@@ -0,0 +1,99 @@
1
+ import type { SessionState, ToolStatus, WithParts } from "./index"
2
+ import type { Logger } from "../logger"
3
+ import { PluginConfig } from "../config"
4
+ import { isMessageCompacted } from "./utils"
5
+ import { countToolTokens } from "../token-utils"
6
+
7
+ const MAX_TOOL_CACHE_SIZE = 1000
8
+
9
+ /**
10
+ * Sync tool parameters from session messages.
11
+ */
12
+ export function syncToolCache(
13
+ state: SessionState,
14
+ config: PluginConfig,
15
+ logger: Logger,
16
+ messages: WithParts[],
17
+ ): void {
18
+ try {
19
+ logger.info("Syncing tool parameters from OpenCode messages")
20
+
21
+ let turnCounter = 0
22
+
23
+ for (const msg of messages) {
24
+ if (isMessageCompacted(state, msg)) {
25
+ continue
26
+ }
27
+
28
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
29
+ for (const part of parts) {
30
+ if (part.type === "step-start") {
31
+ turnCounter++
32
+ continue
33
+ }
34
+
35
+ if (part.type !== "tool" || !part.callID) {
36
+ continue
37
+ }
38
+
39
+ const turnProtectionEnabled = config.turnProtection.enabled
40
+ const turnProtectionTurns = config.turnProtection.turns
41
+ const isProtectedByTurn =
42
+ turnProtectionEnabled &&
43
+ turnProtectionTurns > 0 &&
44
+ state.currentTurn - turnCounter < turnProtectionTurns
45
+
46
+ if (state.toolParameters.has(part.callID)) {
47
+ continue
48
+ }
49
+
50
+ if (isProtectedByTurn) {
51
+ continue
52
+ }
53
+
54
+ const tokenCount = countToolTokens(part)
55
+
56
+ state.toolParameters.set(part.callID, {
57
+ tool: part.tool,
58
+ parameters: part.state?.input ?? {},
59
+ metadata: "metadata" in part.state ? part.state.metadata : undefined,
60
+ status: part.state.status as ToolStatus | undefined,
61
+ error: part.state.status === "error" ? part.state.error : undefined,
62
+ turn: turnCounter,
63
+ tokenCount,
64
+ })
65
+ logger.info(
66
+ `Cached tool id: ${part.callID} (turn ${turnCounter}${tokenCount !== undefined ? `, ${tokenCount} tokens` : ""})`,
67
+ )
68
+ }
69
+ }
70
+
71
+ logger.info(
72
+ `Synced cache - size: ${state.toolParameters.size}, currentTurn: ${state.currentTurn}`,
73
+ )
74
+ trimToolParametersCache(state)
75
+ } catch (error) {
76
+ logger.warn("Failed to sync tool parameters from OpenCode", {
77
+ error: error instanceof Error ? error.message : String(error),
78
+ })
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Trim the tool parameters cache to prevent unbounded memory growth.
84
+ * Uses FIFO eviction - removes oldest entries first.
85
+ */
86
+ export function trimToolParametersCache(state: SessionState): void {
87
+ if (state.toolParameters.size <= MAX_TOOL_CACHE_SIZE) {
88
+ return
89
+ }
90
+
91
+ const keysToRemove = Array.from(state.toolParameters.keys()).slice(
92
+ 0,
93
+ state.toolParameters.size - MAX_TOOL_CACHE_SIZE,
94
+ )
95
+
96
+ for (const key of keysToRemove) {
97
+ state.toolParameters.delete(key)
98
+ }
99
+ }