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
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type { Logger } from "../logger"
|
|
2
|
+
import type { SessionState } from "../state"
|
|
3
|
+
import {
|
|
4
|
+
countDistillationTokens,
|
|
5
|
+
formatExtracted,
|
|
6
|
+
formatPrunedItemsList,
|
|
7
|
+
formatStatsHeader,
|
|
8
|
+
formatTokenCount,
|
|
9
|
+
formatProgressBar,
|
|
10
|
+
} from "./utils"
|
|
11
|
+
import type { ToolParameterEntry } from "../state"
|
|
12
|
+
import type { PluginConfig } from "../config"
|
|
13
|
+
|
|
14
|
+
export type PruneReason = "completion" | "noise" | "extraction"
|
|
15
|
+
export const PRUNE_REASON_LABELS: Record<PruneReason, string> = {
|
|
16
|
+
completion: "Task Complete",
|
|
17
|
+
noise: "Noise Removal",
|
|
18
|
+
extraction: "Extraction",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildMinimalMessage(
|
|
22
|
+
state: SessionState,
|
|
23
|
+
reason: PruneReason | undefined,
|
|
24
|
+
distillation: string[] | undefined,
|
|
25
|
+
showDistillation: boolean,
|
|
26
|
+
): string {
|
|
27
|
+
const extractedTokens = countDistillationTokens(distillation)
|
|
28
|
+
const extractedSuffix =
|
|
29
|
+
extractedTokens > 0 ? ` (distilled ${formatTokenCount(extractedTokens)})` : ""
|
|
30
|
+
const reasonSuffix = reason && extractedTokens === 0 ? ` — ${PRUNE_REASON_LABELS[reason]}` : ""
|
|
31
|
+
let message =
|
|
32
|
+
formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) +
|
|
33
|
+
reasonSuffix +
|
|
34
|
+
extractedSuffix
|
|
35
|
+
|
|
36
|
+
return message + formatExtracted(showDistillation ? distillation : undefined)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildDetailedMessage(
|
|
40
|
+
state: SessionState,
|
|
41
|
+
reason: PruneReason | undefined,
|
|
42
|
+
pruneToolIds: string[],
|
|
43
|
+
toolMetadata: Map<string, ToolParameterEntry>,
|
|
44
|
+
workingDirectory: string,
|
|
45
|
+
distillation: string[] | undefined,
|
|
46
|
+
showDistillation: boolean,
|
|
47
|
+
): string {
|
|
48
|
+
let message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter)
|
|
49
|
+
|
|
50
|
+
if (pruneToolIds.length > 0) {
|
|
51
|
+
const pruneTokenCounterStr = `~${formatTokenCount(state.stats.pruneTokenCounter)}`
|
|
52
|
+
const extractedTokens = countDistillationTokens(distillation)
|
|
53
|
+
const extractedSuffix =
|
|
54
|
+
extractedTokens > 0 ? `, distilled ${formatTokenCount(extractedTokens)}` : ""
|
|
55
|
+
const reasonLabel =
|
|
56
|
+
reason && extractedTokens === 0 ? ` — ${PRUNE_REASON_LABELS[reason]}` : ""
|
|
57
|
+
message += `\n\n▣ Pruning (${pruneTokenCounterStr}${extractedSuffix})${reasonLabel}`
|
|
58
|
+
|
|
59
|
+
const itemLines = formatPrunedItemsList(pruneToolIds, toolMetadata, workingDirectory)
|
|
60
|
+
message += "\n" + itemLines.join("\n")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (message + formatExtracted(showDistillation ? distillation : undefined)).trim()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const TOAST_BODY_MAX_LINES = 12
|
|
67
|
+
const TOAST_SUMMARY_MAX_CHARS = 600
|
|
68
|
+
|
|
69
|
+
function truncateToastBody(body: string, maxLines: number = TOAST_BODY_MAX_LINES): string {
|
|
70
|
+
const lines = body.split("\n")
|
|
71
|
+
if (lines.length <= maxLines) {
|
|
72
|
+
return body
|
|
73
|
+
}
|
|
74
|
+
const kept = lines.slice(0, maxLines - 1)
|
|
75
|
+
const remaining = lines.length - maxLines + 1
|
|
76
|
+
return kept.join("\n") + `\n... and ${remaining} more`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function truncateToastSummary(summary: string, maxChars: number = TOAST_SUMMARY_MAX_CHARS): string {
|
|
80
|
+
if (summary.length <= maxChars) {
|
|
81
|
+
return summary
|
|
82
|
+
}
|
|
83
|
+
return summary.slice(0, maxChars - 3) + "..."
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function truncateExtractedSection(
|
|
87
|
+
message: string,
|
|
88
|
+
maxChars: number = TOAST_SUMMARY_MAX_CHARS,
|
|
89
|
+
): string {
|
|
90
|
+
const marker = "\n\n▣ Extracted"
|
|
91
|
+
const index = message.indexOf(marker)
|
|
92
|
+
if (index === -1) {
|
|
93
|
+
return message
|
|
94
|
+
}
|
|
95
|
+
const extracted = message.slice(index)
|
|
96
|
+
if (extracted.length <= maxChars) {
|
|
97
|
+
return message
|
|
98
|
+
}
|
|
99
|
+
return message.slice(0, index) + truncateToastSummary(extracted, maxChars)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function sendUnifiedNotification(
|
|
103
|
+
client: any,
|
|
104
|
+
logger: Logger,
|
|
105
|
+
config: PluginConfig,
|
|
106
|
+
state: SessionState,
|
|
107
|
+
sessionId: string,
|
|
108
|
+
pruneToolIds: string[],
|
|
109
|
+
toolMetadata: Map<string, ToolParameterEntry>,
|
|
110
|
+
reason: PruneReason | undefined,
|
|
111
|
+
params: any,
|
|
112
|
+
workingDirectory: string,
|
|
113
|
+
distillation?: string[],
|
|
114
|
+
): Promise<boolean> {
|
|
115
|
+
const hasPruned = pruneToolIds.length > 0
|
|
116
|
+
if (!hasPruned) {
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (config.pruneNotification === "off") {
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const showDistillation = config.tools.distill.showDistillation
|
|
125
|
+
|
|
126
|
+
const message =
|
|
127
|
+
config.pruneNotification === "minimal"
|
|
128
|
+
? buildMinimalMessage(state, reason, distillation, showDistillation)
|
|
129
|
+
: buildDetailedMessage(
|
|
130
|
+
state,
|
|
131
|
+
reason,
|
|
132
|
+
pruneToolIds,
|
|
133
|
+
toolMetadata,
|
|
134
|
+
workingDirectory,
|
|
135
|
+
distillation,
|
|
136
|
+
showDistillation,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if (config.pruneNotificationType === "toast") {
|
|
140
|
+
let toastMessage = truncateExtractedSection(message)
|
|
141
|
+
toastMessage =
|
|
142
|
+
config.pruneNotification === "minimal" ? toastMessage : truncateToastBody(toastMessage)
|
|
143
|
+
|
|
144
|
+
await client.tui.showToast({
|
|
145
|
+
body: {
|
|
146
|
+
title: "DCP: Prune Notification",
|
|
147
|
+
message: toastMessage,
|
|
148
|
+
variant: "info",
|
|
149
|
+
duration: 5000,
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
return true
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await sendIgnoredMessage(client, sessionId, message, params, logger)
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function sendCompressNotification(
|
|
160
|
+
client: any,
|
|
161
|
+
logger: Logger,
|
|
162
|
+
config: PluginConfig,
|
|
163
|
+
state: SessionState,
|
|
164
|
+
sessionId: string,
|
|
165
|
+
toolIds: string[],
|
|
166
|
+
messageIds: string[],
|
|
167
|
+
topic: string,
|
|
168
|
+
summary: string,
|
|
169
|
+
startResult: any,
|
|
170
|
+
endResult: any,
|
|
171
|
+
totalMessages: number,
|
|
172
|
+
params: any,
|
|
173
|
+
): Promise<boolean> {
|
|
174
|
+
if (config.pruneNotification === "off") {
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let message: string
|
|
179
|
+
|
|
180
|
+
if (config.pruneNotification === "minimal") {
|
|
181
|
+
message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter)
|
|
182
|
+
} else {
|
|
183
|
+
message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter)
|
|
184
|
+
|
|
185
|
+
const pruneTokenCounterStr = `~${formatTokenCount(state.stats.pruneTokenCounter)}`
|
|
186
|
+
const progressBar = formatProgressBar(
|
|
187
|
+
totalMessages,
|
|
188
|
+
startResult.messageIndex,
|
|
189
|
+
endResult.messageIndex,
|
|
190
|
+
25,
|
|
191
|
+
)
|
|
192
|
+
message += `\n\n▣ Compressing (${pruneTokenCounterStr}) ${progressBar}`
|
|
193
|
+
message += `\n→ Topic: ${topic}`
|
|
194
|
+
message += `\n→ Items: ${messageIds.length} messages`
|
|
195
|
+
if (toolIds.length > 0) {
|
|
196
|
+
message += ` and ${toolIds.length} tools condensed`
|
|
197
|
+
} else {
|
|
198
|
+
message += ` condensed`
|
|
199
|
+
}
|
|
200
|
+
if (config.tools.compress.showCompression) {
|
|
201
|
+
message += `\n→ Compression: ${summary}`
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (config.pruneNotificationType === "toast") {
|
|
206
|
+
let toastMessage = message
|
|
207
|
+
if (config.tools.compress.showCompression) {
|
|
208
|
+
const truncatedSummary = truncateToastSummary(summary)
|
|
209
|
+
if (truncatedSummary !== summary) {
|
|
210
|
+
toastMessage = toastMessage.replace(
|
|
211
|
+
`\n→ Compression: ${summary}`,
|
|
212
|
+
`\n→ Compression: ${truncatedSummary}`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
toastMessage =
|
|
217
|
+
config.pruneNotification === "minimal" ? toastMessage : truncateToastBody(toastMessage)
|
|
218
|
+
|
|
219
|
+
await client.tui.showToast({
|
|
220
|
+
body: {
|
|
221
|
+
title: "DCP: Compress Notification",
|
|
222
|
+
message: toastMessage,
|
|
223
|
+
variant: "info",
|
|
224
|
+
duration: 5000,
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
return true
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
await sendIgnoredMessage(client, sessionId, message, params, logger)
|
|
231
|
+
return true
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function sendIgnoredMessage(
|
|
235
|
+
client: any,
|
|
236
|
+
sessionID: string,
|
|
237
|
+
text: string,
|
|
238
|
+
params: any,
|
|
239
|
+
logger: Logger,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
const agent = params.agent || undefined
|
|
242
|
+
const variant = params.variant || undefined
|
|
243
|
+
const model =
|
|
244
|
+
params.providerId && params.modelId
|
|
245
|
+
? {
|
|
246
|
+
providerID: params.providerId,
|
|
247
|
+
modelID: params.modelId,
|
|
248
|
+
}
|
|
249
|
+
: undefined
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
await client.session.prompt({
|
|
253
|
+
path: {
|
|
254
|
+
id: sessionID,
|
|
255
|
+
},
|
|
256
|
+
body: {
|
|
257
|
+
noReply: true,
|
|
258
|
+
agent: agent,
|
|
259
|
+
model: model,
|
|
260
|
+
variant: variant,
|
|
261
|
+
parts: [
|
|
262
|
+
{
|
|
263
|
+
type: "text",
|
|
264
|
+
text: text,
|
|
265
|
+
ignored: true,
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
} catch (error: any) {
|
|
271
|
+
logger.error("Failed to send notification", { error: error.message })
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { ToolParameterEntry } from "../state"
|
|
2
|
+
import { extractParameterKey } from "../messages/utils"
|
|
3
|
+
import { countTokens } from "../strategies/utils"
|
|
4
|
+
|
|
5
|
+
export function countDistillationTokens(distillation?: string[]): number {
|
|
6
|
+
if (!distillation || distillation.length === 0) return 0
|
|
7
|
+
return countTokens(distillation.join("\n"))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function formatExtracted(distillation?: string[]): string {
|
|
11
|
+
if (!distillation || distillation.length === 0) {
|
|
12
|
+
return ""
|
|
13
|
+
}
|
|
14
|
+
let result = `\n\n▣ Extracted`
|
|
15
|
+
for (const finding of distillation) {
|
|
16
|
+
result += `\n───\n${finding}`
|
|
17
|
+
}
|
|
18
|
+
return result
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatStatsHeader(totalTokensSaved: number, pruneTokenCounter: number): string {
|
|
22
|
+
const totalTokensSavedStr = `~${formatTokenCount(totalTokensSaved + pruneTokenCounter)}`
|
|
23
|
+
return [`▣ DCP | ${totalTokensSavedStr} saved total`].join("\n")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatTokenCount(tokens: number): string {
|
|
27
|
+
if (tokens >= 1000) {
|
|
28
|
+
return `${(tokens / 1000).toFixed(1)}K`.replace(".0K", "K") + " tokens"
|
|
29
|
+
}
|
|
30
|
+
return tokens.toString() + " tokens"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function truncate(str: string, maxLen: number = 60): string {
|
|
34
|
+
if (str.length <= maxLen) return str
|
|
35
|
+
return str.slice(0, maxLen - 3) + "..."
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function formatProgressBar(
|
|
39
|
+
total: number,
|
|
40
|
+
start: number,
|
|
41
|
+
end: number,
|
|
42
|
+
width: number = 20,
|
|
43
|
+
): string {
|
|
44
|
+
if (total <= 0) return `│${" ".repeat(width)}│`
|
|
45
|
+
|
|
46
|
+
const startIdx = Math.floor((start / total) * width)
|
|
47
|
+
const endIdx = Math.min(width - 1, Math.floor((end / total) * width))
|
|
48
|
+
|
|
49
|
+
let bar = ""
|
|
50
|
+
for (let i = 0; i < width; i++) {
|
|
51
|
+
if (i >= startIdx && i <= endIdx) {
|
|
52
|
+
bar += "░"
|
|
53
|
+
} else {
|
|
54
|
+
bar += "█"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return `│${bar}│`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function shortenPath(input: string, workingDirectory?: string): string {
|
|
62
|
+
const inPathMatch = input.match(/^(.+) in (.+)$/)
|
|
63
|
+
if (inPathMatch) {
|
|
64
|
+
const prefix = inPathMatch[1]!
|
|
65
|
+
const pathPart = inPathMatch[2]!
|
|
66
|
+
const shortenedPath = shortenSinglePath(pathPart, workingDirectory)
|
|
67
|
+
return `${prefix} in ${shortenedPath}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return shortenSinglePath(input, workingDirectory)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function shortenSinglePath(path: string, workingDirectory?: string): string {
|
|
74
|
+
if (workingDirectory) {
|
|
75
|
+
if (path.startsWith(workingDirectory + "/")) {
|
|
76
|
+
return path.slice(workingDirectory.length + 1)
|
|
77
|
+
}
|
|
78
|
+
if (path === workingDirectory) {
|
|
79
|
+
return "."
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return path
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function formatPrunedItemsList(
|
|
87
|
+
pruneToolIds: string[],
|
|
88
|
+
toolMetadata: Map<string, ToolParameterEntry>,
|
|
89
|
+
workingDirectory?: string,
|
|
90
|
+
): string[] {
|
|
91
|
+
const lines: string[] = []
|
|
92
|
+
|
|
93
|
+
for (const id of pruneToolIds) {
|
|
94
|
+
const metadata = toolMetadata.get(id)
|
|
95
|
+
|
|
96
|
+
if (metadata) {
|
|
97
|
+
const paramKey = extractParameterKey(metadata.tool, metadata.parameters)
|
|
98
|
+
if (paramKey) {
|
|
99
|
+
// Use 60 char limit to match notification style
|
|
100
|
+
const displayKey = truncate(shortenPath(paramKey, workingDirectory), 60)
|
|
101
|
+
lines.push(`→ ${metadata.tool}: ${displayKey}`)
|
|
102
|
+
} else {
|
|
103
|
+
lines.push(`→ ${metadata.tool}`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const knownCount = pruneToolIds.filter((id) => toolMetadata.has(id)).length
|
|
109
|
+
const unknownCount = pruneToolIds.length - knownCount
|
|
110
|
+
|
|
111
|
+
if (unknownCount > 0) {
|
|
112
|
+
lines.push(`→ (${unknownCount} tool${unknownCount > 1 ? "s" : ""} with unknown metadata)`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return lines
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function formatPruningResultForTool(
|
|
119
|
+
prunedIds: string[],
|
|
120
|
+
toolMetadata: Map<string, ToolParameterEntry>,
|
|
121
|
+
workingDirectory?: string,
|
|
122
|
+
): string {
|
|
123
|
+
const lines: string[] = []
|
|
124
|
+
lines.push(`Context pruning complete. Pruned ${prunedIds.length} tool outputs.`)
|
|
125
|
+
lines.push("")
|
|
126
|
+
|
|
127
|
+
if (prunedIds.length > 0) {
|
|
128
|
+
lines.push(`Semantically pruned (${prunedIds.length}):`)
|
|
129
|
+
lines.push(...formatPrunedItemsList(prunedIds, toolMetadata, workingDirectory))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return lines.join("\n").trim()
|
|
133
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { storeMemory, searchMemories, listMemories, getStats, sanitizeQuery } fr
|
|
|
8
8
|
import { maybeRunMaintenance } from "./maintenance"
|
|
9
9
|
import { extractFromToolOutput, clearSessionExtraction } from "./extract"
|
|
10
10
|
import { incrementCounter, clearCounter, scanProjectFiles, extractFileKnowledge } from "./agent"
|
|
11
|
+
import { createDcpPlugin } from "./dcp"
|
|
11
12
|
|
|
12
13
|
// In-memory cache of session metadata for enriching memories
|
|
13
14
|
interface SessionInfo {
|
|
@@ -29,9 +30,13 @@ export default async function OpenRecallPlugin(
|
|
|
29
30
|
// Store SDK client for hooks and tools to access OpenCode data
|
|
30
31
|
initClient(inputRef.client)
|
|
31
32
|
|
|
33
|
+
// Initialize DCP plugin
|
|
34
|
+
const dcpHooks = await createDcpPlugin(inputRef)
|
|
35
|
+
|
|
32
36
|
return {
|
|
33
37
|
// Load config from opencode.json plugin options
|
|
34
38
|
async config(cfg: any) {
|
|
39
|
+
// OpenRecall config
|
|
35
40
|
const pluginConfig = cfg?.plugins?.openrecall as
|
|
36
41
|
| Partial<OpenRecallConfig>
|
|
37
42
|
| undefined
|
|
@@ -50,6 +55,11 @@ export default async function OpenRecallPlugin(
|
|
|
50
55
|
e,
|
|
51
56
|
)
|
|
52
57
|
}
|
|
58
|
+
|
|
59
|
+
// DCP config (registers commands, primary_tools, permissions)
|
|
60
|
+
if (dcpHooks.config) {
|
|
61
|
+
await dcpHooks.config(cfg)
|
|
62
|
+
}
|
|
53
63
|
},
|
|
54
64
|
|
|
55
65
|
// Detect first message in a session and auto-recall relevant memories
|
|
@@ -60,55 +70,60 @@ export default async function OpenRecallPlugin(
|
|
|
60
70
|
incrementCounter(sessionId)
|
|
61
71
|
|
|
62
72
|
const config = getConfig()
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
73
|
+
if (config.autoRecall && isDbAvailable()) {
|
|
74
|
+
if (!sessionFirstMessage.has(sessionId)) {
|
|
75
|
+
sessionFirstMessage.add(sessionId)
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// Extract text from the user's first message
|
|
79
|
+
const userText = extractUserText(output)
|
|
80
|
+
if (userText) {
|
|
81
|
+
// Search for relevant memories using the first message as query
|
|
82
|
+
const sanitized = sanitizeQuery(userText)
|
|
83
|
+
let recalled: string[] = []
|
|
84
|
+
|
|
85
|
+
if (sanitized.trim()) {
|
|
86
|
+
const results = searchMemories({
|
|
87
|
+
query: sanitized,
|
|
88
|
+
projectId,
|
|
89
|
+
limit: config.searchLimit,
|
|
90
|
+
})
|
|
91
|
+
recalled = results.map((r) => {
|
|
92
|
+
const time = new Date(r.memory.time_created * 1000).toISOString()
|
|
93
|
+
return `[${r.memory.category.toUpperCase()}] ${r.memory.content} (${time})`
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Fall back to recent memories if no search matches
|
|
98
|
+
if (recalled.length === 0) {
|
|
99
|
+
const recent = listMemories({ projectId, limit: 5 })
|
|
100
|
+
recalled = recent.map((m) => {
|
|
101
|
+
const time = new Date(m.time_created * 1000).toISOString()
|
|
102
|
+
return `[${m.category.toUpperCase()}] ${m.content} (${time})`
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (recalled.length > 0) {
|
|
107
|
+
recalledMemories.set(
|
|
108
|
+
sessionId,
|
|
109
|
+
"Relevant memories from previous sessions:\n" +
|
|
110
|
+
recalled.map((r, i) => `${i + 1}. ${r}`).join("\n"),
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
console.error("[OpenRecall] Auto-recall failed:", e)
|
|
116
|
+
}
|
|
97
117
|
}
|
|
118
|
+
}
|
|
98
119
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"Relevant memories from previous sessions:\n" +
|
|
103
|
-
recalled.map((r, i) => `${i + 1}. ${r}`).join("\n"),
|
|
104
|
-
)
|
|
105
|
-
}
|
|
106
|
-
} catch (e) {
|
|
107
|
-
console.error("[OpenRecall] Auto-recall failed:", e)
|
|
120
|
+
// DCP: cache variant from chat messages
|
|
121
|
+
if (dcpHooks["chat.message"]) {
|
|
122
|
+
await dcpHooks["chat.message"](input, output)
|
|
108
123
|
}
|
|
109
124
|
},
|
|
110
125
|
|
|
111
|
-
// Track session lifecycle events
|
|
126
|
+
// Track session lifecycle events (OpenRecall only)
|
|
112
127
|
async event({ event }: { event: any }) {
|
|
113
128
|
if (!event || typeof event !== "object") return
|
|
114
129
|
const type = event.type as string | undefined
|
|
@@ -138,7 +153,7 @@ export default async function OpenRecallPlugin(
|
|
|
138
153
|
}
|
|
139
154
|
},
|
|
140
155
|
|
|
141
|
-
// Auto-extract memories from tool execution results
|
|
156
|
+
// Auto-extract memories from tool execution results (OpenRecall only)
|
|
142
157
|
async "tool.execute.after"(input, output) {
|
|
143
158
|
const config = getConfig()
|
|
144
159
|
|
|
@@ -172,11 +187,15 @@ export default async function OpenRecallPlugin(
|
|
|
172
187
|
}
|
|
173
188
|
},
|
|
174
189
|
|
|
175
|
-
// Expose memory tools
|
|
176
|
-
tool:
|
|
190
|
+
// Expose both OpenRecall memory tools and DCP tools
|
|
191
|
+
tool: {
|
|
192
|
+
...createTools(projectId),
|
|
193
|
+
...(dcpHooks.tool || {}),
|
|
194
|
+
},
|
|
177
195
|
|
|
178
|
-
// Inject memory context
|
|
196
|
+
// Inject both memory context and DCP system prompt
|
|
179
197
|
"experimental.chat.system.transform": async (input, output) => {
|
|
198
|
+
// OpenRecall: inject memory context
|
|
180
199
|
const lines: string[] = [
|
|
181
200
|
"IMPORTANT: You have persistent cross-session memory tools (memory_store, memory_search, memory_list, memory_update, memory_delete, memory_tag, memory_link, memory_refresh, memory_stats, memory_export, memory_import, memory_cleanup, memory_file_check).",
|
|
182
201
|
"MANDATORY FILE ACCESS RULE: You MUST call memory_file_check(file_path) BEFORE every file read. " +
|
|
@@ -227,9 +246,17 @@ export default async function OpenRecallPlugin(
|
|
|
227
246
|
output.system.push(memories)
|
|
228
247
|
}
|
|
229
248
|
}
|
|
249
|
+
|
|
250
|
+
// DCP: inject system prompt
|
|
251
|
+
if (dcpHooks["experimental.chat.system.transform"]) {
|
|
252
|
+
await dcpHooks["experimental.chat.system.transform"](input, output)
|
|
253
|
+
}
|
|
230
254
|
},
|
|
231
255
|
|
|
232
|
-
//
|
|
256
|
+
// DCP: message pruning pipeline (DCP only)
|
|
257
|
+
"experimental.chat.messages.transform": dcpHooks["experimental.chat.messages.transform"] as any,
|
|
258
|
+
|
|
259
|
+
// Auto-distill the LLM's final response text into memory (OpenRecall only)
|
|
233
260
|
"experimental.text.complete": async (input, output) => {
|
|
234
261
|
if (!isDbAvailable()) return
|
|
235
262
|
const text = output.text
|
|
@@ -253,7 +280,7 @@ export default async function OpenRecallPlugin(
|
|
|
253
280
|
}
|
|
254
281
|
},
|
|
255
282
|
|
|
256
|
-
// During compaction, remind to preserve important context
|
|
283
|
+
// During compaction, remind to preserve important context (OpenRecall only)
|
|
257
284
|
"experimental.session.compacting": async (_input, output) => {
|
|
258
285
|
output.context.push(
|
|
259
286
|
"Before compacting, consider using memory_store to save any important " +
|
|
@@ -261,6 +288,9 @@ export default async function OpenRecallPlugin(
|
|
|
261
288
|
"remembered across future sessions.",
|
|
262
289
|
)
|
|
263
290
|
},
|
|
291
|
+
|
|
292
|
+
// DCP: command handler (DCP only)
|
|
293
|
+
"command.execute.before": dcpHooks["command.execute.before"] as any,
|
|
264
294
|
}
|
|
265
295
|
}
|
|
266
296
|
|