openrecall 0.1.0 → 0.2.1

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.1",
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,13 @@ 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
+ "MANDATORY FILE ACCESS RULE: You MUST call memory_file_check(file_path) BEFORE every file read. " +
183
+ "If memory_file_check returns content (\"File unchanged\"), you MUST use that content and MUST NOT call read/Read on that file. " +
184
+ "Only call read/Read if memory_file_check returns \"No memory found\" or \"File has been modified\". " +
185
+ "Calling read on a file after memory_file_check already returned its content is FORBIDDEN — it wastes tokens and time. " +
186
+ "This rule applies to ALL files, every time, with no exceptions.",
182
187
  "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
188
  "Use memory_store to save important findings, decisions, user preferences, and patterns.",
185
189
  ]
186
190
 
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
+ "MANDATORY: Call this BEFORE every file read. Returns cached file content if the file is unchanged, " +
662
+ "saving a read call. If this returns 'File unchanged' with content, you MUST use that content and " +
663
+ "MUST NOT call read on the file. Only read the file if this returns 'No memory found' or 'File has been modified'.",
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
  }