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 +1 -1
- package/src/agent.ts +100 -8
- package/src/index.ts +2 -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,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
|
}
|