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/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
+ }