@tarquinen/opencode-dcp 3.2.5-beta0 → 3.2.7-beta0

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 (74) hide show
  1. package/dist/lib/token-utils.js +2 -2
  2. package/dist/lib/token-utils.js.map +1 -1
  3. package/index.ts +141 -0
  4. package/lib/analysis/tokens.ts +225 -0
  5. package/lib/auth.ts +37 -0
  6. package/lib/commands/compression-targets.ts +137 -0
  7. package/lib/commands/context.ts +132 -0
  8. package/lib/commands/decompress.ts +275 -0
  9. package/lib/commands/help.ts +76 -0
  10. package/lib/commands/index.ts +11 -0
  11. package/lib/commands/manual.ts +125 -0
  12. package/lib/commands/recompress.ts +224 -0
  13. package/lib/commands/stats.ts +148 -0
  14. package/lib/commands/sweep.ts +268 -0
  15. package/lib/compress/index.ts +3 -0
  16. package/lib/compress/message-utils.ts +250 -0
  17. package/lib/compress/message.ts +137 -0
  18. package/lib/compress/pipeline.ts +106 -0
  19. package/lib/compress/protected-content.ts +154 -0
  20. package/lib/compress/range-utils.ts +308 -0
  21. package/lib/compress/range.ts +180 -0
  22. package/lib/compress/search.ts +267 -0
  23. package/lib/compress/state.ts +268 -0
  24. package/lib/compress/timing.ts +77 -0
  25. package/lib/compress/types.ts +108 -0
  26. package/lib/compress-permission.ts +25 -0
  27. package/lib/config.ts +1071 -0
  28. package/lib/hooks.ts +378 -0
  29. package/lib/host-permissions.ts +101 -0
  30. package/lib/logger.ts +235 -0
  31. package/lib/message-ids.ts +172 -0
  32. package/lib/messages/index.ts +8 -0
  33. package/lib/messages/inject/inject.ts +215 -0
  34. package/lib/messages/inject/subagent-results.ts +82 -0
  35. package/lib/messages/inject/utils.ts +374 -0
  36. package/lib/messages/priority.ts +102 -0
  37. package/lib/messages/prune.ts +238 -0
  38. package/lib/messages/query.ts +56 -0
  39. package/lib/messages/reasoning-strip.ts +40 -0
  40. package/lib/messages/sync.ts +124 -0
  41. package/lib/messages/utils.ts +187 -0
  42. package/lib/prompts/compress-message.ts +42 -0
  43. package/lib/prompts/compress-range.ts +60 -0
  44. package/lib/prompts/context-limit-nudge.ts +18 -0
  45. package/lib/prompts/extensions/nudge.ts +43 -0
  46. package/lib/prompts/extensions/system.ts +32 -0
  47. package/lib/prompts/extensions/tool.ts +35 -0
  48. package/lib/prompts/index.ts +29 -0
  49. package/lib/prompts/iteration-nudge.ts +6 -0
  50. package/lib/prompts/store.ts +467 -0
  51. package/lib/prompts/system.ts +33 -0
  52. package/lib/prompts/turn-nudge.ts +10 -0
  53. package/lib/protected-patterns.ts +128 -0
  54. package/lib/state/index.ts +4 -0
  55. package/lib/state/persistence.ts +256 -0
  56. package/lib/state/state.ts +190 -0
  57. package/lib/state/tool-cache.ts +98 -0
  58. package/lib/state/types.ts +112 -0
  59. package/lib/state/utils.ts +334 -0
  60. package/lib/strategies/deduplication.ts +127 -0
  61. package/lib/strategies/index.ts +2 -0
  62. package/lib/strategies/purge-errors.ts +88 -0
  63. package/lib/subagents/subagent-results.ts +74 -0
  64. package/lib/token-utils.ts +162 -0
  65. package/lib/ui/notification.ts +346 -0
  66. package/lib/ui/utils.ts +287 -0
  67. package/package.json +12 -3
  68. package/tui/data/context.ts +177 -0
  69. package/tui/index.tsx +34 -0
  70. package/tui/routes/summary.tsx +175 -0
  71. package/tui/shared/names.ts +9 -0
  72. package/tui/shared/theme.ts +58 -0
  73. package/tui/shared/types.ts +38 -0
  74. package/tui/slots/sidebar-content.tsx +502 -0
@@ -0,0 +1,224 @@
1
+ import type { Logger } from "../logger"
2
+ import type { PruneMessagesState, SessionState, WithParts } from "../state"
3
+ import { syncCompressionBlocks } from "../messages"
4
+ import { parseBlockRef } from "../message-ids"
5
+ import { getCurrentParams } from "../token-utils"
6
+ import { saveSessionState } from "../state/persistence"
7
+ import { sendIgnoredMessage } from "../ui/notification"
8
+ import { formatTokenCount } from "../ui/utils"
9
+ import {
10
+ getRecompressibleCompressionTargets,
11
+ resolveCompressionTarget,
12
+ type CompressionTarget,
13
+ } from "./compression-targets"
14
+
15
+ export interface RecompressCommandContext {
16
+ client: any
17
+ state: SessionState
18
+ logger: Logger
19
+ sessionId: string
20
+ messages: WithParts[]
21
+ args: string[]
22
+ }
23
+
24
+ function parseBlockIdArg(arg: string): number | null {
25
+ const normalized = arg.trim().toLowerCase()
26
+ const blockRef = parseBlockRef(normalized)
27
+ if (blockRef !== null) {
28
+ return blockRef
29
+ }
30
+
31
+ if (!/^[1-9]\d*$/.test(normalized)) {
32
+ return null
33
+ }
34
+
35
+ const parsed = Number.parseInt(normalized, 10)
36
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null
37
+ }
38
+
39
+ function snapshotActiveMessages(messagesState: PruneMessagesState): Set<string> {
40
+ const activeMessages = new Set<string>()
41
+ for (const [messageId, entry] of messagesState.byMessageId) {
42
+ if (entry.activeBlockIds.length > 0) {
43
+ activeMessages.add(messageId)
44
+ }
45
+ }
46
+ return activeMessages
47
+ }
48
+
49
+ function formatRecompressMessage(
50
+ target: CompressionTarget,
51
+ recompressedMessageCount: number,
52
+ recompressedTokens: number,
53
+ deactivatedBlockIds: number[],
54
+ ): string {
55
+ const lines: string[] = []
56
+
57
+ lines.push(`Re-applied compression ${target.displayId}.`)
58
+ if (target.runId !== target.displayId || target.grouped) {
59
+ lines.push(`Tool call label: Compression #${target.runId}.`)
60
+ }
61
+ if (deactivatedBlockIds.length > 0) {
62
+ const refs = deactivatedBlockIds.map((id) => String(id)).join(", ")
63
+ lines.push(`Also re-compressed nested compression(s): ${refs}.`)
64
+ }
65
+
66
+ if (recompressedMessageCount > 0) {
67
+ lines.push(
68
+ `Re-compressed ${recompressedMessageCount} message(s) (~${formatTokenCount(recompressedTokens)}).`,
69
+ )
70
+ } else {
71
+ lines.push("No messages were re-compressed.")
72
+ }
73
+
74
+ return lines.join("\n")
75
+ }
76
+
77
+ function formatAvailableBlocksMessage(availableTargets: CompressionTarget[]): string {
78
+ const lines: string[] = []
79
+
80
+ lines.push("Usage: /dcp recompress <n>")
81
+ lines.push("")
82
+
83
+ if (availableTargets.length === 0) {
84
+ lines.push("No user-decompressed blocks are available to re-compress.")
85
+ return lines.join("\n")
86
+ }
87
+
88
+ lines.push("Available user-decompressed compressions:")
89
+ const entries = availableTargets.map((target) => {
90
+ const topic = target.topic.replace(/\s+/g, " ").trim() || "(no topic)"
91
+ const label = `${target.displayId} (${formatTokenCount(target.compressedTokens)})`
92
+ const details = target.grouped
93
+ ? `Compression #${target.runId} - ${target.blocks.length} messages`
94
+ : `Compression #${target.runId}`
95
+ return { label, topic: `${details} - ${topic}` }
96
+ })
97
+
98
+ const labelWidth = Math.max(...entries.map((entry) => entry.label.length)) + 4
99
+ for (const entry of entries) {
100
+ lines.push(` ${entry.label.padEnd(labelWidth)}${entry.topic}`)
101
+ }
102
+
103
+ return lines.join("\n")
104
+ }
105
+
106
+ export async function handleRecompressCommand(ctx: RecompressCommandContext): Promise<void> {
107
+ const { client, state, logger, sessionId, messages, args } = ctx
108
+
109
+ const params = getCurrentParams(state, messages, logger)
110
+ const targetArg = args[0]
111
+
112
+ if (args.length > 1) {
113
+ await sendIgnoredMessage(
114
+ client,
115
+ sessionId,
116
+ "Invalid arguments. Usage: /dcp recompress <n>",
117
+ params,
118
+ logger,
119
+ )
120
+ return
121
+ }
122
+
123
+ syncCompressionBlocks(state, logger, messages)
124
+ const messagesState = state.prune.messages
125
+ const availableMessageIds = new Set(messages.map((msg) => msg.info.id))
126
+
127
+ if (!targetArg) {
128
+ const availableTargets = getRecompressibleCompressionTargets(
129
+ messagesState,
130
+ availableMessageIds,
131
+ )
132
+ const message = formatAvailableBlocksMessage(availableTargets)
133
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
134
+ return
135
+ }
136
+
137
+ const targetBlockId = parseBlockIdArg(targetArg)
138
+ if (targetBlockId === null) {
139
+ await sendIgnoredMessage(
140
+ client,
141
+ sessionId,
142
+ `Please enter a compression number. Example: /dcp recompress 2`,
143
+ params,
144
+ logger,
145
+ )
146
+ return
147
+ }
148
+
149
+ const target = resolveCompressionTarget(messagesState, targetBlockId)
150
+ if (!target) {
151
+ await sendIgnoredMessage(
152
+ client,
153
+ sessionId,
154
+ `Compression ${targetBlockId} does not exist.`,
155
+ params,
156
+ logger,
157
+ )
158
+ return
159
+ }
160
+
161
+ if (target.blocks.some((block) => !availableMessageIds.has(block.compressMessageId))) {
162
+ await sendIgnoredMessage(
163
+ client,
164
+ sessionId,
165
+ `Compression ${target.displayId} can no longer be re-applied because its origin message is no longer in this session.`,
166
+ params,
167
+ logger,
168
+ )
169
+ return
170
+ }
171
+
172
+ if (!target.blocks.some((block) => block.deactivatedByUser)) {
173
+ const message = target.blocks.some((block) => block.active)
174
+ ? `Compression ${target.displayId} is already active.`
175
+ : `Compression ${target.displayId} is not user-decompressed.`
176
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
177
+ return
178
+ }
179
+
180
+ const activeMessagesBefore = snapshotActiveMessages(messagesState)
181
+ const activeBlockIdsBefore = new Set(messagesState.activeBlockIds)
182
+
183
+ for (const block of target.blocks) {
184
+ block.deactivatedByUser = false
185
+ block.deactivatedAt = undefined
186
+ block.deactivatedByBlockId = undefined
187
+ }
188
+
189
+ syncCompressionBlocks(state, logger, messages)
190
+
191
+ let recompressedMessageCount = 0
192
+ let recompressedTokens = 0
193
+ for (const [messageId, entry] of messagesState.byMessageId) {
194
+ const isActiveNow = entry.activeBlockIds.length > 0
195
+ if (isActiveNow && !activeMessagesBefore.has(messageId)) {
196
+ recompressedMessageCount++
197
+ recompressedTokens += entry.tokenCount
198
+ }
199
+ }
200
+
201
+ state.stats.totalPruneTokens += recompressedTokens
202
+
203
+ const deactivatedBlockIds = Array.from(activeBlockIdsBefore)
204
+ .filter((blockId) => !messagesState.activeBlockIds.has(blockId))
205
+ .sort((a, b) => a - b)
206
+
207
+ await saveSessionState(state, logger)
208
+
209
+ const message = formatRecompressMessage(
210
+ target,
211
+ recompressedMessageCount,
212
+ recompressedTokens,
213
+ deactivatedBlockIds,
214
+ )
215
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
216
+
217
+ logger.info("Recompress command completed", {
218
+ targetBlockId: target.displayId,
219
+ targetRunId: target.runId,
220
+ recompressedMessageCount,
221
+ recompressedTokens,
222
+ deactivatedBlockIds,
223
+ })
224
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * DCP Stats command handler.
3
+ * Shows pruning statistics for the current session and all-time totals.
4
+ */
5
+
6
+ import type { Logger } from "../logger"
7
+ import type { SessionState, WithParts } from "../state"
8
+ import { sendIgnoredMessage } from "../ui/notification"
9
+ import { formatTokenCount } from "../ui/utils"
10
+ import { loadAllSessionStats, type AggregatedStats } from "../state/persistence"
11
+ import { getCurrentParams } from "../token-utils"
12
+ import { getActiveCompressionTargets } from "./compression-targets"
13
+
14
+ export interface StatsCommandContext {
15
+ client: any
16
+ state: SessionState
17
+ logger: Logger
18
+ sessionId: string
19
+ messages: WithParts[]
20
+ }
21
+
22
+ function formatStatsMessage(
23
+ sessionTokens: number,
24
+ sessionSummaryTokens: number,
25
+ sessionTools: number,
26
+ sessionMessages: number,
27
+ sessionDurationMs: number,
28
+ allTime: AggregatedStats,
29
+ ): string {
30
+ const lines: string[] = []
31
+
32
+ lines.push("╭───────────────────────────────────────────────────────────╮")
33
+ lines.push("│ DCP Statistics │")
34
+ lines.push("╰───────────────────────────────────────────────────────────╯")
35
+ lines.push("")
36
+ lines.push("Compression:")
37
+ lines.push("─".repeat(60))
38
+ lines.push(
39
+ ` Tokens in|out: ~${formatTokenCount(sessionTokens)} | ~${formatTokenCount(sessionSummaryTokens)}`,
40
+ )
41
+ lines.push(` Ratio: ${formatCompressionRatio(sessionTokens, sessionSummaryTokens)}`)
42
+ lines.push(` Time: ${formatCompressionTime(sessionDurationMs)}`)
43
+ lines.push(` Messages: ${sessionMessages}`)
44
+ lines.push(` Tools: ${sessionTools}`)
45
+ lines.push("")
46
+ lines.push("All-time:")
47
+ lines.push("─".repeat(60))
48
+ lines.push(` Tokens saved: ~${formatTokenCount(allTime.totalTokens)}`)
49
+ lines.push(` Tools pruned: ${allTime.totalTools}`)
50
+ lines.push(` Messages pruned: ${allTime.totalMessages}`)
51
+ lines.push(` Sessions: ${allTime.sessionCount}`)
52
+
53
+ return lines.join("\n")
54
+ }
55
+
56
+ function formatCompressionRatio(inputTokens: number, outputTokens: number): string {
57
+ if (inputTokens <= 0) {
58
+ return "0:1"
59
+ }
60
+
61
+ if (outputTokens <= 0) {
62
+ return "∞:1"
63
+ }
64
+
65
+ const ratio = Math.max(1, Math.round(inputTokens / outputTokens))
66
+ return `${ratio}:1`
67
+ }
68
+
69
+ function formatCompressionTime(ms: number): string {
70
+ const safeMs = Math.max(0, Math.round(ms))
71
+ if (safeMs < 1000) {
72
+ return `${safeMs} ms`
73
+ }
74
+
75
+ const totalSeconds = safeMs / 1000
76
+ if (totalSeconds < 60) {
77
+ return `${totalSeconds.toFixed(1)} s`
78
+ }
79
+
80
+ const wholeSeconds = Math.floor(totalSeconds)
81
+ const hours = Math.floor(wholeSeconds / 3600)
82
+ const minutes = Math.floor((wholeSeconds % 3600) / 60)
83
+ const seconds = wholeSeconds % 60
84
+
85
+ if (hours > 0) {
86
+ return `${hours}h ${minutes}m ${seconds}s`
87
+ }
88
+
89
+ return `${minutes}m ${seconds}s`
90
+ }
91
+
92
+ export async function handleStatsCommand(ctx: StatsCommandContext): Promise<void> {
93
+ const { client, state, logger, sessionId, messages } = ctx
94
+
95
+ // Session stats from in-memory state
96
+ const sessionTokens = state.stats.totalPruneTokens
97
+ const sessionSummaryTokens = Array.from(state.prune.messages.blocksById.values()).reduce(
98
+ (total, block) => (block.active ? total + block.summaryTokens : total),
99
+ 0,
100
+ )
101
+ const sessionDurationMs = getActiveCompressionTargets(state.prune.messages).reduce(
102
+ (total, target) => total + target.durationMs,
103
+ 0,
104
+ )
105
+
106
+ const prunedToolIds = new Set<string>(state.prune.tools.keys())
107
+ for (const block of state.prune.messages.blocksById.values()) {
108
+ if (block.active) {
109
+ for (const toolId of block.effectiveToolIds) {
110
+ prunedToolIds.add(toolId)
111
+ }
112
+ }
113
+ }
114
+ const sessionTools = prunedToolIds.size
115
+
116
+ let sessionMessages = 0
117
+ for (const entry of state.prune.messages.byMessageId.values()) {
118
+ if (entry.activeBlockIds.length > 0) {
119
+ sessionMessages++
120
+ }
121
+ }
122
+
123
+ // All-time stats from storage files
124
+ const allTime = await loadAllSessionStats(logger)
125
+
126
+ const message = formatStatsMessage(
127
+ sessionTokens,
128
+ sessionSummaryTokens,
129
+ sessionTools,
130
+ sessionMessages,
131
+ sessionDurationMs,
132
+ allTime,
133
+ )
134
+
135
+ const params = getCurrentParams(state, messages, logger)
136
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
137
+
138
+ logger.info("Stats command executed", {
139
+ sessionTokens,
140
+ sessionSummaryTokens,
141
+ sessionTools,
142
+ sessionMessages,
143
+ sessionDurationMs,
144
+ allTimeTokens: allTime.totalTokens,
145
+ allTimeTools: allTime.totalTools,
146
+ allTimeMessages: allTime.totalMessages,
147
+ })
148
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * DCP Sweep command handler.
3
+ * Prunes tool outputs since the last user message, or the last N tools.
4
+ *
5
+ * Usage:
6
+ * /dcp sweep - Prune all tools since the previous user message
7
+ * /dcp sweep 10 - Prune the last 10 tools
8
+ */
9
+
10
+ import type { Logger } from "../logger"
11
+ import type { SessionState, WithParts, ToolParameterEntry } from "../state"
12
+ import type { PluginConfig } from "../config"
13
+ import { sendIgnoredMessage } from "../ui/notification"
14
+ import { formatPrunedItemsList } from "../ui/utils"
15
+ import { getCurrentParams, getTotalToolTokens } from "../token-utils"
16
+ import { isIgnoredUserMessage } from "../messages/query"
17
+ import { buildToolIdList } from "../messages/utils"
18
+ import { saveSessionState } from "../state/persistence"
19
+ import { isMessageCompacted } from "../state/utils"
20
+ import {
21
+ getFilePathsFromParameters,
22
+ isFilePathProtected,
23
+ isToolNameProtected,
24
+ } from "../protected-patterns"
25
+ import { syncToolCache } from "../state/tool-cache"
26
+
27
+ export interface SweepCommandContext {
28
+ client: any
29
+ state: SessionState
30
+ config: PluginConfig
31
+ logger: Logger
32
+ sessionId: string
33
+ messages: WithParts[]
34
+ args: string[]
35
+ workingDirectory: string
36
+ }
37
+
38
+ function findLastUserMessageIndex(messages: WithParts[]): number {
39
+ for (let i = messages.length - 1; i >= 0; i--) {
40
+ const msg = messages[i]
41
+ if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) {
42
+ return i
43
+ }
44
+ }
45
+
46
+ return -1
47
+ }
48
+
49
+ function collectToolIdsAfterIndex(
50
+ state: SessionState,
51
+ messages: WithParts[],
52
+ afterIndex: number,
53
+ ): string[] {
54
+ const toolIds: string[] = []
55
+
56
+ for (let i = afterIndex + 1; i < messages.length; i++) {
57
+ const msg = messages[i]
58
+ if (isMessageCompacted(state, msg)) {
59
+ continue
60
+ }
61
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
62
+ if (parts.length > 0) {
63
+ for (const part of parts) {
64
+ if (part.type === "tool" && part.callID && part.tool) {
65
+ toolIds.push(part.callID)
66
+ }
67
+ }
68
+ }
69
+ }
70
+
71
+ return toolIds
72
+ }
73
+
74
+ function formatNoUserMessage(): string {
75
+ const lines: string[] = []
76
+
77
+ lines.push("╭───────────────────────────────────────────────────────────╮")
78
+ lines.push("│ DCP Sweep │")
79
+ lines.push("╰───────────────────────────────────────────────────────────╯")
80
+ lines.push("")
81
+ lines.push("Nothing swept: no user message found.")
82
+
83
+ return lines.join("\n")
84
+ }
85
+
86
+ function formatSweepMessage(
87
+ toolCount: number,
88
+ tokensSaved: number,
89
+ mode: "since-user" | "last-n",
90
+ toolIds: string[],
91
+ toolMetadata: Map<string, ToolParameterEntry>,
92
+ workingDirectory?: string,
93
+ skippedProtected?: number,
94
+ ): string {
95
+ const lines: string[] = []
96
+
97
+ lines.push("╭───────────────────────────────────────────────────────────╮")
98
+ lines.push("│ DCP Sweep │")
99
+ lines.push("╰───────────────────────────────────────────────────────────╯")
100
+ lines.push("")
101
+
102
+ if (toolCount === 0) {
103
+ if (mode === "since-user") {
104
+ lines.push("No tools found since the previous user message.")
105
+ } else {
106
+ lines.push(`No tools found to sweep.`)
107
+ }
108
+ if (skippedProtected && skippedProtected > 0) {
109
+ lines.push(`(${skippedProtected} protected tool(s) skipped)`)
110
+ }
111
+ } else {
112
+ if (mode === "since-user") {
113
+ lines.push(`Swept ${toolCount} tool(s) since the previous user message.`)
114
+ } else {
115
+ lines.push(`Swept the last ${toolCount} tool(s).`)
116
+ }
117
+ lines.push(`Tokens saved: ~${tokensSaved.toLocaleString()}`)
118
+ if (skippedProtected && skippedProtected > 0) {
119
+ lines.push(`(${skippedProtected} protected tool(s) skipped)`)
120
+ }
121
+ lines.push("")
122
+ const itemLines = formatPrunedItemsList(toolIds, toolMetadata, workingDirectory)
123
+ lines.push(...itemLines)
124
+ }
125
+
126
+ return lines.join("\n")
127
+ }
128
+
129
+ export async function handleSweepCommand(ctx: SweepCommandContext): Promise<void> {
130
+ const { client, state, config, logger, sessionId, messages, args, workingDirectory } = ctx
131
+
132
+ const params = getCurrentParams(state, messages, logger)
133
+ const protectedTools = config.commands.protectedTools
134
+
135
+ syncToolCache(state, config, logger, messages)
136
+ buildToolIdList(state, messages)
137
+
138
+ // Parse optional numeric argument
139
+ const numArg = args[0] ? parseInt(args[0], 10) : null
140
+ const isLastNMode = numArg !== null && !isNaN(numArg) && numArg > 0
141
+
142
+ let toolIdsToSweep: string[]
143
+ let mode: "since-user" | "last-n"
144
+
145
+ if (isLastNMode) {
146
+ // Mode: Sweep last N tools
147
+ mode = "last-n"
148
+ const startIndex = Math.max(0, state.toolIdList.length - numArg!)
149
+ toolIdsToSweep = state.toolIdList.slice(startIndex)
150
+ logger.info(`Sweep command: last ${numArg} mode, found ${toolIdsToSweep.length} tools`)
151
+ } else {
152
+ // Mode: Sweep since last user message
153
+ mode = "since-user"
154
+ const lastUserMsgIndex = findLastUserMessageIndex(messages)
155
+
156
+ if (lastUserMsgIndex === -1) {
157
+ // No user message found - show message and return
158
+ const message = formatNoUserMessage()
159
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
160
+ logger.info("Sweep command: no user message found")
161
+ return
162
+ } else {
163
+ toolIdsToSweep = collectToolIdsAfterIndex(state, messages, lastUserMsgIndex)
164
+ logger.info(
165
+ `Sweep command: found last user at index ${lastUserMsgIndex}, sweeping ${toolIdsToSweep.length} tools`,
166
+ )
167
+ }
168
+ }
169
+
170
+ // Filter out already-pruned tools, protected tools, and protected file paths
171
+ const newToolIds = toolIdsToSweep.filter((id) => {
172
+ if (state.prune.tools.has(id)) {
173
+ return false
174
+ }
175
+ const entry = state.toolParameters.get(id)
176
+ if (!entry) {
177
+ return true
178
+ }
179
+ if (isToolNameProtected(entry.tool, protectedTools)) {
180
+ logger.debug(`Sweep: skipping protected tool ${entry.tool} (${id})`)
181
+ return false
182
+ }
183
+ const filePaths = getFilePathsFromParameters(entry.tool, entry.parameters)
184
+ if (isFilePathProtected(filePaths, config.protectedFilePatterns)) {
185
+ logger.debug(`Sweep: skipping protected file path(s) ${filePaths.join(", ")} (${id})`)
186
+ return false
187
+ }
188
+ return true
189
+ })
190
+
191
+ // Count how many were skipped due to protection
192
+ const skippedProtected = toolIdsToSweep.filter((id) => {
193
+ const entry = state.toolParameters.get(id)
194
+ if (!entry) {
195
+ return false
196
+ }
197
+ if (isToolNameProtected(entry.tool, protectedTools)) {
198
+ return true
199
+ }
200
+ const filePaths = getFilePathsFromParameters(entry.tool, entry.parameters)
201
+ if (isFilePathProtected(filePaths, config.protectedFilePatterns)) {
202
+ return true
203
+ }
204
+ return false
205
+ }).length
206
+
207
+ if (newToolIds.length === 0) {
208
+ const message = formatSweepMessage(
209
+ 0,
210
+ 0,
211
+ mode,
212
+ [],
213
+ new Map(),
214
+ workingDirectory,
215
+ skippedProtected,
216
+ )
217
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
218
+ logger.info("Sweep command: no new tools to sweep", { skippedProtected })
219
+ return
220
+ }
221
+
222
+ const tokensSaved = getTotalToolTokens(state, newToolIds)
223
+
224
+ // Add to prune list
225
+ for (const id of newToolIds) {
226
+ const entry = state.toolParameters.get(id)
227
+ state.prune.tools.set(id, entry?.tokenCount ?? 0)
228
+ }
229
+ state.stats.pruneTokenCounter += tokensSaved
230
+ state.stats.totalPruneTokens += state.stats.pruneTokenCounter
231
+ state.stats.pruneTokenCounter = 0
232
+
233
+ // Collect metadata for logging
234
+ const toolMetadata: Map<string, ToolParameterEntry> = new Map()
235
+ for (const id of newToolIds) {
236
+ const entry = state.toolParameters.get(id)
237
+ if (entry) {
238
+ toolMetadata.set(id, entry)
239
+ }
240
+ }
241
+
242
+ // Persist state
243
+ saveSessionState(state, logger).catch((err) =>
244
+ logger.error("Failed to persist state after sweep", { error: err.message }),
245
+ )
246
+
247
+ const message = formatSweepMessage(
248
+ newToolIds.length,
249
+ tokensSaved,
250
+ mode,
251
+ newToolIds,
252
+ toolMetadata,
253
+ workingDirectory,
254
+ skippedProtected,
255
+ )
256
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
257
+
258
+ logger.info("Sweep command completed", {
259
+ toolsSwept: newToolIds.length,
260
+ tokensSaved,
261
+ skippedProtected,
262
+ mode,
263
+ tools: Array.from(toolMetadata.entries()).map(([id, entry]) => ({
264
+ id,
265
+ tool: entry.tool,
266
+ })),
267
+ })
268
+ }
@@ -0,0 +1,3 @@
1
+ export { ToolContext } from "./types"
2
+ export { createCompressMessageTool } from "./message"
3
+ export { createCompressRangeTool } from "./range"