@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,98 @@
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
+ status: part.state.status as ToolStatus | undefined,
60
+ error: part.state.status === "error" ? part.state.error : undefined,
61
+ turn: turnCounter,
62
+ tokenCount,
63
+ })
64
+ logger.info(
65
+ `Cached tool id: ${part.callID} (turn ${turnCounter}${tokenCount !== undefined ? `, ${tokenCount} tokens` : ""})`,
66
+ )
67
+ }
68
+ }
69
+
70
+ logger.info(
71
+ `Synced cache - size: ${state.toolParameters.size}, currentTurn: ${state.currentTurn}`,
72
+ )
73
+ trimToolParametersCache(state)
74
+ } catch (error) {
75
+ logger.warn("Failed to sync tool parameters from OpenCode", {
76
+ error: error instanceof Error ? error.message : String(error),
77
+ })
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Trim the tool parameters cache to prevent unbounded memory growth.
83
+ * Uses FIFO eviction - removes oldest entries first.
84
+ */
85
+ export function trimToolParametersCache(state: SessionState): void {
86
+ if (state.toolParameters.size <= MAX_TOOL_CACHE_SIZE) {
87
+ return
88
+ }
89
+
90
+ const keysToRemove = Array.from(state.toolParameters.keys()).slice(
91
+ 0,
92
+ state.toolParameters.size - MAX_TOOL_CACHE_SIZE,
93
+ )
94
+
95
+ for (const key of keysToRemove) {
96
+ state.toolParameters.delete(key)
97
+ }
98
+ }
@@ -0,0 +1,111 @@
1
+ import type { CompressionTimingState } from "../compress/timing"
2
+ import { Message, Part } from "@opencode-ai/sdk/v2"
3
+
4
+ export interface WithParts {
5
+ info: Message
6
+ parts: Part[]
7
+ }
8
+
9
+ export type ToolStatus = "pending" | "running" | "completed" | "error"
10
+
11
+ export interface ToolParameterEntry {
12
+ tool: string
13
+ parameters: any
14
+ status?: ToolStatus
15
+ error?: string
16
+ turn: number
17
+ tokenCount?: number
18
+ }
19
+
20
+ export interface SessionStats {
21
+ pruneTokenCounter: number
22
+ totalPruneTokens: number
23
+ }
24
+
25
+ export interface PrunedMessageEntry {
26
+ tokenCount: number
27
+ allBlockIds: number[]
28
+ activeBlockIds: number[]
29
+ }
30
+
31
+ export type CompressionMode = "range" | "message"
32
+
33
+ export interface CompressionBlock {
34
+ blockId: number
35
+ runId: number
36
+ active: boolean
37
+ deactivatedByUser: boolean
38
+ compressedTokens: number
39
+ summaryTokens: number
40
+ durationMs: number
41
+ mode?: CompressionMode
42
+ topic: string
43
+ batchTopic?: string
44
+ startId: string
45
+ endId: string
46
+ anchorMessageId: string
47
+ compressMessageId: string
48
+ compressCallId?: string
49
+ includedBlockIds: number[]
50
+ consumedBlockIds: number[]
51
+ parentBlockIds: number[]
52
+ directMessageIds: string[]
53
+ directToolIds: string[]
54
+ effectiveMessageIds: string[]
55
+ effectiveToolIds: string[]
56
+ createdAt: number
57
+ deactivatedAt?: number
58
+ deactivatedByBlockId?: number
59
+ summary: string
60
+ }
61
+
62
+ export interface PruneMessagesState {
63
+ byMessageId: Map<string, PrunedMessageEntry>
64
+ blocksById: Map<number, CompressionBlock>
65
+ activeBlockIds: Set<number>
66
+ activeByAnchorMessageId: Map<string, number>
67
+ nextBlockId: number
68
+ nextRunId: number
69
+ }
70
+
71
+ export interface Prune {
72
+ tools: Map<string, number>
73
+ messages: PruneMessagesState
74
+ }
75
+
76
+ export interface PendingManualTrigger {
77
+ sessionId: string
78
+ prompt: string
79
+ }
80
+
81
+ export interface MessageIdState {
82
+ byRawId: Map<string, string>
83
+ byRef: Map<string, string>
84
+ nextRef: number
85
+ }
86
+
87
+ export interface Nudges {
88
+ contextLimitAnchors: Set<string>
89
+ turnNudgeAnchors: Set<string>
90
+ iterationNudgeAnchors: Set<string>
91
+ }
92
+
93
+ export interface SessionState {
94
+ sessionId: string | null
95
+ isSubAgent: boolean
96
+ manualMode: false | "active" | "compress-pending"
97
+ compressPermission: "ask" | "allow" | "deny" | undefined
98
+ pendingManualTrigger: PendingManualTrigger | null
99
+ prune: Prune
100
+ nudges: Nudges
101
+ stats: SessionStats
102
+ compressionTiming: CompressionTimingState
103
+ toolParameters: Map<string, ToolParameterEntry>
104
+ subAgentResultCache: Map<string, string>
105
+ toolIdList: string[]
106
+ messageIds: MessageIdState
107
+ lastCompaction: number
108
+ currentTurn: number
109
+ modelContextLimit: number | undefined
110
+ systemPromptTokens: number | undefined
111
+ }
@@ -0,0 +1,345 @@
1
+ import type {
2
+ CompressionBlock,
3
+ PruneMessagesState,
4
+ PrunedMessageEntry,
5
+ SessionState,
6
+ WithParts,
7
+ } from "./types"
8
+ import { isIgnoredUserMessage, messageHasCompress } from "../messages/query"
9
+ import { isMessageWithInfo } from "../messages/shape"
10
+ import { countTokens } from "../token-utils"
11
+
12
+ export const isMessageCompacted = (state: SessionState, msg: WithParts): boolean => {
13
+ if (!isMessageWithInfo(msg)) {
14
+ return false
15
+ }
16
+
17
+ if (msg.info.time.created < state.lastCompaction) {
18
+ return true
19
+ }
20
+ const pruneEntry = state.prune.messages.byMessageId.get(msg.info.id)
21
+ if (pruneEntry && pruneEntry.activeBlockIds.length > 0) {
22
+ return true
23
+ }
24
+ return false
25
+ }
26
+
27
+ interface PersistedPruneMessagesState {
28
+ byMessageId: Record<string, PrunedMessageEntry>
29
+ blocksById: Record<string, CompressionBlock>
30
+ activeBlockIds: number[]
31
+ activeByAnchorMessageId: Record<string, number>
32
+ nextBlockId: number
33
+ nextRunId: number
34
+ }
35
+
36
+ export function serializePruneMessagesState(
37
+ messagesState: PruneMessagesState,
38
+ ): PersistedPruneMessagesState {
39
+ return {
40
+ byMessageId: Object.fromEntries(messagesState.byMessageId),
41
+ blocksById: Object.fromEntries(
42
+ Array.from(messagesState.blocksById.entries()).map(([blockId, block]) => [
43
+ String(blockId),
44
+ block,
45
+ ]),
46
+ ),
47
+ activeBlockIds: Array.from(messagesState.activeBlockIds),
48
+ activeByAnchorMessageId: Object.fromEntries(messagesState.activeByAnchorMessageId),
49
+ nextBlockId: messagesState.nextBlockId,
50
+ nextRunId: messagesState.nextRunId,
51
+ }
52
+ }
53
+
54
+ export async function isSubAgentSession(client: any, sessionID: string): Promise<boolean> {
55
+ try {
56
+ const result = await client.session.get({ path: { id: sessionID } })
57
+ return !!result.data?.parentID
58
+ } catch (error: any) {
59
+ return false
60
+ }
61
+ }
62
+
63
+ export function findLastCompactionTimestamp(messages: WithParts[]): number {
64
+ for (let i = messages.length - 1; i >= 0; i--) {
65
+ const msg = messages[i]
66
+ if (!isMessageWithInfo(msg)) {
67
+ continue
68
+ }
69
+ if (msg.info.role === "assistant" && msg.info.summary === true) {
70
+ return msg.info.time.created
71
+ }
72
+ }
73
+ return 0
74
+ }
75
+
76
+ export function countTurns(state: SessionState, messages: WithParts[]): number {
77
+ let turnCount = 0
78
+ for (const msg of messages) {
79
+ if (!isMessageWithInfo(msg)) {
80
+ continue
81
+ }
82
+ if (isMessageCompacted(state, msg)) {
83
+ continue
84
+ }
85
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
86
+ for (const part of parts) {
87
+ if (part.type === "step-start") {
88
+ turnCount++
89
+ }
90
+ }
91
+ }
92
+ return turnCount
93
+ }
94
+
95
+ export function loadPruneMap(obj?: Record<string, number>): Map<string, number> {
96
+ if (!obj || typeof obj !== "object") {
97
+ return new Map()
98
+ }
99
+
100
+ const entries = Object.entries(obj).filter(
101
+ (entry): entry is [string, number] =>
102
+ typeof entry[0] === "string" && typeof entry[1] === "number",
103
+ )
104
+ return new Map(entries)
105
+ }
106
+
107
+ export function createPruneMessagesState(): PruneMessagesState {
108
+ return {
109
+ byMessageId: new Map<string, PrunedMessageEntry>(),
110
+ blocksById: new Map<number, CompressionBlock>(),
111
+ activeBlockIds: new Set<number>(),
112
+ activeByAnchorMessageId: new Map<string, number>(),
113
+ nextBlockId: 1,
114
+ nextRunId: 1,
115
+ }
116
+ }
117
+
118
+ export function loadPruneMessagesState(
119
+ persisted?: PersistedPruneMessagesState,
120
+ ): PruneMessagesState {
121
+ const state = createPruneMessagesState()
122
+ if (!persisted || typeof persisted !== "object") {
123
+ return state
124
+ }
125
+
126
+ if (typeof persisted.nextBlockId === "number" && Number.isInteger(persisted.nextBlockId)) {
127
+ state.nextBlockId = Math.max(1, persisted.nextBlockId)
128
+ }
129
+ if (typeof persisted.nextRunId === "number" && Number.isInteger(persisted.nextRunId)) {
130
+ state.nextRunId = Math.max(1, persisted.nextRunId)
131
+ }
132
+
133
+ if (persisted.byMessageId && typeof persisted.byMessageId === "object") {
134
+ for (const [messageId, entry] of Object.entries(persisted.byMessageId)) {
135
+ if (!entry || typeof entry !== "object") {
136
+ continue
137
+ }
138
+
139
+ const tokenCount = typeof entry.tokenCount === "number" ? entry.tokenCount : 0
140
+ const allBlockIds = Array.isArray(entry.allBlockIds)
141
+ ? [
142
+ ...new Set(
143
+ entry.allBlockIds.filter(
144
+ (id): id is number => Number.isInteger(id) && id > 0,
145
+ ),
146
+ ),
147
+ ]
148
+ : []
149
+ const activeBlockIds = Array.isArray(entry.activeBlockIds)
150
+ ? [
151
+ ...new Set(
152
+ entry.activeBlockIds.filter(
153
+ (id): id is number => Number.isInteger(id) && id > 0,
154
+ ),
155
+ ),
156
+ ]
157
+ : []
158
+
159
+ state.byMessageId.set(messageId, {
160
+ tokenCount,
161
+ allBlockIds,
162
+ activeBlockIds,
163
+ })
164
+ }
165
+ }
166
+
167
+ if (persisted.blocksById && typeof persisted.blocksById === "object") {
168
+ for (const [blockIdStr, block] of Object.entries(persisted.blocksById)) {
169
+ const blockId = Number.parseInt(blockIdStr, 10)
170
+ if (!Number.isInteger(blockId) || blockId < 1 || !block || typeof block !== "object") {
171
+ continue
172
+ }
173
+
174
+ const toNumberArray = (value: unknown): number[] =>
175
+ Array.isArray(value)
176
+ ? [
177
+ ...new Set(
178
+ value.filter(
179
+ (item): item is number => Number.isInteger(item) && item > 0,
180
+ ),
181
+ ),
182
+ ]
183
+ : []
184
+ const toStringArray = (value: unknown): string[] =>
185
+ Array.isArray(value)
186
+ ? [...new Set(value.filter((item): item is string => typeof item === "string"))]
187
+ : []
188
+
189
+ state.blocksById.set(blockId, {
190
+ blockId,
191
+ runId:
192
+ typeof block.runId === "number" &&
193
+ Number.isInteger(block.runId) &&
194
+ block.runId > 0
195
+ ? block.runId
196
+ : blockId,
197
+ active: block.active === true,
198
+ deactivatedByUser: block.deactivatedByUser === true,
199
+ compressedTokens:
200
+ typeof block.compressedTokens === "number" &&
201
+ Number.isFinite(block.compressedTokens)
202
+ ? Math.max(0, block.compressedTokens)
203
+ : 0,
204
+ summaryTokens:
205
+ typeof block.summaryTokens === "number" && Number.isFinite(block.summaryTokens)
206
+ ? Math.max(0, block.summaryTokens)
207
+ : typeof block.summary === "string"
208
+ ? countTokens(block.summary)
209
+ : 0,
210
+ durationMs:
211
+ typeof block.durationMs === "number" && Number.isFinite(block.durationMs)
212
+ ? Math.max(0, block.durationMs)
213
+ : 0,
214
+ mode: block.mode === "range" || block.mode === "message" ? block.mode : undefined,
215
+ topic: typeof block.topic === "string" ? block.topic : "",
216
+ batchTopic:
217
+ typeof block.batchTopic === "string"
218
+ ? block.batchTopic
219
+ : typeof block.topic === "string"
220
+ ? block.topic
221
+ : "",
222
+ startId: typeof block.startId === "string" ? block.startId : "",
223
+ endId: typeof block.endId === "string" ? block.endId : "",
224
+ anchorMessageId:
225
+ typeof block.anchorMessageId === "string" ? block.anchorMessageId : "",
226
+ compressMessageId:
227
+ typeof block.compressMessageId === "string" ? block.compressMessageId : "",
228
+ compressCallId:
229
+ typeof block.compressCallId === "string" ? block.compressCallId : undefined,
230
+ includedBlockIds: toNumberArray(block.includedBlockIds),
231
+ consumedBlockIds: toNumberArray(block.consumedBlockIds),
232
+ parentBlockIds: toNumberArray(block.parentBlockIds),
233
+ directMessageIds: toStringArray(block.directMessageIds),
234
+ directToolIds: toStringArray(block.directToolIds),
235
+ effectiveMessageIds: toStringArray(block.effectiveMessageIds),
236
+ effectiveToolIds: toStringArray(block.effectiveToolIds),
237
+ createdAt: typeof block.createdAt === "number" ? block.createdAt : 0,
238
+ deactivatedAt:
239
+ typeof block.deactivatedAt === "number" ? block.deactivatedAt : undefined,
240
+ deactivatedByBlockId:
241
+ typeof block.deactivatedByBlockId === "number" &&
242
+ Number.isInteger(block.deactivatedByBlockId)
243
+ ? block.deactivatedByBlockId
244
+ : undefined,
245
+ summary: typeof block.summary === "string" ? block.summary : "",
246
+ })
247
+ }
248
+ }
249
+
250
+ if (Array.isArray(persisted.activeBlockIds)) {
251
+ for (const blockId of persisted.activeBlockIds) {
252
+ if (!Number.isInteger(blockId) || blockId < 1) {
253
+ continue
254
+ }
255
+ state.activeBlockIds.add(blockId)
256
+ }
257
+ }
258
+
259
+ if (
260
+ persisted.activeByAnchorMessageId &&
261
+ typeof persisted.activeByAnchorMessageId === "object"
262
+ ) {
263
+ for (const [anchorMessageId, blockId] of Object.entries(
264
+ persisted.activeByAnchorMessageId,
265
+ )) {
266
+ if (typeof blockId !== "number" || !Number.isInteger(blockId) || blockId < 1) {
267
+ continue
268
+ }
269
+ state.activeByAnchorMessageId.set(anchorMessageId, blockId)
270
+ }
271
+ }
272
+
273
+ for (const [blockId, block] of state.blocksById) {
274
+ if (block.active) {
275
+ state.activeBlockIds.add(blockId)
276
+ if (block.anchorMessageId) {
277
+ state.activeByAnchorMessageId.set(block.anchorMessageId, blockId)
278
+ }
279
+ }
280
+ if (blockId >= state.nextBlockId) {
281
+ state.nextBlockId = blockId + 1
282
+ }
283
+ if (block.runId >= state.nextRunId) {
284
+ state.nextRunId = block.runId + 1
285
+ }
286
+ }
287
+
288
+ return state
289
+ }
290
+
291
+ export function collectTurnNudgeAnchors(messages: WithParts[]): Set<string> {
292
+ const anchors = new Set<string>()
293
+ let pendingUserMessageId: string | null = null
294
+
295
+ for (let i = messages.length - 1; i >= 0; i--) {
296
+ const message = messages[i]
297
+
298
+ if (messageHasCompress(message)) {
299
+ break
300
+ }
301
+
302
+ if (message.info.role === "user") {
303
+ if (!isIgnoredUserMessage(message)) {
304
+ pendingUserMessageId = message.info.id
305
+ }
306
+ continue
307
+ }
308
+
309
+ if (message.info.role === "assistant" && pendingUserMessageId) {
310
+ anchors.add(message.info.id)
311
+ anchors.add(pendingUserMessageId)
312
+ pendingUserMessageId = null
313
+ }
314
+ }
315
+
316
+ return anchors
317
+ }
318
+
319
+ export function getActiveSummaryTokenUsage(state: SessionState): number {
320
+ let total = 0
321
+ for (const blockId of state.prune.messages.activeBlockIds) {
322
+ const block = state.prune.messages.blocksById.get(blockId)
323
+ if (!block || !block.active) {
324
+ continue
325
+ }
326
+ total += block.summaryTokens
327
+ }
328
+ return total
329
+ }
330
+
331
+ export function resetOnCompaction(state: SessionState): void {
332
+ state.toolParameters.clear()
333
+ state.prune.tools = new Map<string, number>()
334
+ state.prune.messages = createPruneMessagesState()
335
+ state.messageIds = {
336
+ byRawId: new Map<string, string>(),
337
+ byRef: new Map<string, string>(),
338
+ nextRef: 1,
339
+ }
340
+ state.nudges = {
341
+ contextLimitAnchors: new Set<string>(),
342
+ turnNudgeAnchors: new Set<string>(),
343
+ iterationNudgeAnchors: new Set<string>(),
344
+ }
345
+ }
@@ -0,0 +1,127 @@
1
+ import { PluginConfig } from "../config"
2
+ import { Logger } from "../logger"
3
+ import type { SessionState, WithParts } from "../state"
4
+ import {
5
+ getFilePathsFromParameters,
6
+ isFilePathProtected,
7
+ isToolNameProtected,
8
+ } from "../protected-patterns"
9
+ import { getTotalToolTokens } from "../token-utils"
10
+
11
+ /**
12
+ * Deduplication strategy - prunes older tool calls that have identical
13
+ * tool name and parameters, keeping only the most recent occurrence.
14
+ * Modifies the session state in place to add pruned tool call IDs.
15
+ */
16
+ export const deduplicate = (
17
+ state: SessionState,
18
+ logger: Logger,
19
+ config: PluginConfig,
20
+ messages: WithParts[],
21
+ ): void => {
22
+ if (state.manualMode && !config.manualMode.automaticStrategies) {
23
+ return
24
+ }
25
+
26
+ if (!config.strategies.deduplication.enabled) {
27
+ return
28
+ }
29
+
30
+ const allToolIds = state.toolIdList
31
+ if (allToolIds.length === 0) {
32
+ return
33
+ }
34
+
35
+ // Filter out IDs already pruned
36
+ const unprunedIds = allToolIds.filter((id) => !state.prune.tools.has(id))
37
+
38
+ if (unprunedIds.length === 0) {
39
+ return
40
+ }
41
+
42
+ const protectedTools = config.strategies.deduplication.protectedTools
43
+
44
+ // Group by signature (tool name + normalized parameters)
45
+ const signatureMap = new Map<string, string[]>()
46
+
47
+ for (const id of unprunedIds) {
48
+ const metadata = state.toolParameters.get(id)
49
+ if (!metadata) {
50
+ // logger.warn(`Missing metadata for tool call ID: ${id}`)
51
+ continue
52
+ }
53
+
54
+ // Skip protected tools
55
+ if (isToolNameProtected(metadata.tool, protectedTools)) {
56
+ continue
57
+ }
58
+
59
+ const filePaths = getFilePathsFromParameters(metadata.tool, metadata.parameters)
60
+ if (isFilePathProtected(filePaths, config.protectedFilePatterns)) {
61
+ continue
62
+ }
63
+
64
+ const signature = createToolSignature(metadata.tool, metadata.parameters)
65
+ if (!signatureMap.has(signature)) {
66
+ signatureMap.set(signature, [])
67
+ }
68
+ const ids = signatureMap.get(signature)
69
+ if (ids) {
70
+ ids.push(id)
71
+ }
72
+ }
73
+
74
+ // Find duplicates - keep only the most recent (last) in each group
75
+ const newPruneIds: string[] = []
76
+
77
+ for (const [, ids] of signatureMap.entries()) {
78
+ if (ids.length > 1) {
79
+ // All except last (most recent) should be pruned
80
+ const idsToRemove = ids.slice(0, -1)
81
+ newPruneIds.push(...idsToRemove)
82
+ }
83
+ }
84
+
85
+ state.stats.totalPruneTokens += getTotalToolTokens(state, newPruneIds)
86
+
87
+ if (newPruneIds.length > 0) {
88
+ for (const id of newPruneIds) {
89
+ const entry = state.toolParameters.get(id)
90
+ state.prune.tools.set(id, entry?.tokenCount ?? 0)
91
+ }
92
+ logger.debug(`Marked ${newPruneIds.length} duplicate tool calls for pruning`)
93
+ }
94
+ }
95
+
96
+ function createToolSignature(tool: string, parameters?: any): string {
97
+ if (!parameters) {
98
+ return tool
99
+ }
100
+ const normalized = normalizeParameters(parameters)
101
+ const sorted = sortObjectKeys(normalized)
102
+ return `${tool}::${JSON.stringify(sorted)}`
103
+ }
104
+
105
+ function normalizeParameters(params: any): any {
106
+ if (typeof params !== "object" || params === null) return params
107
+ if (Array.isArray(params)) return params
108
+
109
+ const normalized: any = {}
110
+ for (const [key, value] of Object.entries(params)) {
111
+ if (value !== undefined && value !== null) {
112
+ normalized[key] = value
113
+ }
114
+ }
115
+ return normalized
116
+ }
117
+
118
+ function sortObjectKeys(obj: any): any {
119
+ if (typeof obj !== "object" || obj === null) return obj
120
+ if (Array.isArray(obj)) return obj.map(sortObjectKeys)
121
+
122
+ const sorted: any = {}
123
+ for (const key of Object.keys(obj).sort()) {
124
+ sorted[key] = sortObjectKeys(obj[key])
125
+ }
126
+ return sorted
127
+ }
@@ -0,0 +1,2 @@
1
+ export { deduplicate } from "./deduplication"
2
+ export { purgeErrors } from "./purge-errors"