openrecall 0.2.2 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openrecall",
3
- "version": "0.2.2",
3
+ "version": "0.3.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",
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
- // Use mtime as cache key to avoid re-scanning unchanged files
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
- storeMemory({
333
- content: `File ${basename}: ${preview}`,
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
- source: `tool-read: ${filePath}`,
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/index.ts CHANGED
@@ -4,7 +4,7 @@ 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"
@@ -184,10 +184,8 @@ export default async function OpenRecallPlugin(
184
184
  "Only call read/Read if memory_file_check returns \"No memory found\" or \"File has been modified\". " +
185
185
  "Calling read on a file after memory_file_check already returned its content is FORBIDDEN — it wastes tokens and time. " +
186
186
  "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
187
  "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.",
188
+ "Use memory_store to save important findings, decisions, user preferences, and patterns. Your final responses are automatically distilled into memory.",
191
189
  ]
192
190
 
193
191
  // Add dynamic summary if DB is available
@@ -231,6 +229,30 @@ export default async function OpenRecallPlugin(
231
229
  }
232
230
  },
233
231
 
232
+ // Auto-distill the LLM's final response text into memory
233
+ "experimental.text.complete": async (input, output) => {
234
+ if (!isDbAvailable()) return
235
+ const text = output.text
236
+ if (!text || text.length < 80) return
237
+
238
+ try {
239
+ // Take a concise summary: first 400 chars of the response
240
+ const summary = text.length > 400
241
+ ? text.slice(0, 397) + "..."
242
+ : text
243
+ storeMemory({
244
+ content: summary,
245
+ category: "discovery",
246
+ projectId,
247
+ sessionId: input.sessionID,
248
+ source: "auto-distill: assistant response",
249
+ tags: ["auto-distill", "assistant-response"],
250
+ })
251
+ } catch {
252
+ // Silent fail — never block the response
253
+ }
254
+ },
255
+
234
256
  // During compaction, remind to preserve important context
235
257
  "experimental.session.compacting": async (_input, output) => {
236
258
  output.context.push(