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.
- package/package.json +8 -2
- package/src/agent.ts +97 -11
- 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 +101 -49
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/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
|
-
//
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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)
|
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
|
+
}
|