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
|
@@ -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
|
@@ -4,10 +4,11 @@ import { getDb } from "./db"
|
|
|
4
4
|
import { isDbAvailable } from "./db"
|
|
5
5
|
import { initConfig, getConfig, type OpenRecallConfig } from "./config"
|
|
6
6
|
import { initClient } from "./client"
|
|
7
|
-
import { searchMemories, listMemories, getStats, sanitizeQuery } from "./memory"
|
|
7
|
+
import { storeMemory, searchMemories, listMemories, getStats, sanitizeQuery } from "./memory"
|
|
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
|
-
|
|
73
|
+
if (config.autoRecall && isDbAvailable()) {
|
|
74
|
+
if (!sessionFirstMessage.has(sessionId)) {
|
|
75
|
+
sessionFirstMessage.add(sessionId)
|
|
65
76
|
|
|
66
|
-
|
|
67
|
-
|
|
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[] = []
|
|
68
84
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
}
|
|
77
96
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return `[${r.memory.category.toUpperCase()}] ${r.memory.content} (${time})`
|
|
87
|
-
})
|
|
88
|
-
}
|
|
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
|
+
}
|
|
89
105
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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. " +
|
|
@@ -184,10 +203,8 @@ export default async function OpenRecallPlugin(
|
|
|
184
203
|
"Only call read/Read if memory_file_check returns \"No memory found\" or \"File has been modified\". " +
|
|
185
204
|
"Calling read on a file after memory_file_check already returned its content is FORBIDDEN — it wastes tokens and time. " +
|
|
186
205
|
"This rule applies to ALL files, every time, with no exceptions.",
|
|
187
|
-
"MANDATORY SAVE RULE: Before giving your final response to the user, call memory_store to save a concise summary of your key findings, conclusions, or answers. " +
|
|
188
|
-
"This ensures the knowledge is persisted for future sessions. Do this EVERY time you research, explore, or analyze something — not just when the user explicitly asks you to remember.",
|
|
189
206
|
"When the user asks you to remember, save, store, or note something, you MUST call memory_store immediately.",
|
|
190
|
-
"Use memory_store to save important findings, decisions, user preferences, and patterns.",
|
|
207
|
+
"Use memory_store to save important findings, decisions, user preferences, and patterns. Your final responses are automatically distilled into memory.",
|
|
191
208
|
]
|
|
192
209
|
|
|
193
210
|
// Add dynamic summary if DB is available
|
|
@@ -229,9 +246,41 @@ export default async function OpenRecallPlugin(
|
|
|
229
246
|
output.system.push(memories)
|
|
230
247
|
}
|
|
231
248
|
}
|
|
249
|
+
|
|
250
|
+
// DCP: inject system prompt
|
|
251
|
+
if (dcpHooks["experimental.chat.system.transform"]) {
|
|
252
|
+
await dcpHooks["experimental.chat.system.transform"](input, output)
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
|
|
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)
|
|
260
|
+
"experimental.text.complete": async (input, output) => {
|
|
261
|
+
if (!isDbAvailable()) return
|
|
262
|
+
const text = output.text
|
|
263
|
+
if (!text || text.length < 80) return
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
// Take a concise summary: first 400 chars of the response
|
|
267
|
+
const summary = text.length > 400
|
|
268
|
+
? text.slice(0, 397) + "..."
|
|
269
|
+
: text
|
|
270
|
+
storeMemory({
|
|
271
|
+
content: summary,
|
|
272
|
+
category: "discovery",
|
|
273
|
+
projectId,
|
|
274
|
+
sessionId: input.sessionID,
|
|
275
|
+
source: "auto-distill: assistant response",
|
|
276
|
+
tags: ["auto-distill", "assistant-response"],
|
|
277
|
+
})
|
|
278
|
+
} catch {
|
|
279
|
+
// Silent fail — never block the response
|
|
280
|
+
}
|
|
232
281
|
},
|
|
233
282
|
|
|
234
|
-
// During compaction, remind to preserve important context
|
|
283
|
+
// During compaction, remind to preserve important context (OpenRecall only)
|
|
235
284
|
"experimental.session.compacting": async (_input, output) => {
|
|
236
285
|
output.context.push(
|
|
237
286
|
"Before compacting, consider using memory_store to save any important " +
|
|
@@ -239,6 +288,9 @@ export default async function OpenRecallPlugin(
|
|
|
239
288
|
"remembered across future sessions.",
|
|
240
289
|
)
|
|
241
290
|
},
|
|
291
|
+
|
|
292
|
+
// DCP: command handler (DCP only)
|
|
293
|
+
"command.execute.before": dcpHooks["command.execute.before"] as any,
|
|
242
294
|
}
|
|
243
295
|
}
|
|
244
296
|
|