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