openrecall 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +8 -2
  2. package/src/agent.ts +97 -11
  3. package/src/dcp/auth.ts +37 -0
  4. package/src/dcp/commands/context.ts +265 -0
  5. package/src/dcp/commands/help.ts +73 -0
  6. package/src/dcp/commands/manual.ts +131 -0
  7. package/src/dcp/commands/stats.ts +73 -0
  8. package/src/dcp/commands/sweep.ts +263 -0
  9. package/src/dcp/config.ts +981 -0
  10. package/src/dcp/hooks.ts +224 -0
  11. package/src/dcp/index.ts +123 -0
  12. package/src/dcp/logger.ts +211 -0
  13. package/src/dcp/messages/index.ts +2 -0
  14. package/src/dcp/messages/inject.ts +316 -0
  15. package/src/dcp/messages/prune.ts +217 -0
  16. package/src/dcp/messages/utils.ts +269 -0
  17. package/src/dcp/prompts/_codegen/compress-nudge.generated.ts +15 -0
  18. package/src/dcp/prompts/_codegen/compress.generated.ts +56 -0
  19. package/src/dcp/prompts/_codegen/distill.generated.ts +33 -0
  20. package/src/dcp/prompts/_codegen/nudge.generated.ts +17 -0
  21. package/src/dcp/prompts/_codegen/prune.generated.ts +23 -0
  22. package/src/dcp/prompts/_codegen/system.generated.ts +57 -0
  23. package/src/dcp/prompts/index.ts +59 -0
  24. package/src/dcp/protected-file-patterns.ts +113 -0
  25. package/src/dcp/shared-utils.ts +26 -0
  26. package/src/dcp/state/index.ts +3 -0
  27. package/src/dcp/state/persistence.ts +196 -0
  28. package/src/dcp/state/state.ts +143 -0
  29. package/src/dcp/state/tool-cache.ts +112 -0
  30. package/src/dcp/state/types.ts +55 -0
  31. package/src/dcp/state/utils.ts +55 -0
  32. package/src/dcp/strategies/deduplication.ts +123 -0
  33. package/src/dcp/strategies/index.ts +4 -0
  34. package/src/dcp/strategies/purge-errors.ts +84 -0
  35. package/src/dcp/strategies/supersede-writes.ts +115 -0
  36. package/src/dcp/strategies/utils.ts +135 -0
  37. package/src/dcp/tools/compress.ts +218 -0
  38. package/src/dcp/tools/distill.ts +60 -0
  39. package/src/dcp/tools/index.ts +4 -0
  40. package/src/dcp/tools/prune-shared.ts +174 -0
  41. package/src/dcp/tools/prune.ts +36 -0
  42. package/src/dcp/tools/types.ts +11 -0
  43. package/src/dcp/tools/utils.ts +244 -0
  44. package/src/dcp/ui/notification.ts +273 -0
  45. package/src/dcp/ui/utils.ts +133 -0
  46. package/src/index.ts +101 -49
@@ -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 (!config.autoRecall) return
64
- if (!isDbAvailable()) return
73
+ if (config.autoRecall && isDbAvailable()) {
74
+ if (!sessionFirstMessage.has(sessionId)) {
75
+ sessionFirstMessage.add(sessionId)
65
76
 
66
- if (sessionFirstMessage.has(sessionId)) return
67
- sessionFirstMessage.add(sessionId)
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
- try {
70
- // Extract text from the user's first message
71
- const userText = extractUserText(output)
72
- if (!userText) return
73
-
74
- // Search for relevant memories using the first message as query
75
- const sanitized = sanitizeQuery(userText)
76
- let recalled: string[] = []
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
- if (sanitized.trim()) {
79
- const results = searchMemories({
80
- query: sanitized,
81
- projectId,
82
- limit: config.searchLimit,
83
- })
84
- recalled = results.map((r) => {
85
- const time = new Date(r.memory.time_created * 1000).toISOString()
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
- // Fall back to recent memories if no search matches
91
- if (recalled.length === 0) {
92
- const recent = listMemories({ projectId, limit: 5 })
93
- recalled = recent.map((m) => {
94
- const time = new Date(m.time_created * 1000).toISOString()
95
- return `[${m.category.toUpperCase()}] ${m.content} (${time})`
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
- if (recalled.length > 0) {
100
- recalledMemories.set(
101
- sessionId,
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 to the LLM
176
- tool: createTools(projectId),
190
+ // Expose both OpenRecall memory tools and DCP tools
191
+ tool: {
192
+ ...createTools(projectId),
193
+ ...(dcpHooks.tool || {}),
194
+ },
177
195
 
178
- // Inject memory context into the system prompt
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