openrecall 0.1.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/LICENSE +21 -0
- package/README.md +145 -0
- package/package.json +51 -0
- package/src/agent.ts +268 -0
- package/src/client.ts +16 -0
- package/src/config.ts +79 -0
- package/src/db.ts +93 -0
- package/src/extract.ts +142 -0
- package/src/index.ts +262 -0
- package/src/maintenance.ts +134 -0
- package/src/memory.ts +604 -0
- package/src/migrations/001_initial.ts +73 -0
- package/src/migrations/002_tags.ts +20 -0
- package/src/migrations/003_decay.ts +15 -0
- package/src/migrations/004_links.ts +23 -0
- package/src/migrations/005_metadata.ts +17 -0
- package/src/migrations/index.ts +16 -0
- package/src/tools.ts +658 -0
package/src/extract.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { storeMemory, findDuplicate } from "./memory"
|
|
2
|
+
import { isDbAvailable } from "./db"
|
|
3
|
+
import { getConfig } from "./config"
|
|
4
|
+
|
|
5
|
+
// Rate limit: max extractions per session
|
|
6
|
+
const sessionExtractionCount = new Map<string, number>()
|
|
7
|
+
const MAX_EXTRACTIONS_PER_SESSION = 20
|
|
8
|
+
|
|
9
|
+
// Preference patterns
|
|
10
|
+
const PREFERENCE_PATTERNS = [
|
|
11
|
+
/\b(?:always|never|prefer|don'?t|avoid)\b.*\b(?:use|do|make|write|create|run|call)\b/i,
|
|
12
|
+
/\b(?:i (?:always|never|prefer to|like to|want to))\b/i,
|
|
13
|
+
/\buser (?:prefers?|wants?|likes?)\b/i,
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
// Bug fix patterns — tool output indicating a fix was applied
|
|
17
|
+
const BUG_FIX_PATTERNS = [
|
|
18
|
+
/\bfixed?\b.*\b(?:by|with|using|adding|removing|changing)\b/i,
|
|
19
|
+
/\b(?:the )?(?:issue|bug|error|problem) was\b/i,
|
|
20
|
+
/\bresolved?\b.*\b(?:by|with)\b/i,
|
|
21
|
+
/\bworkaround\b/i,
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
// Convention patterns
|
|
25
|
+
const CONVENTION_PATTERNS = [
|
|
26
|
+
/\b(?:convention|standard|rule|guideline)\b.*\b(?:is|are|should|must)\b/i,
|
|
27
|
+
/\bproject (?:uses?|follows?|requires?)\b/i,
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
interface ExtractionCandidate {
|
|
31
|
+
content: string
|
|
32
|
+
category: string
|
|
33
|
+
source: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Extract potential memories from tool execution output */
|
|
37
|
+
export function extractFromToolOutput(
|
|
38
|
+
toolName: string,
|
|
39
|
+
args: any,
|
|
40
|
+
output: string,
|
|
41
|
+
sessionId: string,
|
|
42
|
+
projectId: string,
|
|
43
|
+
): void {
|
|
44
|
+
const config = getConfig()
|
|
45
|
+
if (!config.autoExtract) return
|
|
46
|
+
if (!isDbAvailable()) return
|
|
47
|
+
|
|
48
|
+
// Don't extract from our own memory tools
|
|
49
|
+
if (toolName.startsWith("memory_")) return
|
|
50
|
+
|
|
51
|
+
// Rate limit
|
|
52
|
+
const count = sessionExtractionCount.get(sessionId) || 0
|
|
53
|
+
if (count >= MAX_EXTRACTIONS_PER_SESSION) return
|
|
54
|
+
|
|
55
|
+
const candidates: ExtractionCandidate[] = []
|
|
56
|
+
|
|
57
|
+
// Check tool output for extractable patterns
|
|
58
|
+
const outputStr = typeof output === "string" ? output : String(output)
|
|
59
|
+
|
|
60
|
+
// Detect preferences from tool output or args
|
|
61
|
+
for (const pattern of PREFERENCE_PATTERNS) {
|
|
62
|
+
if (pattern.test(outputStr)) {
|
|
63
|
+
// Extract the matching sentence
|
|
64
|
+
const sentences = outputStr.split(/[.!?\n]/).filter((s) => s.trim().length > 10)
|
|
65
|
+
for (const sentence of sentences) {
|
|
66
|
+
if (pattern.test(sentence)) {
|
|
67
|
+
candidates.push({
|
|
68
|
+
content: sentence.trim(),
|
|
69
|
+
category: "preference",
|
|
70
|
+
source: `auto-extracted from ${toolName}`,
|
|
71
|
+
})
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Detect bug fixes
|
|
79
|
+
for (const pattern of BUG_FIX_PATTERNS) {
|
|
80
|
+
if (pattern.test(outputStr)) {
|
|
81
|
+
const sentences = outputStr.split(/[.!?\n]/).filter((s) => s.trim().length > 15)
|
|
82
|
+
for (const sentence of sentences) {
|
|
83
|
+
if (pattern.test(sentence)) {
|
|
84
|
+
candidates.push({
|
|
85
|
+
content: sentence.trim(),
|
|
86
|
+
category: "debugging",
|
|
87
|
+
source: `auto-extracted from ${toolName}`,
|
|
88
|
+
})
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Detect conventions
|
|
96
|
+
for (const pattern of CONVENTION_PATTERNS) {
|
|
97
|
+
if (pattern.test(outputStr)) {
|
|
98
|
+
const sentences = outputStr.split(/[.!?\n]/).filter((s) => s.trim().length > 10)
|
|
99
|
+
for (const sentence of sentences) {
|
|
100
|
+
if (pattern.test(sentence)) {
|
|
101
|
+
candidates.push({
|
|
102
|
+
content: sentence.trim(),
|
|
103
|
+
category: "convention",
|
|
104
|
+
source: `auto-extracted from ${toolName}`,
|
|
105
|
+
})
|
|
106
|
+
break
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Store candidates (dedup will handle duplicates)
|
|
113
|
+
for (const candidate of candidates) {
|
|
114
|
+
if (count + candidates.indexOf(candidate) >= MAX_EXTRACTIONS_PER_SESSION) break
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
// Skip if too short or too long
|
|
118
|
+
if (candidate.content.length < 15 || candidate.content.length > 500) continue
|
|
119
|
+
|
|
120
|
+
storeMemory({
|
|
121
|
+
content: candidate.content,
|
|
122
|
+
category: candidate.category,
|
|
123
|
+
sessionId,
|
|
124
|
+
projectId,
|
|
125
|
+
source: candidate.source,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
sessionExtractionCount.set(
|
|
129
|
+
sessionId,
|
|
130
|
+
(sessionExtractionCount.get(sessionId) || 0) + 1,
|
|
131
|
+
)
|
|
132
|
+
} catch (e) {
|
|
133
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
134
|
+
console.error(`[OpenRecall] Auto-extraction store failed for "${candidate.content.slice(0, 50)}...":`, msg)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Clear extraction tracking for a session */
|
|
140
|
+
export function clearSessionExtraction(sessionId: string): void {
|
|
141
|
+
sessionExtractionCount.delete(sessionId)
|
|
142
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { PluginInput, Hooks } from "@opencode-ai/plugin"
|
|
2
|
+
import { createTools } from "./tools"
|
|
3
|
+
import { getDb } from "./db"
|
|
4
|
+
import { isDbAvailable } from "./db"
|
|
5
|
+
import { initConfig, getConfig, type OpenRecallConfig } from "./config"
|
|
6
|
+
import { initClient } from "./client"
|
|
7
|
+
import { searchMemories, listMemories, getStats, sanitizeQuery } from "./memory"
|
|
8
|
+
import { maybeRunMaintenance } from "./maintenance"
|
|
9
|
+
import { extractFromToolOutput, clearSessionExtraction } from "./extract"
|
|
10
|
+
import { incrementCounter, clearCounter, scanProjectFiles, extractFileKnowledge } from "./agent"
|
|
11
|
+
|
|
12
|
+
// In-memory cache of session metadata for enriching memories
|
|
13
|
+
interface SessionInfo {
|
|
14
|
+
title?: string
|
|
15
|
+
directory?: string
|
|
16
|
+
}
|
|
17
|
+
const sessionContext = new Map<string, SessionInfo>()
|
|
18
|
+
|
|
19
|
+
// Cache recalled memories per session to avoid repeated lookups
|
|
20
|
+
const recalledMemories = new Map<string, string>()
|
|
21
|
+
// Track which sessions have had their first message processed
|
|
22
|
+
const sessionFirstMessage = new Set<string>()
|
|
23
|
+
|
|
24
|
+
export default async function OpenRecallPlugin(
|
|
25
|
+
inputRef: PluginInput,
|
|
26
|
+
): Promise<Hooks> {
|
|
27
|
+
const projectId = inputRef.project.id
|
|
28
|
+
|
|
29
|
+
// Store SDK client for hooks and tools to access OpenCode data
|
|
30
|
+
initClient(inputRef.client)
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
// Load config from opencode.json plugin options
|
|
34
|
+
async config(cfg: any) {
|
|
35
|
+
const pluginConfig = cfg?.plugins?.openrecall as
|
|
36
|
+
| Partial<OpenRecallConfig>
|
|
37
|
+
| undefined
|
|
38
|
+
initConfig(pluginConfig)
|
|
39
|
+
|
|
40
|
+
// Initialize database after config is loaded
|
|
41
|
+
try {
|
|
42
|
+
getDb()
|
|
43
|
+
// Run periodic maintenance if needed (every 7 days)
|
|
44
|
+
maybeRunMaintenance()
|
|
45
|
+
// Scan key project files on startup to pre-populate memory
|
|
46
|
+
scanProjectFiles(inputRef.directory, projectId)
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.error(
|
|
49
|
+
"[OpenRecall] Failed to initialize database. Memory features will be unavailable.",
|
|
50
|
+
e,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// Detect first message in a session and auto-recall relevant memories
|
|
56
|
+
async "chat.message"(input, output) {
|
|
57
|
+
const sessionId = input.sessionID
|
|
58
|
+
|
|
59
|
+
// Increment message counter for agent extraction triggering
|
|
60
|
+
incrementCounter(sessionId)
|
|
61
|
+
|
|
62
|
+
const config = getConfig()
|
|
63
|
+
if (!config.autoRecall) return
|
|
64
|
+
if (!isDbAvailable()) return
|
|
65
|
+
|
|
66
|
+
if (sessionFirstMessage.has(sessionId)) return
|
|
67
|
+
sessionFirstMessage.add(sessionId)
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// Extract text from the user's first message
|
|
71
|
+
const userText = extractUserText(output)
|
|
72
|
+
if (!userText) return
|
|
73
|
+
|
|
74
|
+
// Search for relevant memories using the first message as query
|
|
75
|
+
const sanitized = sanitizeQuery(userText)
|
|
76
|
+
let recalled: string[] = []
|
|
77
|
+
|
|
78
|
+
if (sanitized.trim()) {
|
|
79
|
+
const results = searchMemories({
|
|
80
|
+
query: sanitized,
|
|
81
|
+
projectId,
|
|
82
|
+
limit: config.searchLimit,
|
|
83
|
+
})
|
|
84
|
+
recalled = results.map((r) => {
|
|
85
|
+
const time = new Date(r.memory.time_created * 1000).toISOString()
|
|
86
|
+
return `[${r.memory.category.toUpperCase()}] ${r.memory.content} (${time})`
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Fall back to recent memories if no search matches
|
|
91
|
+
if (recalled.length === 0) {
|
|
92
|
+
const recent = listMemories({ projectId, limit: 5 })
|
|
93
|
+
recalled = recent.map((m) => {
|
|
94
|
+
const time = new Date(m.time_created * 1000).toISOString()
|
|
95
|
+
return `[${m.category.toUpperCase()}] ${m.content} (${time})`
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (recalled.length > 0) {
|
|
100
|
+
recalledMemories.set(
|
|
101
|
+
sessionId,
|
|
102
|
+
"Relevant memories from previous sessions:\n" +
|
|
103
|
+
recalled.map((r, i) => `${i + 1}. ${r}`).join("\n"),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
} catch (e) {
|
|
107
|
+
console.error("[OpenRecall] Auto-recall failed:", e)
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
// Track session lifecycle events
|
|
112
|
+
async event({ event }: { event: any }) {
|
|
113
|
+
if (!event || typeof event !== "object") return
|
|
114
|
+
const type = event.type as string | undefined
|
|
115
|
+
if (!type) return
|
|
116
|
+
|
|
117
|
+
if (type === "session.created" || type === "session.updated") {
|
|
118
|
+
const properties = event.properties as
|
|
119
|
+
| { id?: string; title?: string; directory?: string }
|
|
120
|
+
| undefined
|
|
121
|
+
if (properties?.id) {
|
|
122
|
+
sessionContext.set(properties.id, {
|
|
123
|
+
title: properties.title,
|
|
124
|
+
directory: properties.directory,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (type === "session.deleted") {
|
|
130
|
+
const properties = event.properties as { id?: string } | undefined
|
|
131
|
+
if (properties?.id) {
|
|
132
|
+
sessionContext.delete(properties.id)
|
|
133
|
+
recalledMemories.delete(properties.id)
|
|
134
|
+
sessionFirstMessage.delete(properties.id)
|
|
135
|
+
clearSessionExtraction(properties.id)
|
|
136
|
+
clearCounter(properties.id)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
// Auto-extract memories from tool execution results
|
|
142
|
+
async "tool.execute.after"(input, output) {
|
|
143
|
+
const config = getConfig()
|
|
144
|
+
|
|
145
|
+
// Pattern-based extraction from tool outputs
|
|
146
|
+
if (config.autoExtract) {
|
|
147
|
+
try {
|
|
148
|
+
extractFromToolOutput(
|
|
149
|
+
input.tool,
|
|
150
|
+
input.args,
|
|
151
|
+
output.output,
|
|
152
|
+
input.sessionID,
|
|
153
|
+
projectId,
|
|
154
|
+
)
|
|
155
|
+
} catch {
|
|
156
|
+
// Silent fail
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Track file reads/edits as project knowledge
|
|
161
|
+
try {
|
|
162
|
+
extractFileKnowledge(
|
|
163
|
+
input.tool,
|
|
164
|
+
input.args,
|
|
165
|
+
output.output,
|
|
166
|
+
input.sessionID,
|
|
167
|
+
projectId,
|
|
168
|
+
)
|
|
169
|
+
} catch (e) {
|
|
170
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
171
|
+
console.error("[OpenRecall] extractFileKnowledge error:", msg)
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
// Expose memory tools to the LLM
|
|
176
|
+
tool: createTools(projectId),
|
|
177
|
+
|
|
178
|
+
// Inject memory context into the system prompt
|
|
179
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
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).",
|
|
182
|
+
"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
|
+
"Use memory_store to save important findings, decisions, user preferences, and patterns.",
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
// Add dynamic summary if DB is available
|
|
188
|
+
if (isDbAvailable()) {
|
|
189
|
+
try {
|
|
190
|
+
const stats = getStats()
|
|
191
|
+
if (stats.total > 0) {
|
|
192
|
+
const catSummary = Object.entries(stats.byCategory)
|
|
193
|
+
.map(([cat, count]) => `${count} ${cat}`)
|
|
194
|
+
.join(", ")
|
|
195
|
+
lines.push(`[OpenRecall] ${stats.total} memories stored: ${catSummary}.`)
|
|
196
|
+
|
|
197
|
+
// Show last few recent memories as brief summaries
|
|
198
|
+
const recent = listMemories({ projectId, limit: 3 })
|
|
199
|
+
if (recent.length > 0) {
|
|
200
|
+
const previews = recent
|
|
201
|
+
.map((m) => {
|
|
202
|
+
const preview = m.content.length > 60
|
|
203
|
+
? m.content.slice(0, 57) + "..."
|
|
204
|
+
: m.content
|
|
205
|
+
return `"${preview}"`
|
|
206
|
+
})
|
|
207
|
+
.join(" | ")
|
|
208
|
+
lines.push(`Recent: ${previews}`)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
// Silent fail
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
output.system.push(lines.join("\n"))
|
|
217
|
+
|
|
218
|
+
// Inject auto-recalled memories if available
|
|
219
|
+
const sessionId = input.sessionID
|
|
220
|
+
if (sessionId) {
|
|
221
|
+
const memories = recalledMemories.get(sessionId)
|
|
222
|
+
if (memories) {
|
|
223
|
+
output.system.push(memories)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
// During compaction, remind to preserve important context
|
|
229
|
+
"experimental.session.compacting": async (_input, output) => {
|
|
230
|
+
output.context.push(
|
|
231
|
+
"Before compacting, consider using memory_store to save any important " +
|
|
232
|
+
"findings, decisions, or patterns from this session that should be " +
|
|
233
|
+
"remembered across future sessions.",
|
|
234
|
+
)
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Extract text content from a user message output */
|
|
240
|
+
function extractUserText(output: { message: any; parts: any[] }): string {
|
|
241
|
+
// Try to get text from parts first
|
|
242
|
+
if (output.parts && Array.isArray(output.parts)) {
|
|
243
|
+
const texts = output.parts
|
|
244
|
+
.filter((p: any) => p.type === "text" && p.text)
|
|
245
|
+
.map((p: any) => p.text)
|
|
246
|
+
if (texts.length > 0) return texts.join(" ")
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Fall back to message content
|
|
250
|
+
if (output.message) {
|
|
251
|
+
const msg = output.message as any
|
|
252
|
+
if (typeof msg.content === "string") return msg.content
|
|
253
|
+
if (Array.isArray(msg.content)) {
|
|
254
|
+
return msg.content
|
|
255
|
+
.filter((c: any) => c.type === "text" && c.text)
|
|
256
|
+
.map((c: any) => c.text)
|
|
257
|
+
.join(" ")
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return ""
|
|
262
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { getDb, isDbAvailable } from "./db"
|
|
2
|
+
import { getConfig } from "./config"
|
|
3
|
+
import { statSync } from "fs"
|
|
4
|
+
|
|
5
|
+
const CLEANUP_INTERVAL_DAYS = 7
|
|
6
|
+
|
|
7
|
+
export function getMetadata(key: string): string | null {
|
|
8
|
+
const db = getDb()
|
|
9
|
+
const row = db.prepare("SELECT value FROM metadata WHERE key = ?").get(key) as
|
|
10
|
+
| { value: string }
|
|
11
|
+
| undefined
|
|
12
|
+
return row?.value ?? null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function setMetadata(key: string, value: string): void {
|
|
16
|
+
const db = getDb()
|
|
17
|
+
db.prepare(
|
|
18
|
+
"INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
|
|
19
|
+
).run(key, value)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function optimizeFts(): void {
|
|
23
|
+
const db = getDb()
|
|
24
|
+
db.prepare("INSERT INTO memory_fts(memory_fts) VALUES('optimize')").run()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function vacuumDb(): void {
|
|
28
|
+
const db = getDb()
|
|
29
|
+
db.exec("VACUUM")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getDbSize(): number {
|
|
33
|
+
const config = getConfig()
|
|
34
|
+
try {
|
|
35
|
+
return statSync(config.dbPath).size
|
|
36
|
+
} catch {
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function purgeOldMemories(olderThanDays: number): number {
|
|
42
|
+
const db = getDb()
|
|
43
|
+
const cutoff = Math.floor(Date.now() / 1000) - olderThanDays * 86400
|
|
44
|
+
const result = db
|
|
45
|
+
.prepare(
|
|
46
|
+
"DELETE FROM memory WHERE time_created < ? AND access_count = 0 AND time_last_accessed IS NULL",
|
|
47
|
+
)
|
|
48
|
+
.run(cutoff)
|
|
49
|
+
return result.changes
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function enforceMaxMemories(): number {
|
|
53
|
+
const config = getConfig()
|
|
54
|
+
if (config.maxMemories <= 0) return 0
|
|
55
|
+
|
|
56
|
+
const db = getDb()
|
|
57
|
+
const count = (
|
|
58
|
+
db.prepare("SELECT COUNT(*) as count FROM memory").get() as { count: number }
|
|
59
|
+
).count
|
|
60
|
+
|
|
61
|
+
if (count <= config.maxMemories) return 0
|
|
62
|
+
|
|
63
|
+
const excess = count - config.maxMemories
|
|
64
|
+
// Delete oldest, least-accessed memories first
|
|
65
|
+
const result = db
|
|
66
|
+
.prepare(
|
|
67
|
+
`DELETE FROM memory WHERE id IN (
|
|
68
|
+
SELECT id FROM memory
|
|
69
|
+
ORDER BY access_count ASC, time_created ASC
|
|
70
|
+
LIMIT ?
|
|
71
|
+
)`,
|
|
72
|
+
)
|
|
73
|
+
.run(excess)
|
|
74
|
+
return result.changes
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function runMaintenance(): {
|
|
78
|
+
ftsOptimized: boolean
|
|
79
|
+
memoriesPurged: number
|
|
80
|
+
memoriesTrimmed: number
|
|
81
|
+
dbSizeBytes: number
|
|
82
|
+
} {
|
|
83
|
+
const result = {
|
|
84
|
+
ftsOptimized: false,
|
|
85
|
+
memoriesPurged: 0,
|
|
86
|
+
memoriesTrimmed: 0,
|
|
87
|
+
dbSizeBytes: 0,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
optimizeFts()
|
|
92
|
+
result.ftsOptimized = true
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.error("[OpenRecall] FTS optimization failed:", e)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
result.memoriesTrimmed = enforceMaxMemories()
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.error("[OpenRecall] Max memories enforcement failed:", e)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
result.dbSizeBytes = getDbSize()
|
|
104
|
+
|
|
105
|
+
setMetadata("last_maintenance", String(Math.floor(Date.now() / 1000)))
|
|
106
|
+
|
|
107
|
+
return result
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function shouldRunMaintenance(): boolean {
|
|
111
|
+
if (!isDbAvailable()) return false
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const last = getMetadata("last_maintenance")
|
|
115
|
+
if (!last) return true
|
|
116
|
+
|
|
117
|
+
const lastTs = parseInt(last, 10)
|
|
118
|
+
const now = Math.floor(Date.now() / 1000)
|
|
119
|
+
const daysSinceLast = (now - lastTs) / 86400
|
|
120
|
+
return daysSinceLast >= CLEANUP_INTERVAL_DAYS
|
|
121
|
+
} catch {
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function maybeRunMaintenance(): void {
|
|
127
|
+
if (shouldRunMaintenance()) {
|
|
128
|
+
try {
|
|
129
|
+
runMaintenance()
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.error("[OpenRecall] Auto-maintenance failed:", e)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|