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 +1 -1
- package/src/agent.ts +100 -8
- package/src/index.ts +6 -2
- package/src/tools.ts +31 -0
package/package.json
CHANGED
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,
|
|
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,
|
|
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
|
}
|