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.
- package/package.json +8 -2
- package/src/dcp/auth.ts +37 -0
- package/src/dcp/commands/context.ts +265 -0
- package/src/dcp/commands/help.ts +73 -0
- package/src/dcp/commands/manual.ts +131 -0
- package/src/dcp/commands/stats.ts +73 -0
- package/src/dcp/commands/sweep.ts +263 -0
- package/src/dcp/config.ts +981 -0
- package/src/dcp/hooks.ts +224 -0
- package/src/dcp/index.ts +123 -0
- package/src/dcp/logger.ts +211 -0
- package/src/dcp/messages/index.ts +2 -0
- package/src/dcp/messages/inject.ts +316 -0
- package/src/dcp/messages/prune.ts +217 -0
- package/src/dcp/messages/utils.ts +269 -0
- package/src/dcp/prompts/_codegen/compress-nudge.generated.ts +15 -0
- package/src/dcp/prompts/_codegen/compress.generated.ts +56 -0
- package/src/dcp/prompts/_codegen/distill.generated.ts +33 -0
- package/src/dcp/prompts/_codegen/nudge.generated.ts +17 -0
- package/src/dcp/prompts/_codegen/prune.generated.ts +23 -0
- package/src/dcp/prompts/_codegen/system.generated.ts +57 -0
- package/src/dcp/prompts/index.ts +59 -0
- package/src/dcp/protected-file-patterns.ts +113 -0
- package/src/dcp/shared-utils.ts +26 -0
- package/src/dcp/state/index.ts +3 -0
- package/src/dcp/state/persistence.ts +196 -0
- package/src/dcp/state/state.ts +143 -0
- package/src/dcp/state/tool-cache.ts +112 -0
- package/src/dcp/state/types.ts +55 -0
- package/src/dcp/state/utils.ts +55 -0
- package/src/dcp/strategies/deduplication.ts +123 -0
- package/src/dcp/strategies/index.ts +4 -0
- package/src/dcp/strategies/purge-errors.ts +84 -0
- package/src/dcp/strategies/supersede-writes.ts +115 -0
- package/src/dcp/strategies/utils.ts +135 -0
- package/src/dcp/tools/compress.ts +218 -0
- package/src/dcp/tools/distill.ts +60 -0
- package/src/dcp/tools/index.ts +4 -0
- package/src/dcp/tools/prune-shared.ts +174 -0
- package/src/dcp/tools/prune.ts +36 -0
- package/src/dcp/tools/types.ts +11 -0
- package/src/dcp/tools/utils.ts +244 -0
- package/src/dcp/ui/notification.ts +273 -0
- package/src/dcp/ui/utils.ts +133 -0
- package/src/index.ts +80 -50
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openrecall",
|
|
3
|
-
"version": "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
|
-
"@
|
|
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/dcp/auth.ts
ADDED
|
@@ -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
|
+
}
|