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/memory.ts
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import { getDb } from "./db"
|
|
2
|
+
|
|
3
|
+
export interface Memory {
|
|
4
|
+
id: string
|
|
5
|
+
content: string
|
|
6
|
+
category: string
|
|
7
|
+
session_id: string | null
|
|
8
|
+
project_id: string | null
|
|
9
|
+
source: string | null
|
|
10
|
+
time_created: number
|
|
11
|
+
time_updated: number
|
|
12
|
+
access_count: number
|
|
13
|
+
time_last_accessed: number | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StoreInput {
|
|
17
|
+
content: string
|
|
18
|
+
category?: string
|
|
19
|
+
sessionId?: string
|
|
20
|
+
projectId?: string
|
|
21
|
+
source?: string
|
|
22
|
+
tags?: string[]
|
|
23
|
+
global?: boolean
|
|
24
|
+
force?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SearchInput {
|
|
28
|
+
query: string
|
|
29
|
+
category?: string
|
|
30
|
+
projectId?: string
|
|
31
|
+
limit?: number
|
|
32
|
+
decayRate?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SearchResult {
|
|
36
|
+
memory: Memory
|
|
37
|
+
rank: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function generateId(): string {
|
|
41
|
+
return crypto.randomUUID()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// FTS5 special characters that need escaping (includes - prefix NOT, ` backtick, . period)
|
|
45
|
+
const FTS5_SPECIAL = /["*(){}[\]:^~!&|@#$%+=\\<>,;?/\-`.'']/g
|
|
46
|
+
|
|
47
|
+
// Common English stop words to strip for better matching
|
|
48
|
+
const STOP_WORDS = new Set([
|
|
49
|
+
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
|
50
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
51
|
+
"should", "may", "might", "shall", "can", "to", "of", "in", "for",
|
|
52
|
+
"on", "with", "at", "by", "from", "as", "into", "about", "it",
|
|
53
|
+
"its", "this", "that", "these", "those", "i", "we", "you", "he",
|
|
54
|
+
"she", "they", "me", "him", "her", "us", "them", "my", "your",
|
|
55
|
+
"his", "our", "their", "what", "which", "who", "whom", "how",
|
|
56
|
+
"when", "where", "why", "not", "no", "nor", "so", "if", "or",
|
|
57
|
+
"and", "but", "than", "too", "very", "just",
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
// FTS5 boolean operators that must not appear as tokens in queries
|
|
61
|
+
const FTS5_OPERATORS = new Set(["and", "or", "not", "near"])
|
|
62
|
+
|
|
63
|
+
export function sanitizeQuery(raw: string): string {
|
|
64
|
+
// Remove FTS5 special characters
|
|
65
|
+
let query = raw.replace(FTS5_SPECIAL, " ")
|
|
66
|
+
|
|
67
|
+
// Tokenize and filter
|
|
68
|
+
const tokens = query
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.split(/\s+/)
|
|
71
|
+
.filter((t) => t.length > 1 && !STOP_WORDS.has(t) && !FTS5_OPERATORS.has(t))
|
|
72
|
+
|
|
73
|
+
if (tokens.length === 0) {
|
|
74
|
+
// Fallback: use original words without special chars, still filtering operators
|
|
75
|
+
const fallback = raw
|
|
76
|
+
.replace(FTS5_SPECIAL, " ")
|
|
77
|
+
.split(/\s+/)
|
|
78
|
+
.filter((t) => t.length > 1 && !FTS5_OPERATORS.has(t.toLowerCase()))
|
|
79
|
+
.join(" ")
|
|
80
|
+
.trim()
|
|
81
|
+
return fallback || raw.replace(/[^\w\s]/g, " ").trim()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Join with implicit AND (FTS5 default)
|
|
85
|
+
return tokens.join(" ")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Normalize text for deduplication comparison */
|
|
89
|
+
function normalizeText(text: string): string {
|
|
90
|
+
return text.toLowerCase().trim().replace(/\s+/g, " ")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Check for duplicate/near-duplicate memories */
|
|
94
|
+
export function findDuplicate(
|
|
95
|
+
content: string,
|
|
96
|
+
projectId: string | null,
|
|
97
|
+
): { type: "exact" | "near"; memory: Memory } | null {
|
|
98
|
+
const db = getDb()
|
|
99
|
+
const normalized = normalizeText(content)
|
|
100
|
+
|
|
101
|
+
// Check exact match (normalized)
|
|
102
|
+
let query = "SELECT * FROM memory WHERE 1=1"
|
|
103
|
+
const params: any[] = []
|
|
104
|
+
|
|
105
|
+
if (projectId) {
|
|
106
|
+
query += " AND (project_id = ? OR project_id IS NULL)"
|
|
107
|
+
params.push(projectId)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const candidates = db.prepare(query + " ORDER BY time_created DESC LIMIT 100").all(...params) as Memory[]
|
|
111
|
+
|
|
112
|
+
for (const m of candidates) {
|
|
113
|
+
if (normalizeText(m.content) === normalized) {
|
|
114
|
+
return { type: "exact", memory: m }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check near-duplicate via FTS5 search (use OR matching for broader candidate retrieval)
|
|
119
|
+
try {
|
|
120
|
+
const sanitized = sanitizeQuery(content)
|
|
121
|
+
// Filter tokens again to ensure no FTS5 operators remain after sanitization
|
|
122
|
+
const tokens = sanitized.trim().split(/\s+/).filter(
|
|
123
|
+
(t) => t.length > 0 && !FTS5_OPERATORS.has(t.toLowerCase()),
|
|
124
|
+
)
|
|
125
|
+
if (tokens.length > 0) {
|
|
126
|
+
const orQuery = tokens.join(" OR ")
|
|
127
|
+
let ftsQuery = `
|
|
128
|
+
SELECT m.*, fts.rank
|
|
129
|
+
FROM memory_fts fts
|
|
130
|
+
JOIN memory m ON m.id = fts.memory_id
|
|
131
|
+
WHERE memory_fts MATCH ?
|
|
132
|
+
`
|
|
133
|
+
const ftsParams: any[] = [orQuery]
|
|
134
|
+
|
|
135
|
+
if (projectId) {
|
|
136
|
+
ftsQuery += " AND (m.project_id = ? OR m.project_id IS NULL)"
|
|
137
|
+
ftsParams.push(projectId)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
ftsQuery += " ORDER BY rank LIMIT 5"
|
|
141
|
+
|
|
142
|
+
const results = db.prepare(ftsQuery).all(...ftsParams) as (Memory & { rank: number })[]
|
|
143
|
+
|
|
144
|
+
for (const r of results) {
|
|
145
|
+
// BM25 rank is negative; very high similarity = very negative rank
|
|
146
|
+
// Heuristic: if normalized content is very similar in length and keywords overlap heavily
|
|
147
|
+
const rNorm = normalizeText(r.content)
|
|
148
|
+
const similarity = computeSimilarity(normalized, rNorm)
|
|
149
|
+
if (similarity > 0.8) {
|
|
150
|
+
return { type: "near", memory: r }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch (e) {
|
|
155
|
+
// FTS5 query can still fail on unexpected input; log and fall through to allow store
|
|
156
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
157
|
+
console.error("[OpenRecall] FTS5 dedup query failed:", msg)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Simple word-overlap similarity (Jaccard index) */
|
|
164
|
+
function computeSimilarity(a: string, b: string): number {
|
|
165
|
+
const wordsA = new Set(a.split(" ").filter((w) => w.length > 1))
|
|
166
|
+
const wordsB = new Set(b.split(" ").filter((w) => w.length > 1))
|
|
167
|
+
if (wordsA.size === 0 && wordsB.size === 0) return 1
|
|
168
|
+
if (wordsA.size === 0 || wordsB.size === 0) return 0
|
|
169
|
+
|
|
170
|
+
let intersection = 0
|
|
171
|
+
for (const w of wordsA) {
|
|
172
|
+
if (wordsB.has(w)) intersection++
|
|
173
|
+
}
|
|
174
|
+
const union = new Set([...wordsA, ...wordsB]).size
|
|
175
|
+
return intersection / union
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function storeMemory(input: StoreInput): Memory {
|
|
179
|
+
const db = getDb()
|
|
180
|
+
const id = generateId()
|
|
181
|
+
const now = Math.floor(Date.now() / 1000)
|
|
182
|
+
|
|
183
|
+
// Global memories have no project_id
|
|
184
|
+
const projectId = input.global ? null : (input.projectId || null)
|
|
185
|
+
|
|
186
|
+
// Deduplication check (skip if force=true)
|
|
187
|
+
if (!input.force) {
|
|
188
|
+
const dup = findDuplicate(input.content, projectId)
|
|
189
|
+
if (dup?.type === "exact") {
|
|
190
|
+
// Return existing memory instead of creating duplicate
|
|
191
|
+
return dup.memory
|
|
192
|
+
}
|
|
193
|
+
// For near-duplicates, update existing memory with newer content
|
|
194
|
+
if (dup?.type === "near") {
|
|
195
|
+
const updated = updateMemory(dup.memory.id, {
|
|
196
|
+
content: input.content,
|
|
197
|
+
category: input.category || dup.memory.category,
|
|
198
|
+
source: input.source || dup.memory.source || undefined,
|
|
199
|
+
})
|
|
200
|
+
return updated || dup.memory
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
db.prepare(
|
|
205
|
+
`INSERT INTO memory (id, content, category, session_id, project_id, source, time_created, time_updated)
|
|
206
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
207
|
+
).run(
|
|
208
|
+
id,
|
|
209
|
+
input.content,
|
|
210
|
+
input.category || "general",
|
|
211
|
+
input.sessionId || null,
|
|
212
|
+
projectId,
|
|
213
|
+
input.source || null,
|
|
214
|
+
now,
|
|
215
|
+
now,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if (input.tags && input.tags.length > 0) {
|
|
219
|
+
const stmt = db.prepare(
|
|
220
|
+
"INSERT OR IGNORE INTO memory_tags (memory_id, tag) VALUES (?, ?)",
|
|
221
|
+
)
|
|
222
|
+
for (const tag of input.tags) {
|
|
223
|
+
stmt.run(id, tag.toLowerCase().trim())
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
id,
|
|
229
|
+
content: input.content,
|
|
230
|
+
category: input.category || "general",
|
|
231
|
+
session_id: input.sessionId || null,
|
|
232
|
+
project_id: projectId,
|
|
233
|
+
source: input.source || null,
|
|
234
|
+
time_created: now,
|
|
235
|
+
time_updated: now,
|
|
236
|
+
access_count: 0,
|
|
237
|
+
time_last_accessed: null,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Default decay rate: half-life of ~90 days */
|
|
242
|
+
const DEFAULT_DECAY_RATE = 0.0077
|
|
243
|
+
|
|
244
|
+
export function searchMemories(input: SearchInput): SearchResult[] {
|
|
245
|
+
const db = getDb()
|
|
246
|
+
const limit = input.limit || 10
|
|
247
|
+
|
|
248
|
+
const sanitized = sanitizeQuery(input.query)
|
|
249
|
+
if (!sanitized.trim()) return []
|
|
250
|
+
|
|
251
|
+
// Fetch more than needed so we can re-rank with decay
|
|
252
|
+
const fetchLimit = Math.min(limit * 3, 100)
|
|
253
|
+
|
|
254
|
+
// Use FTS5 for full-text search with BM25 ranking
|
|
255
|
+
let query = `
|
|
256
|
+
SELECT m.*, fts.rank
|
|
257
|
+
FROM memory_fts fts
|
|
258
|
+
JOIN memory m ON m.id = fts.memory_id
|
|
259
|
+
WHERE memory_fts MATCH ?
|
|
260
|
+
`
|
|
261
|
+
const params: any[] = [sanitized]
|
|
262
|
+
|
|
263
|
+
if (input.category) {
|
|
264
|
+
query += ` AND m.category = ?`
|
|
265
|
+
params.push(input.category)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (input.projectId) {
|
|
269
|
+
// Include project-specific AND global (project_id IS NULL) memories
|
|
270
|
+
query += ` AND (m.project_id = ? OR m.project_id IS NULL)`
|
|
271
|
+
params.push(input.projectId)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
query += ` ORDER BY rank LIMIT ?`
|
|
275
|
+
params.push(fetchLimit)
|
|
276
|
+
|
|
277
|
+
let rows: (Memory & { rank: number })[]
|
|
278
|
+
try {
|
|
279
|
+
rows = db.prepare(query).all(...params) as (Memory & { rank: number })[]
|
|
280
|
+
} catch (e) {
|
|
281
|
+
// FTS5 query can fail on unexpected input; log and return empty
|
|
282
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
283
|
+
console.error("[OpenRecall] FTS5 search query failed:", msg, "| sanitized query:", sanitized)
|
|
284
|
+
return []
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const now = Math.floor(Date.now() / 1000)
|
|
288
|
+
const decayRate = input.decayRate ?? DEFAULT_DECAY_RATE
|
|
289
|
+
|
|
290
|
+
// Apply time decay and access boost, then re-rank
|
|
291
|
+
const scored = rows.map((row) => {
|
|
292
|
+
const ageDays = (now - row.time_created) / 86400
|
|
293
|
+
const decayFactor = 1 / (1 + ageDays * decayRate)
|
|
294
|
+
|
|
295
|
+
// Boost for recently/frequently accessed memories
|
|
296
|
+
const accessBoost = Math.log2(1 + (row.access_count || 0)) * 0.1
|
|
297
|
+
|
|
298
|
+
// BM25 rank is negative (lower = better), so we keep it negative and multiply
|
|
299
|
+
const finalRank = row.rank * decayFactor - accessBoost
|
|
300
|
+
|
|
301
|
+
return { row, finalRank }
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
// Sort by finalRank (most negative = best match)
|
|
305
|
+
scored.sort((a, b) => a.finalRank - b.finalRank)
|
|
306
|
+
const top = scored.slice(0, limit)
|
|
307
|
+
|
|
308
|
+
// Update access tracking for returned results
|
|
309
|
+
if (top.length > 0) {
|
|
310
|
+
const stmt = db.prepare(
|
|
311
|
+
"UPDATE memory SET access_count = access_count + 1, time_last_accessed = ? WHERE id = ?",
|
|
312
|
+
)
|
|
313
|
+
for (const { row } of top) {
|
|
314
|
+
stmt.run(now, row.id)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return top.map(({ row, finalRank }) => ({
|
|
319
|
+
memory: {
|
|
320
|
+
id: row.id,
|
|
321
|
+
content: row.content,
|
|
322
|
+
category: row.category,
|
|
323
|
+
session_id: row.session_id,
|
|
324
|
+
project_id: row.project_id,
|
|
325
|
+
source: row.source,
|
|
326
|
+
time_created: row.time_created,
|
|
327
|
+
time_updated: row.time_updated,
|
|
328
|
+
access_count: (row.access_count || 0) + 1,
|
|
329
|
+
time_last_accessed: now,
|
|
330
|
+
},
|
|
331
|
+
rank: finalRank,
|
|
332
|
+
}))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface UpdateInput {
|
|
336
|
+
content?: string
|
|
337
|
+
category?: string
|
|
338
|
+
source?: string
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function updateMemory(id: string, input: UpdateInput): Memory | null {
|
|
342
|
+
const db = getDb()
|
|
343
|
+
const existing = getMemory(id)
|
|
344
|
+
if (!existing) return null
|
|
345
|
+
|
|
346
|
+
const fields: string[] = []
|
|
347
|
+
const params: any[] = []
|
|
348
|
+
|
|
349
|
+
if (input.content !== undefined) {
|
|
350
|
+
fields.push("content = ?")
|
|
351
|
+
params.push(input.content)
|
|
352
|
+
}
|
|
353
|
+
if (input.category !== undefined) {
|
|
354
|
+
fields.push("category = ?")
|
|
355
|
+
params.push(input.category)
|
|
356
|
+
}
|
|
357
|
+
if (input.source !== undefined) {
|
|
358
|
+
fields.push("source = ?")
|
|
359
|
+
params.push(input.source)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (fields.length === 0) return existing
|
|
363
|
+
|
|
364
|
+
fields.push("time_updated = ?")
|
|
365
|
+
const now = Math.floor(Date.now() / 1000)
|
|
366
|
+
params.push(now)
|
|
367
|
+
params.push(id)
|
|
368
|
+
|
|
369
|
+
db.prepare(`UPDATE memory SET ${fields.join(", ")} WHERE id = ?`).run(
|
|
370
|
+
...params,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
return getMemory(id)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function getMemory(id: string): Memory | null {
|
|
377
|
+
const db = getDb()
|
|
378
|
+
return (db.prepare("SELECT * FROM memory WHERE id = ?").get(id) as Memory) || null
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function deleteMemory(id: string): boolean {
|
|
382
|
+
const db = getDb()
|
|
383
|
+
const result = db.prepare("DELETE FROM memory WHERE id = ?").run(id)
|
|
384
|
+
return result.changes > 0
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function listMemories(opts?: {
|
|
388
|
+
category?: string
|
|
389
|
+
projectId?: string
|
|
390
|
+
sessionId?: string
|
|
391
|
+
scope?: "project" | "global" | "all"
|
|
392
|
+
limit?: number
|
|
393
|
+
}): Memory[] {
|
|
394
|
+
const db = getDb()
|
|
395
|
+
let query = "SELECT * FROM memory WHERE 1=1"
|
|
396
|
+
const params: any[] = []
|
|
397
|
+
|
|
398
|
+
if (opts?.category) {
|
|
399
|
+
query += " AND category = ?"
|
|
400
|
+
params.push(opts.category)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const scope = opts?.scope || "all"
|
|
404
|
+
if (opts?.projectId && scope === "project") {
|
|
405
|
+
query += " AND project_id = ?"
|
|
406
|
+
params.push(opts.projectId)
|
|
407
|
+
} else if (scope === "global") {
|
|
408
|
+
query += " AND project_id IS NULL"
|
|
409
|
+
} else if (opts?.projectId && scope === "all") {
|
|
410
|
+
query += " AND (project_id = ? OR project_id IS NULL)"
|
|
411
|
+
params.push(opts.projectId)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (opts?.sessionId) {
|
|
415
|
+
query += " AND session_id = ?"
|
|
416
|
+
params.push(opts.sessionId)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
query += " ORDER BY time_created DESC LIMIT ?"
|
|
420
|
+
params.push(opts?.limit || 20)
|
|
421
|
+
|
|
422
|
+
return db.prepare(query).all(...params) as Memory[]
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function getStats(): {
|
|
426
|
+
total: number
|
|
427
|
+
byCategory: Record<string, number>
|
|
428
|
+
} {
|
|
429
|
+
const db = getDb()
|
|
430
|
+
const total = (
|
|
431
|
+
db.prepare("SELECT COUNT(*) as count FROM memory").get() as {
|
|
432
|
+
count: number
|
|
433
|
+
}
|
|
434
|
+
).count
|
|
435
|
+
|
|
436
|
+
const categories = db
|
|
437
|
+
.prepare(
|
|
438
|
+
"SELECT category, COUNT(*) as count FROM memory GROUP BY category",
|
|
439
|
+
)
|
|
440
|
+
.all() as { category: string; count: number }[]
|
|
441
|
+
|
|
442
|
+
const byCategory: Record<string, number> = {}
|
|
443
|
+
for (const row of categories) {
|
|
444
|
+
byCategory[row.category] = row.count
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return { total, byCategory }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function refreshMemory(id: string): Memory | null {
|
|
451
|
+
const db = getDb()
|
|
452
|
+
const existing = getMemory(id)
|
|
453
|
+
if (!existing) return null
|
|
454
|
+
|
|
455
|
+
const now = Math.floor(Date.now() / 1000)
|
|
456
|
+
db.prepare(
|
|
457
|
+
"UPDATE memory SET access_count = access_count + 5, time_last_accessed = ? WHERE id = ?",
|
|
458
|
+
).run(now, id)
|
|
459
|
+
|
|
460
|
+
return getMemory(id)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// --- Tag operations ---
|
|
464
|
+
|
|
465
|
+
export function getTagsForMemory(memoryId: string): string[] {
|
|
466
|
+
const db = getDb()
|
|
467
|
+
const rows = db
|
|
468
|
+
.prepare("SELECT tag FROM memory_tags WHERE memory_id = ?")
|
|
469
|
+
.all(memoryId) as { tag: string }[]
|
|
470
|
+
return rows.map((r) => r.tag)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function setTags(memoryId: string, tags: string[]): void {
|
|
474
|
+
const db = getDb()
|
|
475
|
+
db.prepare("DELETE FROM memory_tags WHERE memory_id = ?").run(memoryId)
|
|
476
|
+
if (tags.length > 0) {
|
|
477
|
+
const stmt = db.prepare(
|
|
478
|
+
"INSERT OR IGNORE INTO memory_tags (memory_id, tag) VALUES (?, ?)",
|
|
479
|
+
)
|
|
480
|
+
for (const tag of tags) {
|
|
481
|
+
stmt.run(memoryId, tag.toLowerCase().trim())
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export function addTags(memoryId: string, tags: string[]): void {
|
|
487
|
+
const db = getDb()
|
|
488
|
+
const stmt = db.prepare(
|
|
489
|
+
"INSERT OR IGNORE INTO memory_tags (memory_id, tag) VALUES (?, ?)",
|
|
490
|
+
)
|
|
491
|
+
for (const tag of tags) {
|
|
492
|
+
stmt.run(memoryId, tag.toLowerCase().trim())
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function removeTags(memoryId: string, tags: string[]): void {
|
|
497
|
+
const db = getDb()
|
|
498
|
+
const stmt = db.prepare(
|
|
499
|
+
"DELETE FROM memory_tags WHERE memory_id = ? AND tag = ?",
|
|
500
|
+
)
|
|
501
|
+
for (const tag of tags) {
|
|
502
|
+
stmt.run(memoryId, tag.toLowerCase().trim())
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function listAllTags(): { tag: string; count: number }[] {
|
|
507
|
+
const db = getDb()
|
|
508
|
+
return db
|
|
509
|
+
.prepare(
|
|
510
|
+
"SELECT tag, COUNT(*) as count FROM memory_tags GROUP BY tag ORDER BY count DESC",
|
|
511
|
+
)
|
|
512
|
+
.all() as { tag: string; count: number }[]
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function searchByTag(
|
|
516
|
+
tag: string,
|
|
517
|
+
opts?: { projectId?: string; limit?: number },
|
|
518
|
+
): Memory[] {
|
|
519
|
+
const db = getDb()
|
|
520
|
+
let query = `
|
|
521
|
+
SELECT m.* FROM memory m
|
|
522
|
+
JOIN memory_tags t ON t.memory_id = m.id
|
|
523
|
+
WHERE t.tag = ?
|
|
524
|
+
`
|
|
525
|
+
const params: any[] = [tag.toLowerCase().trim()]
|
|
526
|
+
|
|
527
|
+
if (opts?.projectId) {
|
|
528
|
+
query += " AND m.project_id = ?"
|
|
529
|
+
params.push(opts.projectId)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
query += " ORDER BY m.time_created DESC LIMIT ?"
|
|
533
|
+
params.push(opts?.limit || 20)
|
|
534
|
+
|
|
535
|
+
return db.prepare(query).all(...params) as Memory[]
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// --- Link operations ---
|
|
539
|
+
|
|
540
|
+
export type LinkRelationship = "related" | "supersedes" | "contradicts" | "extends"
|
|
541
|
+
|
|
542
|
+
export interface MemoryLink {
|
|
543
|
+
source_id: string
|
|
544
|
+
target_id: string
|
|
545
|
+
relationship: LinkRelationship
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export function addLink(
|
|
549
|
+
sourceId: string,
|
|
550
|
+
targetId: string,
|
|
551
|
+
relationship: LinkRelationship,
|
|
552
|
+
): boolean {
|
|
553
|
+
const db = getDb()
|
|
554
|
+
// Verify both memories exist
|
|
555
|
+
if (!getMemory(sourceId) || !getMemory(targetId)) return false
|
|
556
|
+
if (sourceId === targetId) return false
|
|
557
|
+
|
|
558
|
+
db.prepare(
|
|
559
|
+
"INSERT OR REPLACE INTO memory_links (source_id, target_id, relationship) VALUES (?, ?, ?)",
|
|
560
|
+
).run(sourceId, targetId, relationship)
|
|
561
|
+
return true
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function removeLink(sourceId: string, targetId: string): boolean {
|
|
565
|
+
const db = getDb()
|
|
566
|
+
const result = db
|
|
567
|
+
.prepare("DELETE FROM memory_links WHERE source_id = ? AND target_id = ?")
|
|
568
|
+
.run(sourceId, targetId)
|
|
569
|
+
return result.changes > 0
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function getLinksForMemory(memoryId: string): (MemoryLink & { linked_memory: Memory })[] {
|
|
573
|
+
const db = getDb()
|
|
574
|
+
const rows = db
|
|
575
|
+
.prepare(
|
|
576
|
+
`SELECT l.source_id, l.target_id, l.relationship,
|
|
577
|
+
m.id, m.content, m.category, m.session_id, m.project_id,
|
|
578
|
+
m.source, m.time_created, m.time_updated, m.access_count, m.time_last_accessed
|
|
579
|
+
FROM memory_links l
|
|
580
|
+
JOIN memory m ON (
|
|
581
|
+
CASE WHEN l.source_id = ? THEN l.target_id ELSE l.source_id END = m.id
|
|
582
|
+
)
|
|
583
|
+
WHERE l.source_id = ? OR l.target_id = ?`,
|
|
584
|
+
)
|
|
585
|
+
.all(memoryId, memoryId, memoryId) as any[]
|
|
586
|
+
|
|
587
|
+
return rows.map((row) => ({
|
|
588
|
+
source_id: row.source_id,
|
|
589
|
+
target_id: row.target_id,
|
|
590
|
+
relationship: row.relationship,
|
|
591
|
+
linked_memory: {
|
|
592
|
+
id: row.id,
|
|
593
|
+
content: row.content,
|
|
594
|
+
category: row.category,
|
|
595
|
+
session_id: row.session_id,
|
|
596
|
+
project_id: row.project_id,
|
|
597
|
+
source: row.source,
|
|
598
|
+
time_created: row.time_created,
|
|
599
|
+
time_updated: row.time_updated,
|
|
600
|
+
access_count: row.access_count,
|
|
601
|
+
time_last_accessed: row.time_last_accessed,
|
|
602
|
+
},
|
|
603
|
+
}))
|
|
604
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
export const version = 1
|
|
4
|
+
export const description = "Initial schema: memory table, FTS5, triggers, indexes"
|
|
5
|
+
|
|
6
|
+
export function up(db: Database) {
|
|
7
|
+
db.run(`
|
|
8
|
+
CREATE TABLE IF NOT EXISTS memory (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
content TEXT NOT NULL,
|
|
11
|
+
category TEXT NOT NULL DEFAULT 'general',
|
|
12
|
+
session_id TEXT,
|
|
13
|
+
project_id TEXT,
|
|
14
|
+
source TEXT,
|
|
15
|
+
time_created INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
16
|
+
time_updated INTEGER NOT NULL DEFAULT (unixepoch())
|
|
17
|
+
)
|
|
18
|
+
`)
|
|
19
|
+
|
|
20
|
+
// Standalone FTS5 table — stores its own copy of indexed text
|
|
21
|
+
db.run(`
|
|
22
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
|
23
|
+
memory_id UNINDEXED,
|
|
24
|
+
content,
|
|
25
|
+
category,
|
|
26
|
+
source,
|
|
27
|
+
tokenize='porter unicode61'
|
|
28
|
+
)
|
|
29
|
+
`)
|
|
30
|
+
|
|
31
|
+
// Triggers to keep FTS in sync with memory table
|
|
32
|
+
db.run(`
|
|
33
|
+
CREATE TRIGGER IF NOT EXISTS memory_ai AFTER INSERT ON memory BEGIN
|
|
34
|
+
INSERT INTO memory_fts(memory_id, content, category, source)
|
|
35
|
+
VALUES (NEW.id, NEW.content, NEW.category, NEW.source);
|
|
36
|
+
END
|
|
37
|
+
`)
|
|
38
|
+
|
|
39
|
+
db.run(`
|
|
40
|
+
CREATE TRIGGER IF NOT EXISTS memory_ad AFTER DELETE ON memory BEGIN
|
|
41
|
+
DELETE FROM memory_fts WHERE memory_id = OLD.id;
|
|
42
|
+
END
|
|
43
|
+
`)
|
|
44
|
+
|
|
45
|
+
db.run(`
|
|
46
|
+
CREATE TRIGGER IF NOT EXISTS memory_au AFTER UPDATE ON memory BEGIN
|
|
47
|
+
DELETE FROM memory_fts WHERE memory_id = OLD.id;
|
|
48
|
+
INSERT INTO memory_fts(memory_id, content, category, source)
|
|
49
|
+
VALUES (NEW.id, NEW.content, NEW.category, NEW.source);
|
|
50
|
+
END
|
|
51
|
+
`)
|
|
52
|
+
|
|
53
|
+
db.run(
|
|
54
|
+
`CREATE INDEX IF NOT EXISTS idx_memory_session ON memory(session_id)`,
|
|
55
|
+
)
|
|
56
|
+
db.run(
|
|
57
|
+
`CREATE INDEX IF NOT EXISTS idx_memory_category ON memory(category)`,
|
|
58
|
+
)
|
|
59
|
+
db.run(
|
|
60
|
+
`CREATE INDEX IF NOT EXISTS idx_memory_project ON memory(project_id)`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function down(db: Database) {
|
|
65
|
+
db.run("DROP INDEX IF EXISTS idx_memory_project")
|
|
66
|
+
db.run("DROP INDEX IF EXISTS idx_memory_category")
|
|
67
|
+
db.run("DROP INDEX IF EXISTS idx_memory_session")
|
|
68
|
+
db.run("DROP TRIGGER IF EXISTS memory_au")
|
|
69
|
+
db.run("DROP TRIGGER IF EXISTS memory_ad")
|
|
70
|
+
db.run("DROP TRIGGER IF EXISTS memory_ai")
|
|
71
|
+
db.run("DROP TABLE IF EXISTS memory_fts")
|
|
72
|
+
db.run("DROP TABLE IF EXISTS memory")
|
|
73
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
export const version = 2
|
|
4
|
+
export const description = "Add memory tagging system"
|
|
5
|
+
|
|
6
|
+
export function up(db: Database) {
|
|
7
|
+
db.run(`
|
|
8
|
+
CREATE TABLE IF NOT EXISTS memory_tags (
|
|
9
|
+
memory_id TEXT NOT NULL REFERENCES memory(id) ON DELETE CASCADE,
|
|
10
|
+
tag TEXT NOT NULL,
|
|
11
|
+
PRIMARY KEY (memory_id, tag)
|
|
12
|
+
)
|
|
13
|
+
`)
|
|
14
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_tags_tag ON memory_tags(tag)`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function down(db: Database) {
|
|
18
|
+
db.run("DROP INDEX IF EXISTS idx_tags_tag")
|
|
19
|
+
db.run("DROP TABLE IF EXISTS memory_tags")
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
export const version = 3
|
|
4
|
+
export const description = "Add access tracking columns for relevance decay"
|
|
5
|
+
|
|
6
|
+
export function up(db: Database) {
|
|
7
|
+
db.run(`ALTER TABLE memory ADD COLUMN access_count INTEGER NOT NULL DEFAULT 0`)
|
|
8
|
+
db.run(`ALTER TABLE memory ADD COLUMN time_last_accessed INTEGER`)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function down(db: Database) {
|
|
12
|
+
// SQLite doesn't support DROP COLUMN before 3.35.0, but Bun ships recent SQLite
|
|
13
|
+
db.run(`ALTER TABLE memory DROP COLUMN access_count`)
|
|
14
|
+
db.run(`ALTER TABLE memory DROP COLUMN time_last_accessed`)
|
|
15
|
+
}
|