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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/LICENSE +619 -0
  2. package/README.md +348 -2
  3. package/dist/index.d.ts +7 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +7611 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/lib/commands/compression-targets.d.ts +13 -0
  8. package/dist/lib/commands/compression-targets.d.ts.map +1 -0
  9. package/dist/lib/commands/context.d.ts +67 -0
  10. package/dist/lib/commands/context.d.ts.map +1 -0
  11. package/dist/lib/commands/decompress.d.ts +12 -0
  12. package/dist/lib/commands/decompress.d.ts.map +1 -0
  13. package/dist/lib/commands/help.d.ts +18 -0
  14. package/dist/lib/commands/help.d.ts.map +1 -0
  15. package/dist/lib/commands/index.d.ts +8 -0
  16. package/dist/lib/commands/index.d.ts.map +1 -0
  17. package/dist/lib/commands/manual.d.ts +23 -0
  18. package/dist/lib/commands/manual.d.ts.map +1 -0
  19. package/dist/lib/commands/recompress.d.ts +12 -0
  20. package/dist/lib/commands/recompress.d.ts.map +1 -0
  21. package/dist/lib/commands/stats.d.ts +25 -0
  22. package/dist/lib/commands/stats.d.ts.map +1 -0
  23. package/dist/lib/commands/sweep.d.ts +23 -0
  24. package/dist/lib/commands/sweep.d.ts.map +1 -0
  25. package/dist/lib/compress/index.d.ts +3 -0
  26. package/dist/lib/compress/index.d.ts.map +1 -0
  27. package/dist/lib/compress/pipeline.d.ts +29 -0
  28. package/dist/lib/compress/pipeline.d.ts.map +1 -0
  29. package/dist/lib/compress/protected-content.d.ts +6 -0
  30. package/dist/lib/compress/protected-content.d.ts.map +1 -0
  31. package/dist/lib/compress/rc.d.ts +15 -0
  32. package/dist/lib/compress/rc.d.ts.map +1 -0
  33. package/dist/lib/compress/search.d.ts +12 -0
  34. package/dist/lib/compress/search.d.ts.map +1 -0
  35. package/dist/lib/compress/state.d.ts +10 -0
  36. package/dist/lib/compress/state.d.ts.map +1 -0
  37. package/dist/lib/compress/summary.d.ts +58 -0
  38. package/dist/lib/compress/summary.d.ts.map +1 -0
  39. package/dist/lib/compress/timing.d.ts +18 -0
  40. package/dist/lib/compress/timing.d.ts.map +1 -0
  41. package/dist/lib/compress/types.d.ts +55 -0
  42. package/dist/lib/compress/types.d.ts.map +1 -0
  43. package/dist/lib/compress-permission.d.ts +4 -0
  44. package/dist/lib/compress-permission.d.ts.map +1 -0
  45. package/dist/lib/config.d.ts +83 -0
  46. package/dist/lib/config.d.ts.map +1 -0
  47. package/dist/lib/hooks.d.ts +22 -0
  48. package/dist/lib/hooks.d.ts.map +1 -0
  49. package/dist/lib/logger.d.ts +31 -0
  50. package/dist/lib/logger.d.ts.map +1 -0
  51. package/dist/lib/message-ids.d.ts +20 -0
  52. package/dist/lib/message-ids.d.ts.map +1 -0
  53. package/dist/lib/messages/index.d.ts +8 -0
  54. package/dist/lib/messages/index.d.ts.map +1 -0
  55. package/dist/lib/messages/inject/inject.d.ts +7 -0
  56. package/dist/lib/messages/inject/inject.d.ts.map +1 -0
  57. package/dist/lib/messages/inject/subagent-results.d.ts +4 -0
  58. package/dist/lib/messages/inject/subagent-results.d.ts.map +1 -0
  59. package/dist/lib/messages/inject/utils.d.ts +23 -0
  60. package/dist/lib/messages/inject/utils.d.ts.map +1 -0
  61. package/dist/lib/messages/prune.d.ts +4 -0
  62. package/dist/lib/messages/prune.d.ts.map +1 -0
  63. package/dist/lib/messages/query.d.ts +7 -0
  64. package/dist/lib/messages/query.d.ts.map +1 -0
  65. package/dist/lib/messages/reasoning-strip.d.ts +8 -0
  66. package/dist/lib/messages/reasoning-strip.d.ts.map +1 -0
  67. package/dist/lib/messages/shape.d.ts +5 -0
  68. package/dist/lib/messages/shape.d.ts.map +1 -0
  69. package/dist/lib/messages/sync.d.ts +4 -0
  70. package/dist/lib/messages/sync.d.ts.map +1 -0
  71. package/dist/lib/messages/utils.d.ts +27 -0
  72. package/dist/lib/messages/utils.d.ts.map +1 -0
  73. package/dist/lib/prompts/compress-rc.d.ts +4 -0
  74. package/dist/lib/prompts/compress-rc.d.ts.map +1 -0
  75. package/dist/lib/prompts/context-limit-nudge.d.ts +2 -0
  76. package/dist/lib/prompts/context-limit-nudge.d.ts.map +1 -0
  77. package/dist/lib/prompts/extensions/nudge.d.ts +4 -0
  78. package/dist/lib/prompts/extensions/nudge.d.ts.map +1 -0
  79. package/dist/lib/prompts/extensions/system.d.ts +4 -0
  80. package/dist/lib/prompts/extensions/system.d.ts.map +1 -0
  81. package/dist/lib/prompts/extensions/tool.d.ts +3 -0
  82. package/dist/lib/prompts/extensions/tool.d.ts.map +1 -0
  83. package/dist/lib/prompts/index.d.ts +4 -0
  84. package/dist/lib/prompts/index.d.ts.map +1 -0
  85. package/dist/lib/prompts/iteration-nudge.d.ts +2 -0
  86. package/dist/lib/prompts/iteration-nudge.d.ts.map +1 -0
  87. package/dist/lib/prompts/store.d.ts +26 -0
  88. package/dist/lib/prompts/store.d.ts.map +1 -0
  89. package/dist/lib/prompts/system.d.ts +4 -0
  90. package/dist/lib/prompts/system.d.ts.map +1 -0
  91. package/dist/lib/prompts/turn-nudge.d.ts +2 -0
  92. package/dist/lib/prompts/turn-nudge.d.ts.map +1 -0
  93. package/dist/lib/protected-patterns.d.ts +6 -0
  94. package/dist/lib/protected-patterns.d.ts.map +1 -0
  95. package/dist/lib/state/index.d.ts +5 -0
  96. package/dist/lib/state/index.d.ts.map +1 -0
  97. package/dist/lib/state/persistence.d.ts +45 -0
  98. package/dist/lib/state/persistence.d.ts.map +1 -0
  99. package/dist/lib/state/state.d.ts +9 -0
  100. package/dist/lib/state/state.d.ts.map +1 -0
  101. package/dist/lib/state/tool-cache.d.ts +13 -0
  102. package/dist/lib/state/tool-cache.d.ts.map +1 -0
  103. package/dist/lib/state/types.d.ts +102 -0
  104. package/dist/lib/state/types.d.ts.map +1 -0
  105. package/dist/lib/state/utils.d.ts +22 -0
  106. package/dist/lib/state/utils.d.ts.map +1 -0
  107. package/dist/lib/strategies/deduplication.d.ts +10 -0
  108. package/dist/lib/strategies/deduplication.d.ts.map +1 -0
  109. package/dist/lib/strategies/index.d.ts +3 -0
  110. package/dist/lib/strategies/index.d.ts.map +1 -0
  111. package/dist/lib/strategies/purge-errors.d.ts +13 -0
  112. package/dist/lib/strategies/purge-errors.d.ts.map +1 -0
  113. package/dist/lib/subagents/subagent-results.d.ts +5 -0
  114. package/dist/lib/subagents/subagent-results.d.ts.map +1 -0
  115. package/dist/lib/token-utils.d.ts +19 -0
  116. package/dist/lib/token-utils.d.ts.map +1 -0
  117. package/dist/lib/tui/dialogs.d.ts +29 -0
  118. package/dist/lib/tui/dialogs.d.ts.map +1 -0
  119. package/dist/lib/tui/format.d.ts +4 -0
  120. package/dist/lib/tui/format.d.ts.map +1 -0
  121. package/dist/lib/tui/types.d.ts +18 -0
  122. package/dist/lib/tui/types.d.ts.map +1 -0
  123. package/dist/lib/tui/ui.d.ts +48 -0
  124. package/dist/lib/tui/ui.d.ts.map +1 -0
  125. package/dist/lib/ui/notification.d.ts +17 -0
  126. package/dist/lib/ui/notification.d.ts.map +1 -0
  127. package/dist/lib/ui/utils.d.ts +10 -0
  128. package/dist/lib/ui/utils.d.ts.map +1 -0
  129. package/dist/lib/v2/index.d.ts +5 -0
  130. package/dist/lib/v2/index.d.ts.map +1 -0
  131. package/dist/lib/v2/messages.d.ts +13 -0
  132. package/dist/lib/v2/messages.d.ts.map +1 -0
  133. package/dist/lib/v2/rpc.d.ts +55 -0
  134. package/dist/lib/v2/rpc.d.ts.map +1 -0
  135. package/dist/lib/v2/theme.d.ts +55 -0
  136. package/dist/lib/v2/theme.d.ts.map +1 -0
  137. package/dist/lib/v2/tui.d.ts +4 -0
  138. package/dist/lib/v2/tui.d.ts.map +1 -0
  139. package/dist/tui.d.ts +7 -0
  140. package/dist/tui.d.ts.map +1 -0
  141. package/lib/commands/compression-targets.ts +54 -0
  142. package/lib/commands/context.ts +305 -0
  143. package/lib/commands/decompress.ts +273 -0
  144. package/lib/commands/help.ts +76 -0
  145. package/lib/commands/index.ts +11 -0
  146. package/lib/commands/manual.ts +126 -0
  147. package/lib/commands/recompress.ts +222 -0
  148. package/lib/commands/stats.ts +159 -0
  149. package/lib/commands/sweep.ts +266 -0
  150. package/lib/compress/index.ts +2 -0
  151. package/lib/compress/pipeline.ts +116 -0
  152. package/lib/compress/protected-content.ts +163 -0
  153. package/lib/compress/rc.ts +458 -0
  154. package/lib/compress/search.ts +277 -0
  155. package/lib/compress/state.ts +272 -0
  156. package/lib/compress/summary.ts +293 -0
  157. package/lib/compress/timing.ts +77 -0
  158. package/lib/compress/types.ts +57 -0
  159. package/lib/compress-permission.ts +9 -0
  160. package/lib/config.ts +995 -0
  161. package/lib/hooks.ts +205 -0
  162. package/lib/logger.ts +226 -0
  163. package/lib/message-ids.ts +181 -0
  164. package/lib/messages/index.ts +7 -0
  165. package/lib/messages/inject/inject.ts +215 -0
  166. package/lib/messages/inject/subagent-results.ts +84 -0
  167. package/lib/messages/inject/utils.ts +304 -0
  168. package/lib/messages/prune.ts +238 -0
  169. package/lib/messages/query.ts +71 -0
  170. package/lib/messages/reasoning-strip.ts +40 -0
  171. package/lib/messages/shape.ts +50 -0
  172. package/lib/messages/sync.ts +124 -0
  173. package/lib/messages/utils.ts +190 -0
  174. package/lib/prompts/compress-rc.ts +25 -0
  175. package/lib/prompts/context-limit-nudge.ts +18 -0
  176. package/lib/prompts/extensions/nudge.ts +34 -0
  177. package/lib/prompts/extensions/system.ts +32 -0
  178. package/lib/prompts/extensions/tool.ts +21 -0
  179. package/lib/prompts/index.ts +29 -0
  180. package/lib/prompts/iteration-nudge.ts +6 -0
  181. package/lib/prompts/store.ts +452 -0
  182. package/lib/prompts/system.ts +41 -0
  183. package/lib/prompts/turn-nudge.ts +10 -0
  184. package/lib/protected-patterns.ts +159 -0
  185. package/lib/state/index.ts +4 -0
  186. package/lib/state/persistence.ts +305 -0
  187. package/lib/state/state.ts +210 -0
  188. package/lib/state/tool-cache.ts +99 -0
  189. package/lib/state/types.ts +114 -0
  190. package/lib/state/utils.ts +345 -0
  191. package/lib/strategies/deduplication.ts +125 -0
  192. package/lib/strategies/index.ts +2 -0
  193. package/lib/strategies/purge-errors.ts +86 -0
  194. package/lib/subagents/subagent-results.ts +75 -0
  195. package/lib/token-utils.ts +164 -0
  196. package/lib/tui/dialogs.tsx +237 -0
  197. package/lib/tui/format.ts +25 -0
  198. package/lib/tui/types.ts +25 -0
  199. package/lib/tui/ui.tsx +245 -0
  200. package/lib/ui/notification.ts +347 -0
  201. package/lib/ui/utils.ts +304 -0
  202. package/lib/v2/index.ts +401 -0
  203. package/lib/v2/messages.ts +252 -0
  204. package/lib/v2/rpc.ts +48 -0
  205. package/lib/v2/theme.ts +67 -0
  206. package/lib/v2/tui.tsx +115 -0
  207. package/package.json +91 -5
  208. package/server.js +1 -0
  209. package/tui.tsx +7 -0
@@ -0,0 +1,305 @@
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 { serializePruneMessagesState } from "./utils"
14
+
15
+ /** Prune state as stored on disk */
16
+ export interface PersistedPruneMessagesState {
17
+ byMessageId: Record<string, PrunedMessageEntry>
18
+ blocksById: Record<string, CompressionBlock>
19
+ activeBlockIds: number[]
20
+ activeByAnchorMessageId: Record<string, number>
21
+ nextBlockId: number
22
+ nextRunId: number
23
+ }
24
+
25
+ export interface PersistedPrune {
26
+ tools?: Record<string, number>
27
+ messages?: PersistedPruneMessagesState
28
+ }
29
+
30
+ export interface PersistedNudges {
31
+ contextLimitAnchors: string[]
32
+ turnNudgeAnchors?: string[]
33
+ iterationNudgeAnchors?: string[]
34
+ }
35
+
36
+ export interface PersistedSessionState {
37
+ sessionName?: string
38
+ manualMode?: boolean
39
+ prune: PersistedPrune
40
+ nudges: PersistedNudges
41
+ stats: SessionStats
42
+ lastUpdated: string
43
+ }
44
+
45
+ const STORAGE_DIR = join(
46
+ process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"),
47
+ "opencode",
48
+ "storage",
49
+ "plugin",
50
+ "dcp",
51
+ )
52
+
53
+ async function ensureStorageDir(): Promise<void> {
54
+ if (!existsSync(STORAGE_DIR)) {
55
+ await fs.mkdir(STORAGE_DIR, { recursive: true })
56
+ }
57
+ }
58
+
59
+ function getSessionFilePath(sessionId: string): string {
60
+ return join(STORAGE_DIR, `${sessionId}.json`)
61
+ }
62
+
63
+ async function writePersistedSessionState(
64
+ sessionId: string,
65
+ state: PersistedSessionState,
66
+ logger: Logger,
67
+ ): Promise<void> {
68
+ await ensureStorageDir()
69
+
70
+ const filePath = getSessionFilePath(sessionId)
71
+ const content = JSON.stringify(state, null, 2)
72
+ await fs.writeFile(filePath, content, "utf-8")
73
+
74
+ logger.info("Saved session state to disk", {
75
+ sessionId,
76
+ totalTokensSaved: state.stats.totalPruneTokens,
77
+ })
78
+ }
79
+
80
+ export async function saveSessionState(
81
+ sessionState: SessionState,
82
+ logger: Logger,
83
+ sessionName?: string,
84
+ ): Promise<void> {
85
+ try {
86
+ if (!sessionState.sessionId) {
87
+ return
88
+ }
89
+
90
+ const state: PersistedSessionState = {
91
+ sessionName: sessionName,
92
+ manualMode: !!sessionState.manualMode,
93
+ prune: {
94
+ tools: Object.fromEntries(sessionState.prune.tools),
95
+ messages: serializePruneMessagesState(sessionState.prune.messages),
96
+ },
97
+ nudges: {
98
+ contextLimitAnchors: Array.from(sessionState.nudges.contextLimitAnchors),
99
+ turnNudgeAnchors: Array.from(sessionState.nudges.turnNudgeAnchors),
100
+ iterationNudgeAnchors: Array.from(sessionState.nudges.iterationNudgeAnchors),
101
+ },
102
+ stats: sessionState.stats,
103
+ lastUpdated: new Date().toISOString(),
104
+ }
105
+
106
+ await writePersistedSessionState(sessionState.sessionId, state, logger)
107
+ } catch (error: any) {
108
+ logger.error("Failed to save session state", {
109
+ sessionId: sessionState.sessionId,
110
+ error: error?.message,
111
+ })
112
+ }
113
+ }
114
+
115
+ export async function loadSessionState(
116
+ sessionId: string,
117
+ logger: Logger,
118
+ ): Promise<PersistedSessionState | null> {
119
+ try {
120
+ const filePath = getSessionFilePath(sessionId)
121
+
122
+ if (!existsSync(filePath)) {
123
+ return null
124
+ }
125
+
126
+ const content = await fs.readFile(filePath, "utf-8")
127
+ const state = JSON.parse(content) as PersistedSessionState
128
+
129
+ const hasPruneTools = state?.prune?.tools && typeof state.prune.tools === "object"
130
+ const hasPruneMessages = state?.prune?.messages && typeof state.prune.messages === "object"
131
+ const hasNudgeFormat = state?.nudges && typeof state.nudges === "object"
132
+ if (
133
+ !state ||
134
+ !state.prune ||
135
+ !hasPruneTools ||
136
+ !hasPruneMessages ||
137
+ !state.stats ||
138
+ !hasNudgeFormat
139
+ ) {
140
+ logger.warn("Invalid session state file, ignoring", {
141
+ sessionId: sessionId,
142
+ })
143
+ return null
144
+ }
145
+
146
+ const rawContextLimitAnchors = Array.isArray(state.nudges.contextLimitAnchors)
147
+ ? state.nudges.contextLimitAnchors
148
+ : []
149
+ const validAnchors = rawContextLimitAnchors.filter(
150
+ (entry): entry is string => typeof entry === "string",
151
+ )
152
+ const dedupedAnchors = [...new Set(validAnchors)]
153
+ if (validAnchors.length !== rawContextLimitAnchors.length) {
154
+ logger.warn("Filtered out malformed contextLimitAnchors entries", {
155
+ sessionId: sessionId,
156
+ original: rawContextLimitAnchors.length,
157
+ valid: validAnchors.length,
158
+ })
159
+ }
160
+ state.nudges.contextLimitAnchors = dedupedAnchors
161
+
162
+ const rawTurnNudgeAnchors = Array.isArray(state.nudges.turnNudgeAnchors)
163
+ ? state.nudges.turnNudgeAnchors
164
+ : []
165
+ const validSoftAnchors = rawTurnNudgeAnchors.filter(
166
+ (entry): entry is string => typeof entry === "string",
167
+ )
168
+ const dedupedSoftAnchors = [...new Set(validSoftAnchors)]
169
+ if (validSoftAnchors.length !== rawTurnNudgeAnchors.length) {
170
+ logger.warn("Filtered out malformed turnNudgeAnchors entries", {
171
+ sessionId: sessionId,
172
+ original: rawTurnNudgeAnchors.length,
173
+ valid: validSoftAnchors.length,
174
+ })
175
+ }
176
+ state.nudges.turnNudgeAnchors = dedupedSoftAnchors
177
+
178
+ const rawIterationNudgeAnchors = Array.isArray(state.nudges.iterationNudgeAnchors)
179
+ ? state.nudges.iterationNudgeAnchors
180
+ : []
181
+ const validIterationAnchors = rawIterationNudgeAnchors.filter(
182
+ (entry): entry is string => typeof entry === "string",
183
+ )
184
+ const dedupedIterationAnchors = [...new Set(validIterationAnchors)]
185
+ if (validIterationAnchors.length !== rawIterationNudgeAnchors.length) {
186
+ logger.warn("Filtered out malformed iterationNudgeAnchors entries", {
187
+ sessionId: sessionId,
188
+ original: rawIterationNudgeAnchors.length,
189
+ valid: validIterationAnchors.length,
190
+ })
191
+ }
192
+ state.nudges.iterationNudgeAnchors = dedupedIterationAnchors
193
+
194
+ logger.info("Loaded session state from disk", {
195
+ sessionId: sessionId,
196
+ })
197
+
198
+ return state
199
+ } catch (error: any) {
200
+ logger.warn("Failed to load session state", {
201
+ sessionId: sessionId,
202
+ error: error?.message,
203
+ })
204
+ return null
205
+ }
206
+ }
207
+
208
+ function emptyPersistedState(manualMode: boolean): PersistedSessionState {
209
+ return {
210
+ manualMode,
211
+ prune: {
212
+ tools: {},
213
+ messages: {
214
+ byMessageId: {},
215
+ blocksById: {},
216
+ activeBlockIds: [],
217
+ activeByAnchorMessageId: {},
218
+ nextBlockId: 1,
219
+ nextRunId: 1,
220
+ },
221
+ },
222
+ nudges: {
223
+ contextLimitAnchors: [],
224
+ turnNudgeAnchors: [],
225
+ iterationNudgeAnchors: [],
226
+ },
227
+ stats: {
228
+ pruneTokenCounter: 0,
229
+ totalPruneTokens: 0,
230
+ },
231
+ lastUpdated: new Date().toISOString(),
232
+ }
233
+ }
234
+
235
+ export async function loadManualModeSetting(
236
+ sessionId: string,
237
+ logger: Logger,
238
+ ): Promise<boolean | undefined> {
239
+ const state = await loadSessionState(sessionId, logger)
240
+ return typeof state?.manualMode === "boolean" ? state.manualMode : undefined
241
+ }
242
+
243
+ export async function saveManualModeSetting(
244
+ sessionId: string,
245
+ manualMode: boolean,
246
+ logger: Logger,
247
+ ): Promise<void> {
248
+ const existing = await loadSessionState(sessionId, logger)
249
+ const state = existing ?? emptyPersistedState(manualMode)
250
+ state.manualMode = manualMode
251
+ state.lastUpdated = new Date().toISOString()
252
+ await writePersistedSessionState(sessionId, state, logger)
253
+ }
254
+
255
+ export interface AggregatedStats {
256
+ totalTokens: number
257
+ totalTools: number
258
+ totalMessages: number
259
+ sessionCount: number
260
+ }
261
+
262
+ export async function loadAllSessionStats(logger: Logger): Promise<AggregatedStats> {
263
+ const result: AggregatedStats = {
264
+ totalTokens: 0,
265
+ totalTools: 0,
266
+ totalMessages: 0,
267
+ sessionCount: 0,
268
+ }
269
+
270
+ try {
271
+ if (!existsSync(STORAGE_DIR)) {
272
+ return result
273
+ }
274
+
275
+ const files = await fs.readdir(STORAGE_DIR)
276
+ const jsonFiles = files.filter((f) => f.endsWith(".json"))
277
+
278
+ for (const file of jsonFiles) {
279
+ try {
280
+ const filePath = join(STORAGE_DIR, file)
281
+ const content = await fs.readFile(filePath, "utf-8")
282
+ const state = JSON.parse(content) as PersistedSessionState
283
+
284
+ if (state?.stats?.totalPruneTokens && state?.prune) {
285
+ result.totalTokens += state.stats.totalPruneTokens
286
+ result.totalTools += state.prune.tools
287
+ ? Object.keys(state.prune.tools).length
288
+ : 0
289
+ result.totalMessages += state.prune.messages?.byMessageId
290
+ ? Object.keys(state.prune.messages.byMessageId).length
291
+ : 0
292
+ result.sessionCount++
293
+ }
294
+ } catch {
295
+ // Skip invalid files
296
+ }
297
+ }
298
+
299
+ logger.debug("Loaded all-time stats", result)
300
+ } catch (error: any) {
301
+ logger.warn("Failed to load all-time stats", { error: error?.message })
302
+ }
303
+
304
+ return result
305
+ }
@@ -0,0 +1,210 @@
1
+ import type { SessionState, ToolParameterEntry, WithParts } from "./types"
2
+ import type { Logger } from "../logger"
3
+ import { applyPendingCompressionDurations } from "../compress/timing"
4
+ import { loadManualModeSetting, loadSessionState, saveSessionState } from "./persistence"
5
+ import {
6
+ isSubAgentSession,
7
+ findLastCompactionTimestamp,
8
+ countTurns,
9
+ resetOnCompaction,
10
+ createPruneMessagesState,
11
+ loadPruneMessagesState,
12
+ loadPruneMap,
13
+ collectTurnNudgeAnchors,
14
+ } from "./utils"
15
+ import { getLastUserMessage } from "../messages/query"
16
+ import type { IdFormat } from "../message-ids"
17
+
18
+ export const checkSession = async (
19
+ client: any,
20
+ state: SessionState,
21
+ logger: Logger,
22
+ messages: WithParts[],
23
+ manualModeDefault: boolean,
24
+ ): Promise<void> => {
25
+ const lastUserMessage = getLastUserMessage(messages)
26
+ if (!lastUserMessage) {
27
+ return
28
+ }
29
+
30
+ const lastSessionId = lastUserMessage.info.sessionID
31
+
32
+ if (state.sessionId === null || state.sessionId !== lastSessionId) {
33
+ logger.info(`Session changed: ${state.sessionId} -> ${lastSessionId}`)
34
+ try {
35
+ await ensureSessionInitialized(
36
+ client,
37
+ state,
38
+ lastSessionId,
39
+ logger,
40
+ messages,
41
+ manualModeDefault,
42
+ )
43
+ } catch (err: any) {
44
+ logger.error("Failed to initialize session state", { error: err.message })
45
+ }
46
+ }
47
+
48
+ const lastCompactionTimestamp = findLastCompactionTimestamp(messages)
49
+ if (lastCompactionTimestamp > state.lastCompaction) {
50
+ state.lastCompaction = lastCompactionTimestamp
51
+ resetOnCompaction(state)
52
+ logger.info("Detected compaction - reset stale state", {
53
+ timestamp: lastCompactionTimestamp,
54
+ })
55
+
56
+ saveSessionState(state, logger).catch((error) => {
57
+ logger.warn("Failed to persist state reset after compaction", {
58
+ error: error instanceof Error ? error.message : String(error),
59
+ })
60
+ })
61
+ }
62
+
63
+ state.currentTurn = countTurns(state, messages)
64
+ await refreshManualMode(state, lastSessionId, logger, manualModeDefault)
65
+ }
66
+
67
+ export function createSessionState(idFormat: IdFormat = "xml"): SessionState {
68
+ return {
69
+ idFormat,
70
+ sessionId: null,
71
+ isSubAgent: false,
72
+ manualMode: false,
73
+ compressPermission: undefined,
74
+ pendingManualTrigger: null,
75
+ prune: {
76
+ tools: new Map<string, number>(),
77
+ messages: createPruneMessagesState(),
78
+ },
79
+ nudges: {
80
+ contextLimitAnchors: new Set<string>(),
81
+ turnNudgeAnchors: new Set<string>(),
82
+ iterationNudgeAnchors: new Set<string>(),
83
+ },
84
+ stats: {
85
+ pruneTokenCounter: 0,
86
+ totalPruneTokens: 0,
87
+ },
88
+ compressionTiming: {
89
+ startsByCallId: new Map<string, number>(),
90
+ pendingByCallId: new Map(),
91
+ },
92
+ toolParameters: new Map<string, ToolParameterEntry>(),
93
+ subAgentResultCache: new Map<string, string>(),
94
+ toolIdList: [],
95
+ messageIds: {
96
+ byRawId: new Map<string, string>(),
97
+ byRef: new Map<string, string>(),
98
+ nextRef: 1,
99
+ },
100
+ lastCompaction: 0,
101
+ currentTurn: 0,
102
+ modelContextLimit: undefined,
103
+ systemPromptTokens: undefined,
104
+ }
105
+ }
106
+
107
+ export function resetSessionState(state: SessionState): void {
108
+ state.sessionId = null
109
+ state.isSubAgent = false
110
+ state.manualMode = false
111
+ state.compressPermission = undefined
112
+ state.pendingManualTrigger = null
113
+ state.prune = {
114
+ tools: new Map<string, number>(),
115
+ messages: createPruneMessagesState(),
116
+ }
117
+ state.nudges = {
118
+ contextLimitAnchors: new Set<string>(),
119
+ turnNudgeAnchors: new Set<string>(),
120
+ iterationNudgeAnchors: new Set<string>(),
121
+ }
122
+ state.stats = {
123
+ pruneTokenCounter: 0,
124
+ totalPruneTokens: 0,
125
+ }
126
+ state.toolParameters.clear()
127
+ state.subAgentResultCache.clear()
128
+ state.toolIdList = []
129
+ state.messageIds = {
130
+ byRawId: new Map<string, string>(),
131
+ byRef: new Map<string, string>(),
132
+ nextRef: 1,
133
+ }
134
+ state.lastCompaction = 0
135
+ state.currentTurn = 0
136
+ state.modelContextLimit = undefined
137
+ state.systemPromptTokens = undefined
138
+ }
139
+
140
+ export async function ensureSessionInitialized(
141
+ client: any,
142
+ state: SessionState,
143
+ sessionId: string,
144
+ logger: Logger,
145
+ messages: WithParts[],
146
+ manualModeEnabled: boolean,
147
+ ): Promise<void> {
148
+ if (state.sessionId === sessionId) {
149
+ return
150
+ }
151
+
152
+ // logger.info("session ID = " + sessionId)
153
+ // logger.info("Initializing session state", { sessionId: sessionId })
154
+
155
+ resetSessionState(state)
156
+ state.manualMode = manualModeEnabled ? "active" : false
157
+ state.sessionId = sessionId
158
+
159
+ const isSubAgent = await isSubAgentSession(client, sessionId)
160
+ state.isSubAgent = isSubAgent
161
+ // logger.info("isSubAgent = " + isSubAgent)
162
+
163
+ state.lastCompaction = findLastCompactionTimestamp(messages)
164
+ state.currentTurn = countTurns(state, messages)
165
+ state.nudges.turnNudgeAnchors = collectTurnNudgeAnchors(messages)
166
+
167
+ const persisted = await loadSessionState(sessionId, logger)
168
+ if (persisted === null) {
169
+ return
170
+ }
171
+
172
+ if (typeof persisted.manualMode === "boolean") {
173
+ state.manualMode = persisted.manualMode ? "active" : false
174
+ }
175
+
176
+ state.prune.tools = loadPruneMap(persisted.prune.tools)
177
+ state.prune.messages = loadPruneMessagesState(persisted.prune.messages)
178
+ state.nudges.contextLimitAnchors = new Set<string>(persisted.nudges.contextLimitAnchors || [])
179
+ state.nudges.turnNudgeAnchors = new Set<string>([
180
+ ...state.nudges.turnNudgeAnchors,
181
+ ...(persisted.nudges.turnNudgeAnchors || []),
182
+ ])
183
+ state.nudges.iterationNudgeAnchors = new Set<string>(
184
+ persisted.nudges.iterationNudgeAnchors || [],
185
+ )
186
+ state.stats = {
187
+ pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0,
188
+ totalPruneTokens: persisted.stats?.totalPruneTokens || 0,
189
+ }
190
+
191
+ const applied = applyPendingCompressionDurations(state)
192
+ if (applied > 0) {
193
+ await saveSessionState(state, logger)
194
+ }
195
+ }
196
+
197
+ export async function refreshManualMode(
198
+ state: SessionState,
199
+ sessionId: string,
200
+ logger: Logger,
201
+ manualModeDefault: boolean,
202
+ ): Promise<void> {
203
+ if (state.manualMode === "compress-pending") {
204
+ return
205
+ }
206
+
207
+ const persisted = await loadManualModeSetting(sessionId, logger)
208
+ const enabled = persisted ?? manualModeDefault
209
+ state.manualMode = enabled ? "active" : false
210
+ }
@@ -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
+ }