openrecall 0.3.0 → 0.4.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 (45) hide show
  1. package/package.json +8 -2
  2. package/src/dcp/auth.ts +37 -0
  3. package/src/dcp/commands/context.ts +265 -0
  4. package/src/dcp/commands/help.ts +73 -0
  5. package/src/dcp/commands/manual.ts +131 -0
  6. package/src/dcp/commands/stats.ts +73 -0
  7. package/src/dcp/commands/sweep.ts +263 -0
  8. package/src/dcp/config.ts +981 -0
  9. package/src/dcp/hooks.ts +224 -0
  10. package/src/dcp/index.ts +123 -0
  11. package/src/dcp/logger.ts +211 -0
  12. package/src/dcp/messages/index.ts +2 -0
  13. package/src/dcp/messages/inject.ts +316 -0
  14. package/src/dcp/messages/prune.ts +217 -0
  15. package/src/dcp/messages/utils.ts +269 -0
  16. package/src/dcp/prompts/_codegen/compress-nudge.generated.ts +15 -0
  17. package/src/dcp/prompts/_codegen/compress.generated.ts +56 -0
  18. package/src/dcp/prompts/_codegen/distill.generated.ts +33 -0
  19. package/src/dcp/prompts/_codegen/nudge.generated.ts +17 -0
  20. package/src/dcp/prompts/_codegen/prune.generated.ts +23 -0
  21. package/src/dcp/prompts/_codegen/system.generated.ts +57 -0
  22. package/src/dcp/prompts/index.ts +59 -0
  23. package/src/dcp/protected-file-patterns.ts +113 -0
  24. package/src/dcp/shared-utils.ts +26 -0
  25. package/src/dcp/state/index.ts +3 -0
  26. package/src/dcp/state/persistence.ts +196 -0
  27. package/src/dcp/state/state.ts +143 -0
  28. package/src/dcp/state/tool-cache.ts +112 -0
  29. package/src/dcp/state/types.ts +55 -0
  30. package/src/dcp/state/utils.ts +55 -0
  31. package/src/dcp/strategies/deduplication.ts +123 -0
  32. package/src/dcp/strategies/index.ts +4 -0
  33. package/src/dcp/strategies/purge-errors.ts +84 -0
  34. package/src/dcp/strategies/supersede-writes.ts +115 -0
  35. package/src/dcp/strategies/utils.ts +135 -0
  36. package/src/dcp/tools/compress.ts +218 -0
  37. package/src/dcp/tools/distill.ts +60 -0
  38. package/src/dcp/tools/index.ts +4 -0
  39. package/src/dcp/tools/prune-shared.ts +174 -0
  40. package/src/dcp/tools/prune.ts +36 -0
  41. package/src/dcp/tools/types.ts +11 -0
  42. package/src/dcp/tools/utils.ts +244 -0
  43. package/src/dcp/ui/notification.ts +273 -0
  44. package/src/dcp/ui/utils.ts +133 -0
  45. package/src/index.ts +80 -50
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openrecall",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Cross-session memory plugin for OpenCode with full-text search, tagging, and auto-recall",
5
5
  "module": "src/index.ts",
6
6
  "main": "src/index.ts",
@@ -40,7 +40,13 @@
40
40
  "license": "MIT",
41
41
  "author": "ASidorenkoCode",
42
42
  "dependencies": {
43
- "@opencode-ai/plugin": "^1.2.2"
43
+ "@anthropic-ai/tokenizer": "^0.0.4",
44
+ "@opencode-ai/plugin": "^1.2.2",
45
+ "@opencode-ai/sdk": "^1.1.48",
46
+ "fuzzball": "^2.2.3",
47
+ "jsonc-parser": "^3.3.1",
48
+ "ulid": "^3.0.2",
49
+ "zod": "^4.3.6"
44
50
  },
45
51
  "devDependencies": {
46
52
  "@types/bun": "latest"
@@ -0,0 +1,37 @@
1
+ export function isSecureMode(): boolean {
2
+ return !!process.env.OPENCODE_SERVER_PASSWORD
3
+ }
4
+
5
+ export function getAuthorizationHeader(): string | undefined {
6
+ const password = process.env.OPENCODE_SERVER_PASSWORD
7
+ if (!password) return undefined
8
+
9
+ const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
10
+ // Use Buffer for Node.js base64 encoding (btoa may not be available in all Node versions)
11
+ const credentials = Buffer.from(`${username}:${password}`).toString("base64")
12
+ return `Basic ${credentials}`
13
+ }
14
+
15
+ export function configureClientAuth(client: any): any {
16
+ const authHeader = getAuthorizationHeader()
17
+
18
+ if (!authHeader) {
19
+ return client
20
+ }
21
+
22
+ // The SDK client has an internal client with request interceptors
23
+ // Access the underlying client to add the interceptor
24
+ const innerClient = client._client || client.client
25
+
26
+ if (innerClient?.interceptors?.request) {
27
+ innerClient.interceptors.request.use((request: Request) => {
28
+ // Only add auth header if not already present
29
+ if (!request.headers.has("Authorization")) {
30
+ request.headers.set("Authorization", authHeader)
31
+ }
32
+ return request
33
+ })
34
+ }
35
+
36
+ return client
37
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * DCP Context Command
3
+ * Shows a visual breakdown of token usage in the current session.
4
+ *
5
+ * TOKEN CALCULATION STRATEGY
6
+ * ==========================
7
+ * We minimize tokenizer estimation by leveraging API-reported values wherever possible.
8
+ *
9
+ * WHAT WE GET FROM THE API (exact):
10
+ * - tokens.input : Input tokens for each assistant response
11
+ * - tokens.output : Output tokens generated (includes text + tool calls)
12
+ * - tokens.reasoning: Reasoning tokens used
13
+ * - tokens.cache : Cache read/write tokens
14
+ *
15
+ * HOW WE CALCULATE EACH CATEGORY:
16
+ *
17
+ * SYSTEM = firstAssistant.input + cache.read - tokenizer(firstUserMessage)
18
+ * The first response's input contains system + first user message.
19
+ *
20
+ * TOOLS = tokenizer(toolInputs + toolOutputs) - prunedTokens
21
+ * We must tokenize tools anyway for pruning decisions.
22
+ *
23
+ * USER = tokenizer(all user messages)
24
+ * User messages are typically small, so estimation is acceptable.
25
+ *
26
+ * ASSISTANT = total - system - user - tools
27
+ * Calculated as residual. This absorbs:
28
+ * - Assistant text output tokens
29
+ * - Reasoning tokens (if persisted by the model)
30
+ * - Any estimation errors
31
+ *
32
+ * TOTAL = input + output + reasoning + cache.read + cache.write
33
+ * Matches opencode's UI display.
34
+ *
35
+ * WHY ASSISTANT IS THE RESIDUAL:
36
+ * If reasoning tokens persist in context (model-dependent), they semantically
37
+ * belong with "Assistant" since reasoning IS assistant-generated content.
38
+ */
39
+
40
+ import type { Logger } from "../logger"
41
+ import type { SessionState, WithParts } from "../state"
42
+ import { sendIgnoredMessage } from "../ui/notification"
43
+ import { formatTokenCount } from "../ui/utils"
44
+ import { isMessageCompacted } from "../shared-utils"
45
+ import { isIgnoredUserMessage } from "../messages/utils"
46
+ import { countTokens, getCurrentParams } from "../strategies/utils"
47
+ import type { AssistantMessage, TextPart, ToolPart } from "@opencode-ai/sdk/v2"
48
+
49
+ export interface ContextCommandContext {
50
+ client: any
51
+ state: SessionState
52
+ logger: Logger
53
+ sessionId: string
54
+ messages: WithParts[]
55
+ }
56
+
57
+ interface TokenBreakdown {
58
+ system: number
59
+ user: number
60
+ assistant: number
61
+ tools: number
62
+ toolCount: number
63
+ prunedTokens: number
64
+ prunedToolCount: number
65
+ prunedMessageCount: number
66
+ total: number
67
+ }
68
+
69
+ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdown {
70
+ const breakdown: TokenBreakdown = {
71
+ system: 0,
72
+ user: 0,
73
+ assistant: 0,
74
+ tools: 0,
75
+ toolCount: 0,
76
+ prunedTokens: state.stats.totalPruneTokens,
77
+ prunedToolCount: state.prune.tools.size,
78
+ prunedMessageCount: state.prune.messages.size,
79
+ total: 0,
80
+ }
81
+
82
+ let firstAssistant: AssistantMessage | undefined
83
+ for (const msg of messages) {
84
+ if (msg.info.role === "assistant") {
85
+ const assistantInfo = msg.info as AssistantMessage
86
+ if (assistantInfo.tokens?.input > 0 || assistantInfo.tokens?.cache?.read > 0) {
87
+ firstAssistant = assistantInfo
88
+ break
89
+ }
90
+ }
91
+ }
92
+
93
+ let lastAssistant: AssistantMessage | undefined
94
+ for (let i = messages.length - 1; i >= 0; i--) {
95
+ const msg = messages[i]!
96
+ if (msg.info.role === "assistant") {
97
+ const assistantInfo = msg.info as AssistantMessage
98
+ if (assistantInfo.tokens?.output > 0) {
99
+ lastAssistant = assistantInfo
100
+ break
101
+ }
102
+ }
103
+ }
104
+
105
+ const apiInput = lastAssistant?.tokens?.input || 0
106
+ const apiOutput = lastAssistant?.tokens?.output || 0
107
+ const apiReasoning = lastAssistant?.tokens?.reasoning || 0
108
+ const apiCacheRead = lastAssistant?.tokens?.cache?.read || 0
109
+ const apiCacheWrite = lastAssistant?.tokens?.cache?.write || 0
110
+ breakdown.total = apiInput + apiOutput + apiReasoning + apiCacheRead + apiCacheWrite
111
+
112
+ const userTextParts: string[] = []
113
+ const toolInputParts: string[] = []
114
+ const toolOutputParts: string[] = []
115
+ let firstUserText = ""
116
+ let foundFirstUser = false
117
+ const foundToolIds = new Set<string>()
118
+
119
+ for (const msg of messages) {
120
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
121
+ const isCompacted = isMessageCompacted(state, msg)
122
+ const isIgnoredUser = msg.info.role === "user" && isIgnoredUserMessage(msg)
123
+
124
+ for (const part of parts) {
125
+ if (part.type === "tool") {
126
+ const toolPart = part as ToolPart
127
+ if (toolPart.callID && !foundToolIds.has(toolPart.callID)) {
128
+ breakdown.toolCount++
129
+ foundToolIds.add(toolPart.callID)
130
+ }
131
+
132
+ const isPruned = toolPart.callID && state.prune.tools.has(toolPart.callID)
133
+ if (!isCompacted && !isPruned) {
134
+ if (toolPart.state?.input) {
135
+ const inputStr =
136
+ typeof toolPart.state.input === "string"
137
+ ? toolPart.state.input
138
+ : JSON.stringify(toolPart.state.input)
139
+ toolInputParts.push(inputStr)
140
+ }
141
+
142
+ if (toolPart.state?.status === "completed" && toolPart.state?.output) {
143
+ const outputStr =
144
+ typeof toolPart.state.output === "string"
145
+ ? toolPart.state.output
146
+ : JSON.stringify(toolPart.state.output)
147
+ toolOutputParts.push(outputStr)
148
+ }
149
+ }
150
+ } else if (
151
+ part.type === "text" &&
152
+ msg.info.role === "user" &&
153
+ !isCompacted &&
154
+ !isIgnoredUser
155
+ ) {
156
+ const textPart = part as TextPart
157
+ const text = textPart.text || ""
158
+ userTextParts.push(text)
159
+ if (!foundFirstUser) {
160
+ firstUserText += text
161
+ }
162
+ }
163
+ }
164
+
165
+ if (msg.info.role === "user" && !isIgnoredUser && !foundFirstUser) {
166
+ foundFirstUser = true
167
+ }
168
+ }
169
+
170
+ const firstUserTokens = countTokens(firstUserText)
171
+ breakdown.user = countTokens(userTextParts.join("\n"))
172
+ const toolInputTokens = countTokens(toolInputParts.join("\n"))
173
+ const toolOutputTokens = countTokens(toolOutputParts.join("\n"))
174
+
175
+ if (firstAssistant) {
176
+ const firstInput =
177
+ (firstAssistant.tokens?.input || 0) + (firstAssistant.tokens?.cache?.read || 0)
178
+ breakdown.system = Math.max(0, firstInput - firstUserTokens)
179
+ }
180
+
181
+ breakdown.tools = toolInputTokens + toolOutputTokens
182
+ breakdown.assistant = Math.max(
183
+ 0,
184
+ breakdown.total - breakdown.system - breakdown.user - breakdown.tools,
185
+ )
186
+
187
+ return breakdown
188
+ }
189
+
190
+ function createBar(value: number, maxValue: number, width: number, char: string = "█"): string {
191
+ if (maxValue === 0) return ""
192
+ const filled = Math.round((value / maxValue) * width)
193
+ const bar = char.repeat(Math.max(0, filled))
194
+ return bar
195
+ }
196
+
197
+ function formatContextMessage(breakdown: TokenBreakdown): string {
198
+ const lines: string[] = []
199
+ const barWidth = 30
200
+
201
+ const toolsInContext = breakdown.toolCount - breakdown.prunedToolCount
202
+ const toolsLabel = `Tools (${toolsInContext})`
203
+
204
+ const categories = [
205
+ { label: "System", value: breakdown.system, char: "█" },
206
+ { label: "User", value: breakdown.user, char: "▓" },
207
+ { label: "Assistant", value: breakdown.assistant, char: "▒" },
208
+ { label: toolsLabel, value: breakdown.tools, char: "░" },
209
+ ] as const
210
+
211
+ const maxLabelLen = Math.max(...categories.map((c) => c.label.length))
212
+
213
+ lines.push("╭───────────────────────────────────────────────────────────╮")
214
+ lines.push("│ DCP Context Analysis │")
215
+ lines.push("╰───────────────────────────────────────────────────────────╯")
216
+ lines.push("")
217
+ lines.push("Session Context Breakdown:")
218
+ lines.push("─".repeat(60))
219
+ lines.push("")
220
+
221
+ for (const cat of categories) {
222
+ const bar = createBar(cat.value, breakdown.total, barWidth, cat.char)
223
+ const percentage =
224
+ breakdown.total > 0 ? ((cat.value / breakdown.total) * 100).toFixed(1) : "0.0"
225
+ const labelWithPct = `${cat.label.padEnd(maxLabelLen)} ${percentage.padStart(5)}% `
226
+ const valueStr = formatTokenCount(cat.value).padStart(13)
227
+ lines.push(`${labelWithPct}│${bar.padEnd(barWidth)}│${valueStr}`)
228
+ }
229
+
230
+ lines.push("")
231
+ lines.push("─".repeat(60))
232
+ lines.push("")
233
+
234
+ lines.push("Summary:")
235
+
236
+ if (breakdown.prunedTokens > 0) {
237
+ const withoutPruning = breakdown.total + breakdown.prunedTokens
238
+ const pruned = []
239
+ if (breakdown.prunedToolCount > 0) pruned.push(`${breakdown.prunedToolCount} tools`)
240
+ if (breakdown.prunedMessageCount > 0)
241
+ pruned.push(`${breakdown.prunedMessageCount} messages`)
242
+ lines.push(
243
+ ` Pruned: ${pruned.join(", ")} (~${formatTokenCount(breakdown.prunedTokens)})`,
244
+ )
245
+ lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`)
246
+ lines.push(` Without DCP: ~${formatTokenCount(withoutPruning)}`)
247
+ } else {
248
+ lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`)
249
+ }
250
+
251
+ lines.push("")
252
+
253
+ return lines.join("\n")
254
+ }
255
+
256
+ export async function handleContextCommand(ctx: ContextCommandContext): Promise<void> {
257
+ const { client, state, logger, sessionId, messages } = ctx
258
+
259
+ const breakdown = analyzeTokens(state, messages)
260
+
261
+ const message = formatContextMessage(breakdown)
262
+
263
+ const params = getCurrentParams(state, messages, logger)
264
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
265
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * DCP Help command handler.
3
+ * Shows available DCP commands and their descriptions.
4
+ */
5
+
6
+ import type { Logger } from "../logger"
7
+ import type { PluginConfig } from "../config"
8
+ import type { SessionState, WithParts } from "../state"
9
+ import { sendIgnoredMessage } from "../ui/notification"
10
+ import { getCurrentParams } from "../strategies/utils"
11
+
12
+ export interface HelpCommandContext {
13
+ client: any
14
+ state: SessionState
15
+ config: PluginConfig
16
+ logger: Logger
17
+ sessionId: string
18
+ messages: WithParts[]
19
+ }
20
+
21
+ const BASE_COMMANDS: [string, string][] = [
22
+ ["/dcp context", "Show token usage breakdown for current session"],
23
+ ["/dcp stats", "Show DCP pruning statistics"],
24
+ ["/dcp sweep [n]", "Prune tools since last user message, or last n tools"],
25
+ ["/dcp manual [on|off]", "Toggle manual mode or set explicit state"],
26
+ ]
27
+
28
+ const TOOL_COMMANDS: Record<string, [string, string]> = {
29
+ prune: ["/dcp prune [focus]", "Trigger manual prune tool execution"],
30
+ distill: ["/dcp distill [focus]", "Trigger manual distill tool execution"],
31
+ compress: ["/dcp compress [focus]", "Trigger manual compress tool execution"],
32
+ }
33
+
34
+ function getVisibleCommands(config: PluginConfig): [string, string][] {
35
+ const commands = [...BASE_COMMANDS]
36
+ for (const tool of ["prune", "distill", "compress"] as const) {
37
+ if (config.tools[tool].permission !== "deny") {
38
+ commands.push(TOOL_COMMANDS[tool]!)
39
+ }
40
+ }
41
+ return commands
42
+ }
43
+
44
+ function formatHelpMessage(manualMode: boolean, config: PluginConfig): string {
45
+ const commands = getVisibleCommands(config)
46
+ const colWidth = Math.max(...commands.map(([cmd]) => cmd.length)) + 4
47
+ const lines: string[] = []
48
+
49
+ lines.push("╭─────────────────────────────────────────────────────────────────────────╮")
50
+ lines.push("│ DCP Commands │")
51
+ lines.push("╰─────────────────────────────────────────────────────────────────────────╯")
52
+ lines.push("")
53
+ lines.push(` ${"Manual mode:".padEnd(colWidth)}${manualMode ? "ON" : "OFF"}`)
54
+ lines.push("")
55
+ for (const [cmd, desc] of commands) {
56
+ lines.push(` ${cmd.padEnd(colWidth)}${desc}`)
57
+ }
58
+ lines.push("")
59
+
60
+ return lines.join("\n")
61
+ }
62
+
63
+ export async function handleHelpCommand(ctx: HelpCommandContext): Promise<void> {
64
+ const { client, state, logger, sessionId, messages } = ctx
65
+
66
+ const { config } = ctx
67
+ const message = formatHelpMessage(state.manualMode, config)
68
+
69
+ const params = getCurrentParams(state, messages, logger)
70
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
71
+
72
+ logger.info("Help command executed")
73
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * DCP Manual mode command handler.
3
+ * Handles toggling manual mode and triggering individual tool executions.
4
+ *
5
+ * Usage:
6
+ * /dcp manual [on|off] - Toggle manual mode or set explicit state
7
+ * /dcp prune [focus] - Trigger manual prune execution
8
+ * /dcp distill [focus] - Trigger manual distill execution
9
+ * /dcp compress [focus] - Trigger manual compress execution
10
+ */
11
+
12
+ import type { Logger } from "../logger"
13
+ import type { SessionState, WithParts } from "../state"
14
+ import type { PluginConfig } from "../config"
15
+ import { sendIgnoredMessage } from "../ui/notification"
16
+ import { getCurrentParams } from "../strategies/utils"
17
+ import { syncToolCache } from "../state/tool-cache"
18
+ import { buildToolIdList } from "../messages/utils"
19
+ import { buildPrunableToolsList } from "../messages/inject"
20
+
21
+ const MANUAL_MODE_ON =
22
+ "Manual mode is now ON. Use /dcp prune, /dcp distill, or /dcp compress to trigger context tools manually."
23
+
24
+ const MANUAL_MODE_OFF = "Manual mode is now OFF."
25
+
26
+ const NO_PRUNABLE_TOOLS = "No prunable tool outputs are currently available for manual triggering."
27
+
28
+ const PRUNE_TRIGGER_PROMPT = [
29
+ "<prune triggered manually>",
30
+ "Manual mode trigger received. You must now use the prune tool exactly once.",
31
+ "Find the most significant set of prunable tool outputs to remove safely.",
32
+ "Follow prune policy and avoid pruning outputs that may be needed later.",
33
+ "Return after prune with a brief explanation of what you pruned and why.",
34
+ ].join("\n\n")
35
+
36
+ const DISTILL_TRIGGER_PROMPT = [
37
+ "<distill triggered manually>",
38
+ "Manual mode trigger received. You must now use the distill tool.",
39
+ "Select the most information-dense prunable outputs and distill them into complete technical substitutes.",
40
+ "Be exhaustive and preserve all critical technical details.",
41
+ "Return after distill with a brief explanation of what was distilled and why.",
42
+ ].join("\n\n")
43
+
44
+ const COMPRESS_TRIGGER_PROMPT = [
45
+ "<compress triggered manually>",
46
+ "Manual mode trigger received. You must now use the compress tool.",
47
+ "Find the most significant completed section of the conversation that can be compressed into a high-fidelity technical summary.",
48
+ "Choose safe boundaries and preserve all critical implementation details.",
49
+ "Return after compress with a brief explanation of what range was compressed.",
50
+ ].join("\n\n")
51
+
52
+ function getTriggerPrompt(
53
+ tool: "prune" | "distill" | "compress",
54
+ context?: string,
55
+ userFocus?: string,
56
+ ): string {
57
+ const base =
58
+ tool === "prune"
59
+ ? PRUNE_TRIGGER_PROMPT
60
+ : tool === "distill"
61
+ ? DISTILL_TRIGGER_PROMPT
62
+ : COMPRESS_TRIGGER_PROMPT
63
+
64
+ const sections = [base]
65
+ if (userFocus && userFocus.trim().length > 0) {
66
+ sections.push(`Additional user focus:\n${userFocus.trim()}`)
67
+ }
68
+ if (context) {
69
+ sections.push(context)
70
+ }
71
+
72
+ return sections.join("\n\n")
73
+ }
74
+
75
+ export interface ManualCommandContext {
76
+ client: any
77
+ state: SessionState
78
+ config: PluginConfig
79
+ logger: Logger
80
+ sessionId: string
81
+ messages: WithParts[]
82
+ }
83
+
84
+ export async function handleManualToggleCommand(
85
+ ctx: ManualCommandContext,
86
+ modeArg?: string,
87
+ ): Promise<void> {
88
+ const { client, state, logger, sessionId, messages } = ctx
89
+
90
+ if (modeArg === "on") {
91
+ state.manualMode = true
92
+ } else if (modeArg === "off") {
93
+ state.manualMode = false
94
+ } else {
95
+ state.manualMode = !state.manualMode
96
+ }
97
+
98
+ const params = getCurrentParams(state, messages, logger)
99
+ await sendIgnoredMessage(
100
+ client,
101
+ sessionId,
102
+ state.manualMode ? MANUAL_MODE_ON : MANUAL_MODE_OFF,
103
+ params,
104
+ logger,
105
+ )
106
+
107
+ logger.info("Manual mode toggled", { manualMode: state.manualMode })
108
+ }
109
+
110
+ export async function handleManualTriggerCommand(
111
+ ctx: ManualCommandContext,
112
+ tool: "prune" | "distill" | "compress",
113
+ userFocus?: string,
114
+ ): Promise<string | null> {
115
+ const { client, state, config, logger, sessionId, messages } = ctx
116
+
117
+ if (tool === "prune" || tool === "distill") {
118
+ syncToolCache(state, config, logger, messages)
119
+ buildToolIdList(state, messages, logger)
120
+ const prunableToolsList = buildPrunableToolsList(state, config, logger)
121
+ if (!prunableToolsList) {
122
+ const params = getCurrentParams(state, messages, logger)
123
+ await sendIgnoredMessage(client, sessionId, NO_PRUNABLE_TOOLS, params, logger)
124
+ return null
125
+ }
126
+
127
+ return getTriggerPrompt(tool, prunableToolsList, userFocus)
128
+ }
129
+
130
+ return getTriggerPrompt("compress", undefined, userFocus)
131
+ }
@@ -0,0 +1,73 @@
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 "../strategies/utils"
12
+
13
+ export interface StatsCommandContext {
14
+ client: any
15
+ state: SessionState
16
+ logger: Logger
17
+ sessionId: string
18
+ messages: WithParts[]
19
+ }
20
+
21
+ function formatStatsMessage(
22
+ sessionTokens: number,
23
+ sessionTools: number,
24
+ sessionMessages: number,
25
+ allTime: AggregatedStats,
26
+ ): string {
27
+ const lines: string[] = []
28
+
29
+ lines.push("╭───────────────────────────────────────────────────────────╮")
30
+ lines.push("│ DCP Statistics │")
31
+ lines.push("╰───────────────────────────────────────────────────────────╯")
32
+ lines.push("")
33
+ lines.push("Session:")
34
+ lines.push("─".repeat(60))
35
+ lines.push(` Tokens pruned: ~${formatTokenCount(sessionTokens)}`)
36
+ lines.push(` Tools pruned: ${sessionTools}`)
37
+ lines.push(` Messages pruned: ${sessionMessages}`)
38
+ lines.push("")
39
+ lines.push("All-time:")
40
+ lines.push("─".repeat(60))
41
+ lines.push(` Tokens saved: ~${formatTokenCount(allTime.totalTokens)}`)
42
+ lines.push(` Tools pruned: ${allTime.totalTools}`)
43
+ lines.push(` Messages pruned: ${allTime.totalMessages}`)
44
+ lines.push(` Sessions: ${allTime.sessionCount}`)
45
+
46
+ return lines.join("\n")
47
+ }
48
+
49
+ export async function handleStatsCommand(ctx: StatsCommandContext): Promise<void> {
50
+ const { client, state, logger, sessionId, messages } = ctx
51
+
52
+ // Session stats from in-memory state
53
+ const sessionTokens = state.stats.totalPruneTokens
54
+ const sessionTools = state.prune.tools.size
55
+ const sessionMessages = state.prune.messages.size
56
+
57
+ // All-time stats from storage files
58
+ const allTime = await loadAllSessionStats(logger)
59
+
60
+ const message = formatStatsMessage(sessionTokens, sessionTools, sessionMessages, allTime)
61
+
62
+ const params = getCurrentParams(state, messages, logger)
63
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
64
+
65
+ logger.info("Stats command executed", {
66
+ sessionTokens,
67
+ sessionTools,
68
+ sessionMessages,
69
+ allTimeTokens: allTime.totalTokens,
70
+ allTimeTools: allTime.totalTools,
71
+ allTimeMessages: allTime.totalMessages,
72
+ })
73
+ }