@memoryrelay/plugin-memoryrelay-ai 0.16.3 → 0.17.1

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.
@@ -0,0 +1,374 @@
1
+ import type BetterSqlite3 from "better-sqlite3";
2
+ import { statSync } from "node:fs";
3
+ import { migrateIfNeeded } from "./schema.js";
4
+ import type {
5
+ LocalCacheConfig,
6
+ LocalMemory,
7
+ BufferEntry,
8
+ SyncState,
9
+ CacheStats,
10
+ } from "./types.js";
11
+
12
+ interface MemoryRow {
13
+ id: string;
14
+ remote_id: string | null;
15
+ content: string;
16
+ agent_id: string;
17
+ user_id: string;
18
+ metadata: string;
19
+ entities: string;
20
+ importance: number;
21
+ tier: "hot" | "warm" | "cold";
22
+ scope: "session" | "long-term";
23
+ session_id: string | null;
24
+ namespace: string;
25
+ created_at: string;
26
+ updated_at: string;
27
+ synced_at: string | null;
28
+ expires_at: string | null;
29
+ embedding: Buffer | null;
30
+ }
31
+
32
+ interface BufferRow {
33
+ id: number;
34
+ content: string;
35
+ metadata: string;
36
+ scope: "session" | "long-term";
37
+ session_id: string | null;
38
+ namespace: string;
39
+ created_at: string;
40
+ flushed: number;
41
+ }
42
+
43
+ interface FtsRow extends MemoryRow {
44
+ rank: number;
45
+ }
46
+
47
+ function rowToMemory(row: MemoryRow): LocalMemory {
48
+ return {
49
+ ...row,
50
+ metadata: JSON.parse(row.metadata),
51
+ entities: JSON.parse(row.entities),
52
+ };
53
+ }
54
+
55
+ function rowToBuffer(row: BufferRow): BufferEntry {
56
+ return {
57
+ ...row,
58
+ metadata: JSON.parse(row.metadata),
59
+ scope: row.scope as "session" | "long-term",
60
+ flushed: row.flushed === 1,
61
+ };
62
+ }
63
+
64
+ export class LocalCache {
65
+ private db: BetterSqlite3.Database;
66
+ private readonly _dbPath: string;
67
+ private readonly config: LocalCacheConfig;
68
+
69
+ constructor(dbPath: string, config: LocalCacheConfig) {
70
+ let Database: typeof BetterSqlite3;
71
+ try {
72
+ Database = require("better-sqlite3");
73
+ } catch {
74
+ throw new Error(
75
+ "better-sqlite3 not available — run: cd ~/.openclaw/extensions/plugin-memoryrelay-ai && npm install --omit=dev",
76
+ );
77
+ }
78
+
79
+ this._dbPath = dbPath;
80
+ this.config = config;
81
+ this.db = this.initDb(dbPath, Database);
82
+ }
83
+
84
+ get dbPath(): string {
85
+ return this._dbPath;
86
+ }
87
+
88
+ private initDb(dbPath: string, Database: typeof BetterSqlite3): BetterSqlite3.Database {
89
+ const db = new Database(dbPath);
90
+ db.pragma("journal_mode = WAL");
91
+ db.pragma("foreign_keys = ON");
92
+ db.pragma("busy_timeout = 5000");
93
+ migrateIfNeeded(db);
94
+ return db;
95
+ }
96
+
97
+ // --- Memory CRUD ---
98
+
99
+ upsert(memory: Partial<LocalMemory> & { id: string; content: string; agent_id: string }): void {
100
+ const now = new Date().toISOString();
101
+ const stmt = this.db.prepare(`
102
+ INSERT INTO memories (id, remote_id, content, agent_id, user_id, metadata, entities,
103
+ importance, tier, scope, session_id, namespace, created_at, updated_at, synced_at, expires_at, embedding)
104
+ VALUES (@id, @remote_id, @content, @agent_id, @user_id, @metadata, @entities,
105
+ @importance, @tier, @scope, @session_id, @namespace, @created_at, @updated_at, @synced_at, @expires_at, @embedding)
106
+ ON CONFLICT(id) DO UPDATE SET
107
+ content = excluded.content,
108
+ remote_id = COALESCE(excluded.remote_id, memories.remote_id),
109
+ agent_id = excluded.agent_id,
110
+ user_id = excluded.user_id,
111
+ metadata = excluded.metadata,
112
+ entities = excluded.entities,
113
+ importance = excluded.importance,
114
+ tier = excluded.tier,
115
+ scope = excluded.scope,
116
+ session_id = excluded.session_id,
117
+ namespace = excluded.namespace,
118
+ updated_at = excluded.updated_at,
119
+ synced_at = excluded.synced_at,
120
+ expires_at = excluded.expires_at,
121
+ embedding = excluded.embedding
122
+ `);
123
+
124
+ stmt.run({
125
+ id: memory.id,
126
+ remote_id: memory.remote_id ?? null,
127
+ content: memory.content,
128
+ agent_id: memory.agent_id,
129
+ user_id: memory.user_id ?? "",
130
+ metadata: JSON.stringify(memory.metadata ?? {}),
131
+ entities: JSON.stringify(memory.entities ?? []),
132
+ importance: memory.importance ?? 0.5,
133
+ tier: memory.tier ?? "warm",
134
+ scope: memory.scope ?? "long-term",
135
+ session_id: memory.session_id ?? null,
136
+ namespace: memory.namespace ?? "default",
137
+ created_at: memory.created_at ?? now,
138
+ updated_at: memory.updated_at ?? now,
139
+ synced_at: memory.synced_at ?? null,
140
+ expires_at: memory.expires_at ?? null,
141
+ embedding: memory.embedding ?? null,
142
+ });
143
+ }
144
+
145
+ get(id: string): LocalMemory | null {
146
+ const row = this.db
147
+ .prepare("SELECT * FROM memories WHERE id = ?")
148
+ .get(id) as MemoryRow | undefined;
149
+ return row ? rowToMemory(row) : null;
150
+ }
151
+
152
+ delete(id: string): boolean {
153
+ const result = this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
154
+ return result.changes > 0;
155
+ }
156
+
157
+ count(): number {
158
+ const row = this.db.prepare("SELECT COUNT(*) as cnt FROM memories").get() as { cnt: number };
159
+ return row.cnt;
160
+ }
161
+
162
+ countByTier(): { hot: number; warm: number; cold: number } {
163
+ const rows = this.db
164
+ .prepare("SELECT tier, COUNT(*) as cnt FROM memories GROUP BY tier")
165
+ .all() as { tier: string; cnt: number }[];
166
+ const result = { hot: 0, warm: 0, cold: 0 };
167
+ for (const row of rows) {
168
+ if (row.tier === "hot" || row.tier === "warm" || row.tier === "cold") {
169
+ result[row.tier] = row.cnt;
170
+ }
171
+ }
172
+ return result;
173
+ }
174
+
175
+ // --- Search ---
176
+
177
+ search(
178
+ query: string,
179
+ opts?: { limit?: number; scope?: string; sessionId?: string; namespace?: string },
180
+ ): LocalMemory[] {
181
+ if (!query.trim()) return [];
182
+
183
+ const limit = opts?.limit ?? 20;
184
+ // Escape FTS5 special chars and wrap terms in double quotes for safe matching
185
+ const safeQuery = query
186
+ .replace(/['"]/g, " ")
187
+ .split(/\s+/)
188
+ .filter(Boolean)
189
+ .map((term) => `"${term}"`)
190
+ .join(" ");
191
+
192
+ if (!safeQuery) return [];
193
+
194
+ let sql = `
195
+ SELECT m.*, fts.rank
196
+ FROM memories_fts fts
197
+ JOIN memories m ON m.rowid = fts.rowid
198
+ WHERE memories_fts MATCH ?
199
+ `;
200
+ const params: (string | number)[] = [safeQuery];
201
+
202
+ if (opts?.scope) {
203
+ sql += " AND m.scope = ?";
204
+ params.push(opts.scope);
205
+ }
206
+ if (opts?.sessionId) {
207
+ sql += " AND m.session_id = ?";
208
+ params.push(opts.sessionId);
209
+ }
210
+ if (opts?.namespace) {
211
+ sql += " AND m.namespace = ?";
212
+ params.push(opts.namespace);
213
+ }
214
+
215
+ sql += " ORDER BY fts.rank LIMIT ?";
216
+ params.push(limit);
217
+
218
+ const rows = this.db.prepare(sql).all(...params) as FtsRow[];
219
+ return rows.map(rowToMemory);
220
+ }
221
+
222
+ searchByScope(
223
+ scope: "session" | "long-term",
224
+ sessionId?: string,
225
+ opts?: { namespace?: string; limit?: number },
226
+ ): LocalMemory[] {
227
+ let sql = "SELECT * FROM memories WHERE scope = ?";
228
+ const params: (string | number)[] = [scope];
229
+
230
+ if (sessionId) {
231
+ sql += " AND session_id = ?";
232
+ params.push(sessionId);
233
+ }
234
+ if (opts?.namespace) {
235
+ sql += " AND namespace = ?";
236
+ params.push(opts.namespace);
237
+ }
238
+
239
+ sql += " ORDER BY updated_at DESC LIMIT ?";
240
+ params.push(opts?.limit ?? 50);
241
+
242
+ const rows = this.db.prepare(sql).all(...params) as MemoryRow[];
243
+ return rows.map(rowToMemory);
244
+ }
245
+
246
+ // --- Buffer (capture pipeline) ---
247
+
248
+ bufferWrite(content: string, metadata: Record<string, unknown>): string {
249
+ const now = new Date().toISOString();
250
+ const stmt = this.db.prepare(`
251
+ INSERT INTO session_buffer (content, metadata, scope, session_id, namespace, created_at)
252
+ VALUES (?, ?, ?, ?, ?, ?)
253
+ `);
254
+ const scope = (metadata.scope as string) ?? "long-term";
255
+ const sessionId = (metadata.session_id as string) ?? null;
256
+ const namespace = (metadata.namespace as string) ?? "default";
257
+
258
+ const result = stmt.run(
259
+ content,
260
+ JSON.stringify(metadata),
261
+ scope,
262
+ sessionId,
263
+ namespace,
264
+ now,
265
+ );
266
+ return String(result.lastInsertRowid);
267
+ }
268
+
269
+ bufferReadPending(): BufferEntry[] {
270
+ const rows = this.db
271
+ .prepare("SELECT * FROM session_buffer WHERE flushed = 0 ORDER BY created_at ASC")
272
+ .all() as BufferRow[];
273
+ return rows.map(rowToBuffer);
274
+ }
275
+
276
+ bufferMarkFlushed(ids: string[]): void {
277
+ if (ids.length === 0) return;
278
+ const placeholders = ids.map(() => "?").join(",");
279
+ this.db
280
+ .prepare(`UPDATE session_buffer SET flushed = 1 WHERE id IN (${placeholders})`)
281
+ .run(...ids.map(Number));
282
+ }
283
+
284
+ bufferDepth(): number {
285
+ const row = this.db
286
+ .prepare("SELECT COUNT(*) as cnt FROM session_buffer WHERE flushed = 0")
287
+ .get() as { cnt: number };
288
+ return row.cnt;
289
+ }
290
+
291
+ // --- Sync state ---
292
+
293
+ getSyncState(): SyncState {
294
+ const rows = this.db.prepare("SELECT key, value FROM sync_state").all() as {
295
+ key: string;
296
+ value: string;
297
+ }[];
298
+ const map = new Map(rows.map((r) => [r.key, r.value]));
299
+ return {
300
+ lastPull: map.get("last_pull") ?? null,
301
+ lastPush: map.get("last_push") ?? null,
302
+ cursor: map.get("cursor") ?? null,
303
+ };
304
+ }
305
+
306
+ setSyncState(state: Partial<SyncState>): void {
307
+ const now = new Date().toISOString();
308
+ const stmt = this.db.prepare(
309
+ "INSERT OR REPLACE INTO sync_state (key, value, updated_at) VALUES (?, ?, ?)",
310
+ );
311
+ const run = this.db.transaction(() => {
312
+ if (state.lastPull !== undefined) stmt.run("last_pull", state.lastPull ?? "", now);
313
+ if (state.lastPush !== undefined) stmt.run("last_push", state.lastPush ?? "", now);
314
+ if (state.cursor !== undefined) stmt.run("cursor", state.cursor ?? "", now);
315
+ });
316
+ run();
317
+ }
318
+
319
+ // --- Maintenance ---
320
+
321
+ evictExpired(): number {
322
+ const now = new Date().toISOString();
323
+ const result = this.db
324
+ .prepare("DELETE FROM memories WHERE expires_at IS NOT NULL AND expires_at < ?")
325
+ .run(now);
326
+ return result.changes;
327
+ }
328
+
329
+ enforceCapLimit(): number {
330
+ const max = this.config.maxLocalMemories;
331
+ const total = this.count();
332
+ if (total <= max) return 0;
333
+
334
+ const excess = total - max;
335
+ const result = this.db
336
+ .prepare(
337
+ `DELETE FROM memories WHERE id IN (
338
+ SELECT id FROM memories
339
+ ORDER BY
340
+ CASE tier WHEN 'cold' THEN 0 WHEN 'warm' THEN 1 WHEN 'hot' THEN 2 END,
341
+ updated_at ASC
342
+ LIMIT ?
343
+ )`,
344
+ )
345
+ .run(excess);
346
+ return result.changes;
347
+ }
348
+
349
+ stats(): CacheStats {
350
+ const totalMemories = this.count();
351
+ const tierBreakdown = this.countByTier();
352
+ const bufferDepth = this.bufferDepth();
353
+ const syncState = this.getSyncState();
354
+ let dbSizeBytes = 0;
355
+ if (this._dbPath !== ":memory:") {
356
+ try {
357
+ dbSizeBytes = statSync(this._dbPath).size;
358
+ } catch {
359
+ // file may not exist yet
360
+ }
361
+ }
362
+ return {
363
+ totalMemories,
364
+ tierBreakdown,
365
+ bufferDepth,
366
+ lastSync: syncState.lastPull ?? syncState.lastPush ?? null,
367
+ dbSizeBytes,
368
+ };
369
+ }
370
+
371
+ close(): void {
372
+ this.db.close();
373
+ }
374
+ }
@@ -0,0 +1,100 @@
1
+ import type { LocalCache } from "./local-cache.js";
2
+ import type { SyncDaemon } from "./sync-daemon.js";
3
+ import type { CacheStats, LocalCacheConfig } from "./types.js";
4
+
5
+ /**
6
+ * MemoryProviderStatus — compatible with OpenClaw's MemorySearchManager interface.
7
+ * See: /usr/lib/node_modules/openclaw/dist/memory-search-B5CuuJZB.js
8
+ */
9
+ export interface MemoryProviderStatus {
10
+ backend: "builtin" | "qmd";
11
+ provider: string;
12
+ files?: number;
13
+ chunks?: number;
14
+ dirty?: boolean;
15
+ fts?: { enabled: boolean; available: boolean };
16
+ vector?: { enabled: boolean; available?: boolean; dims?: number };
17
+ cache?: { enabled: boolean; entries?: number; maxEntries?: number };
18
+ custom?: Record<string, unknown>;
19
+ }
20
+
21
+ /**
22
+ * PluginMemoryManager wraps LocalCache + SyncDaemon to satisfy OpenClaw's
23
+ * MemorySearchManager interface, enabling `openclaw status` to display
24
+ * real memory counts and provider info.
25
+ */
26
+ export class PluginMemoryManager {
27
+ private readonly cache: LocalCache;
28
+ private readonly syncDaemon: SyncDaemon;
29
+ private readonly vectorAvailable: boolean;
30
+ private readonly config: LocalCacheConfig;
31
+ private readonly agentId: string;
32
+
33
+ constructor(
34
+ cache: LocalCache,
35
+ syncDaemon: SyncDaemon,
36
+ config: LocalCacheConfig,
37
+ vectorAvailable: boolean,
38
+ agentId: string,
39
+ ) {
40
+ this.cache = cache;
41
+ this.syncDaemon = syncDaemon;
42
+ this.config = config;
43
+ this.vectorAvailable = vectorAvailable;
44
+ this.agentId = agentId;
45
+ }
46
+
47
+ status(): MemoryProviderStatus {
48
+ const stats = this.cache.stats();
49
+ return {
50
+ backend: "builtin",
51
+ provider: "memoryrelay",
52
+ files: stats.totalMemories,
53
+ chunks: stats.totalMemories,
54
+ dirty: stats.bufferDepth > 0,
55
+ fts: { enabled: true, available: true },
56
+ vector: {
57
+ enabled: this.vectorAvailable,
58
+ available: this.vectorAvailable,
59
+ dims: 768,
60
+ },
61
+ cache: {
62
+ enabled: true,
63
+ entries: stats.totalMemories,
64
+ maxEntries: this.config.maxLocalMemories,
65
+ },
66
+ custom: {
67
+ provider: "memoryrelay-api",
68
+ agentId: this.agentId,
69
+ bufferDepth: stats.bufferDepth,
70
+ tierBreakdown: stats.tierBreakdown,
71
+ lastSync: stats.lastSync,
72
+ syncActive: this.syncDaemon.isRunning(),
73
+ consecutiveErrors: this.syncDaemon.getConsecutiveErrors(),
74
+ syncIntervalMinutes: this.config.syncIntervalMinutes,
75
+ },
76
+ };
77
+ }
78
+
79
+ cacheStats(): CacheStats {
80
+ return this.cache.stats();
81
+ }
82
+
83
+ getSyncDaemonInfo(): { running: boolean; errors: number; intervalMinutes: number; lastError: string | null } {
84
+ return {
85
+ running: this.syncDaemon.isRunning(),
86
+ errors: this.syncDaemon.getConsecutiveErrors(),
87
+ intervalMinutes: this.config.syncIntervalMinutes,
88
+ lastError: this.syncDaemon.lastError(),
89
+ };
90
+ }
91
+
92
+ async probeVectorAvailability(): Promise<boolean> {
93
+ return this.vectorAvailable;
94
+ }
95
+
96
+ async close(): Promise<void> {
97
+ this.syncDaemon.stop();
98
+ this.cache.close();
99
+ }
100
+ }
@@ -0,0 +1,130 @@
1
+ import type Database from "better-sqlite3";
2
+
3
+ export const SCHEMA_VERSION = 1;
4
+
5
+ export const CREATE_MEMORIES_TABLE = `CREATE TABLE IF NOT EXISTS memories (
6
+ id TEXT PRIMARY KEY,
7
+ remote_id TEXT UNIQUE,
8
+ content TEXT NOT NULL,
9
+ agent_id TEXT NOT NULL,
10
+ user_id TEXT DEFAULT '',
11
+ metadata TEXT DEFAULT '{}',
12
+ entities TEXT DEFAULT '[]',
13
+ importance REAL DEFAULT 0.5,
14
+ tier TEXT DEFAULT 'warm'
15
+ CHECK(tier IN ('hot', 'warm', 'cold')),
16
+ scope TEXT DEFAULT 'long-term'
17
+ CHECK(scope IN ('session', 'long-term')),
18
+ session_id TEXT,
19
+ namespace TEXT DEFAULT 'default',
20
+ created_at TEXT NOT NULL,
21
+ updated_at TEXT NOT NULL,
22
+ synced_at TEXT,
23
+ expires_at TEXT,
24
+ embedding BLOB
25
+ )`;
26
+
27
+ export const CREATE_MEMORIES_FTS = `CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
28
+ content,
29
+ metadata,
30
+ content=memories,
31
+ content_rowid=rowid
32
+ )`;
33
+
34
+ export const CREATE_FTS_TRIGGER_INSERT = `CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories
35
+ BEGIN INSERT INTO memories_fts(rowid, content, metadata) VALUES (new.rowid, new.content, new.metadata); END`;
36
+
37
+ export const CREATE_FTS_TRIGGER_DELETE = `CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories
38
+ BEGIN INSERT INTO memories_fts(memories_fts, rowid, content, metadata) VALUES ('delete', old.rowid, old.content, old.metadata); END`;
39
+
40
+ export const CREATE_FTS_TRIGGER_UPDATE = `CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories
41
+ BEGIN INSERT INTO memories_fts(memories_fts, rowid, content, metadata) VALUES ('delete', old.rowid, old.content, old.metadata);
42
+ INSERT INTO memories_fts(rowid, content, metadata) VALUES (new.rowid, new.content, new.metadata); END`;
43
+
44
+ export const CREATE_BUFFER_TABLE = `CREATE TABLE IF NOT EXISTS session_buffer (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ content TEXT NOT NULL,
47
+ metadata TEXT DEFAULT '{}',
48
+ scope TEXT DEFAULT 'long-term',
49
+ session_id TEXT,
50
+ namespace TEXT DEFAULT 'default',
51
+ created_at TEXT NOT NULL,
52
+ flushed INTEGER DEFAULT 0
53
+ )`;
54
+
55
+ export const CREATE_SYNC_STATE_TABLE = `CREATE TABLE IF NOT EXISTS sync_state (
56
+ key TEXT PRIMARY KEY,
57
+ value TEXT NOT NULL,
58
+ updated_at TEXT NOT NULL
59
+ )`;
60
+
61
+ export const CREATE_CACHE_META_TABLE = `CREATE TABLE IF NOT EXISTS cache_meta (
62
+ key TEXT PRIMARY KEY,
63
+ value TEXT NOT NULL
64
+ )`;
65
+
66
+ export const CREATE_INDEXES = [
67
+ "CREATE INDEX IF NOT EXISTS idx_memories_agent ON memories(agent_id)",
68
+ "CREATE INDEX IF NOT EXISTS idx_memories_tier ON memories(tier)",
69
+ "CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope)",
70
+ "CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id)",
71
+ "CREATE INDEX IF NOT EXISTS idx_memories_namespace ON memories(namespace)",
72
+ "CREATE INDEX IF NOT EXISTS idx_memories_expires ON memories(expires_at)",
73
+ "CREATE INDEX IF NOT EXISTS idx_memories_synced ON memories(synced_at)",
74
+ "CREATE INDEX IF NOT EXISTS idx_memories_updated ON memories(updated_at DESC)",
75
+ "CREATE INDEX IF NOT EXISTS idx_buffer_flushed ON session_buffer(flushed, created_at)",
76
+ ];
77
+
78
+ function runDDL(db: Database.Database, sql: string): void {
79
+ // For DDL statements (CREATE TABLE, CREATE INDEX, CREATE TRIGGER, CREATE VIRTUAL TABLE)
80
+ // we need to use the exec method on the database instance
81
+ const fn = (db as unknown as { exec: (sql: string) => void }).exec.bind(db);
82
+ fn(sql);
83
+ }
84
+
85
+ export function createSchema(db: Database.Database): void {
86
+ runDDL(db, CREATE_MEMORIES_TABLE);
87
+ runDDL(db, CREATE_MEMORIES_FTS);
88
+ runDDL(db, CREATE_FTS_TRIGGER_INSERT);
89
+ runDDL(db, CREATE_FTS_TRIGGER_DELETE);
90
+ runDDL(db, CREATE_FTS_TRIGGER_UPDATE);
91
+ runDDL(db, CREATE_BUFFER_TABLE);
92
+ runDDL(db, CREATE_SYNC_STATE_TABLE);
93
+ runDDL(db, CREATE_CACHE_META_TABLE);
94
+ for (const idx of CREATE_INDEXES) {
95
+ runDDL(db, idx);
96
+ }
97
+ db.prepare("INSERT OR REPLACE INTO cache_meta (key, value) VALUES ('schema_version', ?)").run(
98
+ String(SCHEMA_VERSION),
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Create the vec0 virtual table for vector search.
104
+ * Only call after sqlite-vec extension has been loaded.
105
+ * Separated from createSchema() because it depends on an optional extension.
106
+ */
107
+ export function createVecSchema(db: Database.Database): void {
108
+ runDDL(
109
+ db,
110
+ "CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(memory_id TEXT PRIMARY KEY, embedding float[768])",
111
+ );
112
+ }
113
+
114
+ export function getSchemaVersion(db: Database.Database): number {
115
+ const row = db.prepare("SELECT value FROM cache_meta WHERE key = 'schema_version'").get() as
116
+ | { value: string }
117
+ | undefined;
118
+ return row ? parseInt(row.value, 10) : 0;
119
+ }
120
+
121
+ export function migrateIfNeeded(db: Database.Database): void {
122
+ // Ensure cache_meta exists so we can read the version
123
+ runDDL(db, CREATE_CACHE_META_TABLE);
124
+ const version = getSchemaVersion(db);
125
+
126
+ if (version < 1) {
127
+ createSchema(db);
128
+ }
129
+ // Future: if (version < 2) { runMigrationV2(db); }
130
+ }