openrecall 0.2.2 → 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 (46) hide show
  1. package/package.json +8 -2
  2. package/src/agent.ts +97 -11
  3. package/src/dcp/auth.ts +37 -0
  4. package/src/dcp/commands/context.ts +265 -0
  5. package/src/dcp/commands/help.ts +73 -0
  6. package/src/dcp/commands/manual.ts +131 -0
  7. package/src/dcp/commands/stats.ts +73 -0
  8. package/src/dcp/commands/sweep.ts +263 -0
  9. package/src/dcp/config.ts +981 -0
  10. package/src/dcp/hooks.ts +224 -0
  11. package/src/dcp/index.ts +123 -0
  12. package/src/dcp/logger.ts +211 -0
  13. package/src/dcp/messages/index.ts +2 -0
  14. package/src/dcp/messages/inject.ts +316 -0
  15. package/src/dcp/messages/prune.ts +217 -0
  16. package/src/dcp/messages/utils.ts +269 -0
  17. package/src/dcp/prompts/_codegen/compress-nudge.generated.ts +15 -0
  18. package/src/dcp/prompts/_codegen/compress.generated.ts +56 -0
  19. package/src/dcp/prompts/_codegen/distill.generated.ts +33 -0
  20. package/src/dcp/prompts/_codegen/nudge.generated.ts +17 -0
  21. package/src/dcp/prompts/_codegen/prune.generated.ts +23 -0
  22. package/src/dcp/prompts/_codegen/system.generated.ts +57 -0
  23. package/src/dcp/prompts/index.ts +59 -0
  24. package/src/dcp/protected-file-patterns.ts +113 -0
  25. package/src/dcp/shared-utils.ts +26 -0
  26. package/src/dcp/state/index.ts +3 -0
  27. package/src/dcp/state/persistence.ts +196 -0
  28. package/src/dcp/state/state.ts +143 -0
  29. package/src/dcp/state/tool-cache.ts +112 -0
  30. package/src/dcp/state/types.ts +55 -0
  31. package/src/dcp/state/utils.ts +55 -0
  32. package/src/dcp/strategies/deduplication.ts +123 -0
  33. package/src/dcp/strategies/index.ts +4 -0
  34. package/src/dcp/strategies/purge-errors.ts +84 -0
  35. package/src/dcp/strategies/supersede-writes.ts +115 -0
  36. package/src/dcp/strategies/utils.ts +135 -0
  37. package/src/dcp/tools/compress.ts +218 -0
  38. package/src/dcp/tools/distill.ts +60 -0
  39. package/src/dcp/tools/index.ts +4 -0
  40. package/src/dcp/tools/prune-shared.ts +174 -0
  41. package/src/dcp/tools/prune.ts +36 -0
  42. package/src/dcp/tools/types.ts +11 -0
  43. package/src/dcp/tools/utils.ts +244 -0
  44. package/src/dcp/ui/notification.ts +273 -0
  45. package/src/dcp/ui/utils.ts +133 -0
  46. package/src/index.ts +101 -49
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openrecall",
3
- "version": "0.2.2",
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"
package/src/agent.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { storeMemory, searchByTag, getTagsForMemory } from "./memory"
1
+ import { storeMemory, searchByTag, getTagsForMemory, deleteMemory, updateMemory, setTags } from "./memory"
2
2
  import { isDbAvailable } from "./db"
3
3
  import * as fs from "fs"
4
4
  import * as path from "path"
@@ -57,6 +57,92 @@ function buildFingerprintTags(filePath: string, directory?: string): string[] {
57
57
  return tags
58
58
  }
59
59
 
60
+ /**
61
+ * Remove all existing memories tagged with a specific filepath.
62
+ */
63
+ function purgeFileMemories(absPath: string, projectId: string): number {
64
+ const tag = `filepath:${absPath}`.toLowerCase()
65
+ const existing = searchByTag(tag, { projectId, limit: 50 })
66
+ for (const m of existing) {
67
+ deleteMemory(m.id)
68
+ }
69
+ return existing.length
70
+ }
71
+
72
+ /**
73
+ * Check if a file already has a fresh memory. Returns true if fresh (skip store).
74
+ * If stale, purges old memories so caller can store fresh ones.
75
+ */
76
+ function isFileFreshInMemory(absPath: string, projectId: string, directory?: string): boolean {
77
+ const tag = `filepath:${absPath}`.toLowerCase()
78
+ const existing = searchByTag(tag, { projectId, limit: 1 })
79
+ if (existing.length === 0) return false
80
+
81
+ const memory = existing[0]!
82
+ const memoryTags = getTagsForMemory(memory.id)
83
+
84
+ let storedGitHash: string | undefined
85
+ let storedMtime: number | undefined
86
+ for (const t of memoryTags) {
87
+ if (t.startsWith("git:")) storedGitHash = t.slice(4)
88
+ if (t.startsWith("mtime:")) storedMtime = parseFloat(t.slice(6))
89
+ }
90
+
91
+ const current = getFileFingerprint(absPath, directory)
92
+
93
+ // Compare git hash first
94
+ if (current.gitHash && storedGitHash && current.gitHash === storedGitHash) {
95
+ return true // fresh
96
+ }
97
+
98
+ // Fall back to mtime
99
+ if (!current.gitHash && storedMtime !== undefined && current.mtime > 0) {
100
+ if (Math.abs(current.mtime - storedMtime) < 1000) return true // fresh
101
+ }
102
+
103
+ // Stale — purge old memories for this file
104
+ purgeFileMemories(absPath, projectId)
105
+ return false
106
+ }
107
+
108
+ /**
109
+ * Upsert a single file memory: update existing or create new.
110
+ * For read-tool tracking where we want exactly one memory per file.
111
+ */
112
+ function upsertFileMemory(
113
+ content: string,
114
+ projectId: string,
115
+ filePath: string,
116
+ tags: string[],
117
+ source: string,
118
+ sessionId?: string,
119
+ ): void {
120
+ const absPath = path.resolve(filePath)
121
+ const tag = `filepath:${absPath}`.toLowerCase()
122
+ const existing = searchByTag(tag, { projectId, limit: 1 })
123
+
124
+ if (existing.length > 0) {
125
+ const memory = existing[0]!
126
+ updateMemory(memory.id, { content, source })
127
+ // Refresh fingerprint tags
128
+ const fpTags = buildFingerprintTags(filePath)
129
+ const nonFpTags = getTagsForMemory(memory.id).filter(
130
+ (t) => !t.startsWith("git:") && !t.startsWith("mtime:"),
131
+ )
132
+ setTags(memory.id, [...nonFpTags, ...fpTags])
133
+ } else {
134
+ storeMemory({
135
+ content,
136
+ category: "discovery",
137
+ projectId,
138
+ sessionId,
139
+ source,
140
+ tags,
141
+ force: true,
142
+ })
143
+ }
144
+ }
145
+
60
146
  /**
61
147
  * Check if a stored file memory is still fresh by comparing fingerprints.
62
148
  * Returns { fresh, memory, storedContent } or null if no memory found.
@@ -159,11 +245,14 @@ export function scanProjectFiles(directory: string, projectId: string): void {
159
245
  // Skip large files (> 50KB)
160
246
  if (stat.size > 50 * 1024) continue
161
247
 
162
- // Use mtime as cache key to avoid re-scanning unchanged files
248
+ // Skip if already scanned this process lifetime
163
249
  const cacheKey = `${fullPath}:${stat.mtimeMs}`
164
250
  if (scannedFiles.has(cacheKey)) continue
165
251
  scannedFiles.add(cacheKey)
166
252
 
253
+ // Skip if memory already has fresh content for this file
254
+ if (isFileFreshInMemory(fullPath, projectId, directory)) continue
255
+
167
256
  const content = fs.readFileSync(fullPath, "utf-8")
168
257
  if (!content.trim()) continue
169
258
 
@@ -224,7 +313,6 @@ function storePackageJsonMemory(content: string, projectId: string, filePath: st
224
313
  projectId,
225
314
  source: `file-scan: ${filePath}`,
226
315
  tags: ["project-config", "package.json", ...fpTags],
227
- force: true,
228
316
  })
229
317
  }
230
318
  } catch {
@@ -254,7 +342,6 @@ function storeFileChunks(content: string, projectId: string, filePath: string, d
254
342
  projectId,
255
343
  source: `file-scan: ${filePath} (section ${i + 1})`,
256
344
  tags: ["file-content", path.basename(filePath).toLowerCase(), ...fpTags],
257
- force: true,
258
345
  })
259
346
  stored++
260
347
  } catch {
@@ -329,15 +416,14 @@ export function extractFileKnowledge(
329
416
  const basename = path.basename(filePath)
330
417
  const fpTags = buildFingerprintTags(filePath)
331
418
 
332
- storeMemory({
333
- content: `File ${basename}: ${preview}`,
334
- category: "discovery",
419
+ upsertFileMemory(
420
+ `File ${basename}: ${preview}`,
335
421
  projectId,
422
+ filePath,
423
+ ["file-content", basename.toLowerCase(), ...fpTags],
424
+ `tool-read: ${filePath}`,
336
425
  sessionId,
337
- source: `tool-read: ${filePath}`,
338
- tags: ["file-content", basename.toLowerCase(), ...fpTags],
339
- force: true,
340
- })
426
+ )
341
427
  } else if (toolName === "edit" || toolName === "write") {
342
428
  // Store what was edited/written
343
429
  const basename = path.basename(filePath)
@@ -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
+ }