@tarquinen/opencode-dcp 3.1.11 → 3.1.13

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 (111) hide show
  1. package/README.md +14 -9
  2. package/dist/index.js +106 -49
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/commands/context.d.ts +15 -0
  5. package/dist/lib/commands/context.d.ts.map +1 -1
  6. package/dist/lib/commands/help.d.ts +1 -0
  7. package/dist/lib/commands/help.d.ts.map +1 -1
  8. package/dist/lib/commands/manual.d.ts +1 -1
  9. package/dist/lib/commands/manual.d.ts.map +1 -1
  10. package/dist/lib/commands/stats.d.ts +10 -0
  11. package/dist/lib/commands/stats.d.ts.map +1 -1
  12. package/dist/lib/compress/pipeline.d.ts.map +1 -1
  13. package/dist/lib/hooks.d.ts +0 -2
  14. package/dist/lib/hooks.d.ts.map +1 -1
  15. package/dist/lib/messages/index.d.ts +0 -1
  16. package/dist/lib/messages/index.d.ts.map +1 -1
  17. package/dist/lib/messages/inject/utils.d.ts +0 -7
  18. package/dist/lib/messages/inject/utils.d.ts.map +1 -1
  19. package/dist/lib/state/persistence.d.ts +3 -0
  20. package/dist/lib/state/persistence.d.ts.map +1 -1
  21. package/dist/lib/state/state.d.ts +1 -0
  22. package/dist/lib/state/state.d.ts.map +1 -1
  23. package/dist/lib/tui/commands.d.ts +3 -0
  24. package/dist/lib/tui/commands.d.ts.map +1 -0
  25. package/dist/lib/tui/data.d.ts +14 -0
  26. package/dist/lib/tui/data.d.ts.map +1 -0
  27. package/dist/lib/tui/dialogs.d.ts +30 -0
  28. package/dist/lib/tui/dialogs.d.ts.map +1 -0
  29. package/dist/lib/tui/format.d.ts +4 -0
  30. package/dist/lib/tui/format.d.ts.map +1 -0
  31. package/dist/lib/tui/modals.d.ts +10 -0
  32. package/dist/lib/tui/modals.d.ts.map +1 -0
  33. package/dist/lib/tui/types.d.ts +15 -0
  34. package/dist/lib/tui/types.d.ts.map +1 -0
  35. package/dist/lib/tui/ui.d.ts +48 -0
  36. package/dist/lib/tui/ui.d.ts.map +1 -0
  37. package/dist/tui.d.ts +7 -0
  38. package/dist/tui.d.ts.map +1 -0
  39. package/lib/auth.ts +37 -0
  40. package/lib/commands/compression-targets.ts +137 -0
  41. package/lib/commands/context.ts +305 -0
  42. package/lib/commands/decompress.ts +275 -0
  43. package/lib/commands/help.ts +76 -0
  44. package/lib/commands/index.ts +11 -0
  45. package/lib/commands/manual.ts +127 -0
  46. package/lib/commands/recompress.ts +224 -0
  47. package/lib/commands/stats.ts +159 -0
  48. package/lib/commands/sweep.ts +268 -0
  49. package/lib/compress/index.ts +3 -0
  50. package/lib/compress/message-utils.ts +250 -0
  51. package/lib/compress/message.ts +145 -0
  52. package/lib/compress/pipeline.ts +108 -0
  53. package/lib/compress/protected-content.ts +208 -0
  54. package/lib/compress/range-utils.ts +308 -0
  55. package/lib/compress/range.ts +192 -0
  56. package/lib/compress/search.ts +267 -0
  57. package/lib/compress/state.ts +268 -0
  58. package/lib/compress/timing.ts +77 -0
  59. package/lib/compress/types.ts +108 -0
  60. package/lib/compress-permission.ts +25 -0
  61. package/lib/config.ts +1007 -0
  62. package/lib/hooks.ts +375 -0
  63. package/lib/host-permissions.ts +101 -0
  64. package/lib/logger.ts +226 -0
  65. package/lib/message-ids.ts +172 -0
  66. package/lib/messages/index.ts +8 -0
  67. package/lib/messages/inject/inject.ts +215 -0
  68. package/lib/messages/inject/subagent-results.ts +82 -0
  69. package/lib/messages/inject/utils.ts +374 -0
  70. package/lib/messages/priority.ts +102 -0
  71. package/lib/messages/prune.ts +233 -0
  72. package/lib/messages/query.ts +72 -0
  73. package/lib/messages/reasoning-strip.ts +40 -0
  74. package/lib/messages/shape.ts +50 -0
  75. package/lib/messages/sync.ts +124 -0
  76. package/lib/messages/utils.ts +185 -0
  77. package/lib/prompts/compress-message.ts +43 -0
  78. package/lib/prompts/compress-range.ts +60 -0
  79. package/lib/prompts/context-limit-nudge.ts +18 -0
  80. package/lib/prompts/extensions/nudge.ts +43 -0
  81. package/lib/prompts/extensions/system.ts +32 -0
  82. package/lib/prompts/extensions/tool.ts +35 -0
  83. package/lib/prompts/index.ts +29 -0
  84. package/lib/prompts/iteration-nudge.ts +6 -0
  85. package/lib/prompts/store.ts +467 -0
  86. package/lib/prompts/system.ts +33 -0
  87. package/lib/prompts/turn-nudge.ts +10 -0
  88. package/lib/protected-patterns.ts +128 -0
  89. package/lib/state/index.ts +4 -0
  90. package/lib/state/persistence.ts +305 -0
  91. package/lib/state/state.ts +208 -0
  92. package/lib/state/tool-cache.ts +98 -0
  93. package/lib/state/types.ts +111 -0
  94. package/lib/state/utils.ts +345 -0
  95. package/lib/strategies/deduplication.ts +127 -0
  96. package/lib/strategies/index.ts +2 -0
  97. package/lib/strategies/purge-errors.ts +88 -0
  98. package/lib/subagents/subagent-results.ts +74 -0
  99. package/lib/token-utils.ts +164 -0
  100. package/lib/tui/commands.ts +31 -0
  101. package/lib/tui/data.ts +73 -0
  102. package/lib/tui/dialogs.tsx +235 -0
  103. package/lib/tui/format.ts +25 -0
  104. package/lib/tui/modals.tsx +92 -0
  105. package/lib/tui/types.ts +16 -0
  106. package/lib/tui/ui.tsx +219 -0
  107. package/lib/ui/notification.ts +347 -0
  108. package/lib/ui/utils.ts +304 -0
  109. package/lib/update.ts +185 -0
  110. package/package.json +17 -5
  111. package/tui.tsx +26 -0
@@ -0,0 +1,128 @@
1
+ function normalizePath(input: string): string {
2
+ return input.replaceAll("\\\\", "/")
3
+ }
4
+
5
+ function escapeRegExpChar(ch: string): string {
6
+ return /[\\.^$+{}()|\[\]]/.test(ch) ? `\\${ch}` : ch
7
+ }
8
+
9
+ export function matchesGlob(inputPath: string, pattern: string): boolean {
10
+ if (!pattern) return false
11
+
12
+ const input = normalizePath(inputPath)
13
+ const pat = normalizePath(pattern)
14
+
15
+ let regex = "^"
16
+
17
+ for (let i = 0; i < pat.length; i++) {
18
+ const ch = pat[i]
19
+
20
+ if (ch === "*") {
21
+ const next = pat[i + 1]
22
+ if (next === "*") {
23
+ const after = pat[i + 2]
24
+ if (after === "/") {
25
+ // **/ (zero or more directories)
26
+ regex += "(?:.*/)?"
27
+ i += 2
28
+ continue
29
+ }
30
+
31
+ // **
32
+ regex += ".*"
33
+ i++
34
+ continue
35
+ }
36
+
37
+ // *
38
+ regex += "[^/]*"
39
+ continue
40
+ }
41
+
42
+ if (ch === "?") {
43
+ regex += "[^/]"
44
+ continue
45
+ }
46
+
47
+ if (ch === "/") {
48
+ regex += "/"
49
+ continue
50
+ }
51
+
52
+ regex += escapeRegExpChar(ch)
53
+ }
54
+
55
+ regex += "$"
56
+
57
+ return new RegExp(regex).test(input)
58
+ }
59
+
60
+ export function getFilePathsFromParameters(tool: string, parameters: unknown): string[] {
61
+ if (typeof parameters !== "object" || parameters === null) {
62
+ return []
63
+ }
64
+
65
+ const paths: string[] = []
66
+ const params = parameters as Record<string, any>
67
+
68
+ // 1. apply_patch uses patchText with embedded paths
69
+ if (tool === "apply_patch" && typeof params.patchText === "string") {
70
+ const pathRegex = /\*\*\* (?:Add|Delete|Update) File: ([^\n\r]+)/g
71
+ let match
72
+ while ((match = pathRegex.exec(params.patchText)) !== null) {
73
+ paths.push(match[1].trim())
74
+ }
75
+ }
76
+
77
+ // 2. multiedit uses top-level filePath and nested edits array
78
+ if (tool === "multiedit") {
79
+ if (typeof params.filePath === "string") {
80
+ paths.push(params.filePath)
81
+ }
82
+ if (Array.isArray(params.edits)) {
83
+ for (const edit of params.edits) {
84
+ if (edit && typeof edit.filePath === "string") {
85
+ paths.push(edit.filePath)
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ // 3. Default check for common filePath parameter (read, write, edit, etc)
92
+ if (typeof params.filePath === "string") {
93
+ paths.push(params.filePath)
94
+ }
95
+
96
+ // Return unique non-empty paths
97
+ return [...new Set(paths)].filter((p) => p.length > 0)
98
+ }
99
+
100
+ export function isFilePathProtected(filePaths: string[], patterns: string[]): boolean {
101
+ if (!filePaths || filePaths.length === 0) return false
102
+ if (!patterns || patterns.length === 0) return false
103
+
104
+ return filePaths.some((path) => patterns.some((pattern) => matchesGlob(path, pattern)))
105
+ }
106
+
107
+ const GLOB_CHARS = /[*?]/
108
+
109
+ export function isToolNameProtected(toolName: string, patterns: string[]): boolean {
110
+ if (!toolName || !patterns || patterns.length === 0) return false
111
+
112
+ const exactPatterns: Set<string> = new Set()
113
+ const globPatterns: string[] = []
114
+
115
+ for (const pattern of patterns) {
116
+ if (GLOB_CHARS.test(pattern)) {
117
+ globPatterns.push(pattern)
118
+ } else {
119
+ exactPatterns.add(pattern)
120
+ }
121
+ }
122
+
123
+ if (exactPatterns.has(toolName)) {
124
+ return true
125
+ }
126
+
127
+ return globPatterns.some((pattern) => matchesGlob(toolName, pattern))
128
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./persistence"
2
+ export * from "./types"
3
+ export * from "./state"
4
+ export * from "./tool-cache"
@@ -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,208 @@
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
+
17
+ export const checkSession = async (
18
+ client: any,
19
+ state: SessionState,
20
+ logger: Logger,
21
+ messages: WithParts[],
22
+ manualModeDefault: boolean,
23
+ ): Promise<void> => {
24
+ const lastUserMessage = getLastUserMessage(messages)
25
+ if (!lastUserMessage) {
26
+ return
27
+ }
28
+
29
+ const lastSessionId = lastUserMessage.info.sessionID
30
+
31
+ if (state.sessionId === null || state.sessionId !== lastSessionId) {
32
+ logger.info(`Session changed: ${state.sessionId} -> ${lastSessionId}`)
33
+ try {
34
+ await ensureSessionInitialized(
35
+ client,
36
+ state,
37
+ lastSessionId,
38
+ logger,
39
+ messages,
40
+ manualModeDefault,
41
+ )
42
+ } catch (err: any) {
43
+ logger.error("Failed to initialize session state", { error: err.message })
44
+ }
45
+ }
46
+
47
+ const lastCompactionTimestamp = findLastCompactionTimestamp(messages)
48
+ if (lastCompactionTimestamp > state.lastCompaction) {
49
+ state.lastCompaction = lastCompactionTimestamp
50
+ resetOnCompaction(state)
51
+ logger.info("Detected compaction - reset stale state", {
52
+ timestamp: lastCompactionTimestamp,
53
+ })
54
+
55
+ saveSessionState(state, logger).catch((error) => {
56
+ logger.warn("Failed to persist state reset after compaction", {
57
+ error: error instanceof Error ? error.message : String(error),
58
+ })
59
+ })
60
+ }
61
+
62
+ state.currentTurn = countTurns(state, messages)
63
+ await refreshManualMode(state, lastSessionId, logger, manualModeDefault)
64
+ }
65
+
66
+ export function createSessionState(): SessionState {
67
+ return {
68
+ sessionId: null,
69
+ isSubAgent: false,
70
+ manualMode: false,
71
+ compressPermission: undefined,
72
+ pendingManualTrigger: null,
73
+ prune: {
74
+ tools: new Map<string, number>(),
75
+ messages: createPruneMessagesState(),
76
+ },
77
+ nudges: {
78
+ contextLimitAnchors: new Set<string>(),
79
+ turnNudgeAnchors: new Set<string>(),
80
+ iterationNudgeAnchors: new Set<string>(),
81
+ },
82
+ stats: {
83
+ pruneTokenCounter: 0,
84
+ totalPruneTokens: 0,
85
+ },
86
+ compressionTiming: {
87
+ startsByCallId: new Map<string, number>(),
88
+ pendingByCallId: new Map(),
89
+ },
90
+ toolParameters: new Map<string, ToolParameterEntry>(),
91
+ subAgentResultCache: new Map<string, string>(),
92
+ toolIdList: [],
93
+ messageIds: {
94
+ byRawId: new Map<string, string>(),
95
+ byRef: new Map<string, string>(),
96
+ nextRef: 1,
97
+ },
98
+ lastCompaction: 0,
99
+ currentTurn: 0,
100
+ modelContextLimit: undefined,
101
+ systemPromptTokens: undefined,
102
+ }
103
+ }
104
+
105
+ export function resetSessionState(state: SessionState): void {
106
+ state.sessionId = null
107
+ state.isSubAgent = false
108
+ state.manualMode = false
109
+ state.compressPermission = undefined
110
+ state.pendingManualTrigger = null
111
+ state.prune = {
112
+ tools: new Map<string, number>(),
113
+ messages: createPruneMessagesState(),
114
+ }
115
+ state.nudges = {
116
+ contextLimitAnchors: new Set<string>(),
117
+ turnNudgeAnchors: new Set<string>(),
118
+ iterationNudgeAnchors: new Set<string>(),
119
+ }
120
+ state.stats = {
121
+ pruneTokenCounter: 0,
122
+ totalPruneTokens: 0,
123
+ }
124
+ state.toolParameters.clear()
125
+ state.subAgentResultCache.clear()
126
+ state.toolIdList = []
127
+ state.messageIds = {
128
+ byRawId: new Map<string, string>(),
129
+ byRef: new Map<string, string>(),
130
+ nextRef: 1,
131
+ }
132
+ state.lastCompaction = 0
133
+ state.currentTurn = 0
134
+ state.modelContextLimit = undefined
135
+ state.systemPromptTokens = undefined
136
+ }
137
+
138
+ export async function ensureSessionInitialized(
139
+ client: any,
140
+ state: SessionState,
141
+ sessionId: string,
142
+ logger: Logger,
143
+ messages: WithParts[],
144
+ manualModeEnabled: boolean,
145
+ ): Promise<void> {
146
+ if (state.sessionId === sessionId) {
147
+ return
148
+ }
149
+
150
+ // logger.info("session ID = " + sessionId)
151
+ // logger.info("Initializing session state", { sessionId: sessionId })
152
+
153
+ resetSessionState(state)
154
+ state.manualMode = manualModeEnabled ? "active" : false
155
+ state.sessionId = sessionId
156
+
157
+ const isSubAgent = await isSubAgentSession(client, sessionId)
158
+ state.isSubAgent = isSubAgent
159
+ // logger.info("isSubAgent = " + isSubAgent)
160
+
161
+ state.lastCompaction = findLastCompactionTimestamp(messages)
162
+ state.currentTurn = countTurns(state, messages)
163
+ state.nudges.turnNudgeAnchors = collectTurnNudgeAnchors(messages)
164
+
165
+ const persisted = await loadSessionState(sessionId, logger)
166
+ if (persisted === null) {
167
+ return
168
+ }
169
+
170
+ if (typeof persisted.manualMode === "boolean") {
171
+ state.manualMode = persisted.manualMode ? "active" : false
172
+ }
173
+
174
+ state.prune.tools = loadPruneMap(persisted.prune.tools)
175
+ state.prune.messages = loadPruneMessagesState(persisted.prune.messages)
176
+ state.nudges.contextLimitAnchors = new Set<string>(persisted.nudges.contextLimitAnchors || [])
177
+ state.nudges.turnNudgeAnchors = new Set<string>([
178
+ ...state.nudges.turnNudgeAnchors,
179
+ ...(persisted.nudges.turnNudgeAnchors || []),
180
+ ])
181
+ state.nudges.iterationNudgeAnchors = new Set<string>(
182
+ persisted.nudges.iterationNudgeAnchors || [],
183
+ )
184
+ state.stats = {
185
+ pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0,
186
+ totalPruneTokens: persisted.stats?.totalPruneTokens || 0,
187
+ }
188
+
189
+ const applied = applyPendingCompressionDurations(state)
190
+ if (applied > 0) {
191
+ await saveSessionState(state, logger)
192
+ }
193
+ }
194
+
195
+ export async function refreshManualMode(
196
+ state: SessionState,
197
+ sessionId: string,
198
+ logger: Logger,
199
+ manualModeDefault: boolean,
200
+ ): Promise<void> {
201
+ if (state.manualMode === "compress-pending") {
202
+ return
203
+ }
204
+
205
+ const persisted = await loadManualModeSetting(sessionId, logger)
206
+ const enabled = persisted ?? manualModeDefault
207
+ state.manualMode = enabled ? "active" : false
208
+ }