openrecall 0.1.0 → 0.2.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.1.0",
3
+ "version": "0.2.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 } from "./memory"
1
+ import { storeMemory, searchByTag, getTagsForMemory } from "./memory"
2
2
  import { isDbAvailable } from "./db"
3
3
  import * as fs from "fs"
4
4
  import * as path from "path"
@@ -24,6 +24,91 @@ const MAX_CHUNK = 400
24
24
  // Track which files have been scanned this process lifetime
25
25
  const scannedFiles = new Set<string>()
26
26
 
27
+ /**
28
+ * Get a file's current git commit hash and mtime for freshness tracking.
29
+ */
30
+ export function getFileFingerprint(filePath: string, directory?: string): { gitHash?: string; mtime: number } {
31
+ let mtime = 0
32
+ try { mtime = fs.statSync(filePath).mtimeMs } catch {}
33
+
34
+ let gitHash: string | undefined
35
+ try {
36
+ const { execSync } = require("child_process")
37
+ gitHash = execSync(`git log -1 --format=%H -- "${filePath}"`, {
38
+ cwd: directory || path.dirname(filePath),
39
+ encoding: "utf-8",
40
+ timeout: 3000,
41
+ }).trim()
42
+ if (!gitHash) gitHash = undefined
43
+ } catch {}
44
+
45
+ return { gitHash, mtime }
46
+ }
47
+
48
+ /**
49
+ * Build fingerprint tags for a file path.
50
+ */
51
+ function buildFingerprintTags(filePath: string, directory?: string): string[] {
52
+ const absPath = path.resolve(directory || "", filePath)
53
+ const fp = getFileFingerprint(absPath, directory)
54
+ const tags: string[] = [`filepath:${absPath}`]
55
+ tags.push(`git:${fp.gitHash || ""}`)
56
+ tags.push(`mtime:${fp.mtime}`)
57
+ return tags
58
+ }
59
+
60
+ /**
61
+ * Check if a stored file memory is still fresh by comparing fingerprints.
62
+ * Returns { fresh, memory, storedContent } or null if no memory found.
63
+ */
64
+ export function checkFileFreshness(filePath: string, projectId: string, directory?: string): {
65
+ fresh: boolean
66
+ memory: any
67
+ storedContent: string
68
+ } | null {
69
+ if (!isDbAvailable()) return null
70
+
71
+ const absPath = path.resolve(directory || "", filePath)
72
+ const tag = `filepath:${absPath}`
73
+
74
+ const memories = searchByTag(tag, { projectId, limit: 1 })
75
+ if (memories.length === 0) return null
76
+
77
+ const memory = memories[0]!
78
+ const memoryTags = getTagsForMemory(memory.id)
79
+
80
+ // Extract stored git hash and mtime from tags
81
+ let storedGitHash: string | undefined
82
+ let storedMtime: number | undefined
83
+ for (const t of memoryTags) {
84
+ if (t.startsWith("git:")) storedGitHash = t.slice(4)
85
+ if (t.startsWith("mtime:")) storedMtime = parseFloat(t.slice(6))
86
+ }
87
+
88
+ const current = getFileFingerprint(absPath, directory)
89
+
90
+ // Compare git hash first (most reliable)
91
+ if (current.gitHash && storedGitHash) {
92
+ return {
93
+ fresh: current.gitHash === storedGitHash,
94
+ memory,
95
+ storedContent: memory.content,
96
+ }
97
+ }
98
+
99
+ // Fall back to mtime comparison
100
+ if (storedMtime !== undefined && current.mtime > 0) {
101
+ return {
102
+ fresh: Math.abs(current.mtime - storedMtime) < 1000, // 1s tolerance
103
+ memory,
104
+ storedContent: memory.content,
105
+ }
106
+ }
107
+
108
+ // Can't determine freshness — treat as stale
109
+ return { fresh: false, memory, storedContent: memory.content }
110
+ }
111
+
27
112
  interface ExtractionCounter {
28
113
  count: number
29
114
  extracting: boolean
@@ -84,13 +169,13 @@ export function scanProjectFiles(directory: string, projectId: string): void {
84
169
 
85
170
  // For package.json, extract key info
86
171
  if (relPath === "package.json") {
87
- storePackageJsonMemory(content, projectId, relPath)
172
+ storePackageJsonMemory(content, projectId, fullPath, directory)
88
173
  stored++
89
174
  continue
90
175
  }
91
176
 
92
177
  // Store file content in chunks
93
- stored += storeFileChunks(content, projectId, relPath)
178
+ stored += storeFileChunks(content, projectId, fullPath, directory)
94
179
  } catch (e) {
95
180
  // Silent fail per file — don't break startup
96
181
  }
@@ -108,7 +193,7 @@ export function scanProjectFiles(directory: string, projectId: string): void {
108
193
  }
109
194
  }
110
195
 
111
- function storePackageJsonMemory(content: string, projectId: string, filePath: string): void {
196
+ function storePackageJsonMemory(content: string, projectId: string, filePath: string, directory?: string): void {
112
197
  try {
113
198
  const pkg = JSON.parse(content)
114
199
  const parts: string[] = []
@@ -132,12 +217,14 @@ function storePackageJsonMemory(content: string, projectId: string, filePath: st
132
217
 
133
218
  const summary = parts.join(" | ")
134
219
  if (summary.length > 10) {
220
+ const fpTags = buildFingerprintTags(filePath, directory)
135
221
  storeMemory({
136
222
  content: summary.slice(0, MAX_CHUNK),
137
223
  category: "discovery",
138
224
  projectId,
139
225
  source: `file-scan: ${filePath}`,
140
- tags: ["project-config", "package.json"],
226
+ tags: ["project-config", "package.json", ...fpTags],
227
+ force: true,
141
228
  })
142
229
  }
143
230
  } catch {
@@ -145,11 +232,13 @@ function storePackageJsonMemory(content: string, projectId: string, filePath: st
145
232
  }
146
233
  }
147
234
 
148
- function storeFileChunks(content: string, projectId: string, filePath: string): number {
235
+ function storeFileChunks(content: string, projectId: string, filePath: string, directory?: string): number {
149
236
  // Split by sections (headers in markdown, or double newlines)
150
237
  const sections = content.split(/\n#{1,3}\s+|\n\n/).filter((s) => s.trim().length > 20)
151
238
  let stored = 0
152
239
 
240
+ const fpTags = buildFingerprintTags(filePath, directory)
241
+
153
242
  // Store up to 5 chunks per file to avoid flooding
154
243
  const maxChunks = 5
155
244
  for (let i = 0; i < Math.min(sections.length, maxChunks); i++) {
@@ -164,7 +253,8 @@ function storeFileChunks(content: string, projectId: string, filePath: string):
164
253
  category: "discovery",
165
254
  projectId,
166
255
  source: `file-scan: ${filePath} (section ${i + 1})`,
167
- tags: ["file-content", path.basename(filePath).toLowerCase()],
256
+ tags: ["file-content", path.basename(filePath).toLowerCase(), ...fpTags],
257
+ force: true,
168
258
  })
169
259
  stored++
170
260
  } catch {
@@ -237,6 +327,7 @@ export function extractFileKnowledge(
237
327
  // Take the first ~400 chars as a content preview
238
328
  const preview = output.slice(0, MAX_CHUNK)
239
329
  const basename = path.basename(filePath)
330
+ const fpTags = buildFingerprintTags(filePath)
240
331
 
241
332
  storeMemory({
242
333
  content: `File ${basename}: ${preview}`,
@@ -244,7 +335,8 @@ export function extractFileKnowledge(
244
335
  projectId,
245
336
  sessionId,
246
337
  source: `tool-read: ${filePath}`,
247
- tags: ["file-content", basename.toLowerCase()],
338
+ tags: ["file-content", basename.toLowerCase(), ...fpTags],
339
+ force: true,
248
340
  })
249
341
  } else if (toolName === "edit" || toolName === "write") {
250
342
  // Store what was edited/written
package/src/index.ts CHANGED
@@ -178,9 +178,9 @@ export default async function OpenRecallPlugin(
178
178
  // Inject memory context into the system prompt
179
179
  "experimental.chat.system.transform": async (input, output) => {
180
180
  const lines: string[] = [
181
- "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).",
181
+ "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
+ "CRITICAL: Before reading any file, call memory_file_check with the file path. If the file is unchanged, use the stored memory content directly — do NOT re-read it. Only read files that memory_file_check reports as modified or not found.",
182
183
  "When the user asks you to remember, save, store, or note something, you MUST call memory_store immediately.",
183
- "When starting work on a topic, call memory_search first to check for relevant past context.",
184
184
  "Use memory_store to save important findings, decisions, user preferences, and patterns.",
185
185
  ]
186
186
 
package/src/tools.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from "./memory"
20
20
  import { getConfig } from "./config"
21
21
  import { isDbAvailable } from "./db"
22
+ import { checkFileFreshness } from "./agent"
22
23
  import {
23
24
  runMaintenance,
24
25
  purgeOldMemories,
@@ -654,5 +655,35 @@ export function createTools(projectId: string) {
654
655
  }, "Failed to import memories")
655
656
  },
656
657
  }),
658
+
659
+ memory_file_check: tool({
660
+ description:
661
+ "Check if a file's content is already stored in memory and whether it is still current. " +
662
+ "Call this BEFORE reading any file. If the file is unchanged, use the returned memory content " +
663
+ "directly instead of re-reading the file. Only read files that this tool reports as modified or not found.",
664
+ args: {
665
+ file_path: tool.schema
666
+ .string()
667
+ .describe(
668
+ "The absolute or relative path of the file to check. " +
669
+ "This should be the same path you would pass to the read tool.",
670
+ ),
671
+ },
672
+ async execute(args) {
673
+ return safeExecute(() => {
674
+ const result = checkFileFreshness(args.file_path, projectId)
675
+
676
+ if (!result) {
677
+ return "No memory found for this file. Read it normally."
678
+ }
679
+
680
+ if (result.fresh) {
681
+ return `File unchanged since last read. Memory content:\n${result.storedContent}`
682
+ }
683
+
684
+ return "File has been modified since last read. Please re-read it."
685
+ }, "Failed to check file freshness")
686
+ },
687
+ }),
657
688
  }
658
689
  }