@jungjaehoon/mama-core 1.0.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,364 @@
1
+ /**
2
+ * SQLite Database Adapter
3
+ *
4
+ * Implements DatabaseAdapter interface using better-sqlite3 + sqlite-vec
5
+ * This is the current production implementation extracted from memory-store.js
6
+ *
7
+ * @module sqlite-adapter
8
+ */
9
+
10
+ const { DatabaseAdapter } = require('./base-adapter');
11
+ const { SQLiteStatement } = require('./statement');
12
+ const { info, warn, error: logError } = require('../debug-logger');
13
+ const { cosineSimilarity } = require('../embeddings');
14
+ const Database = require('better-sqlite3');
15
+ const path = require('path');
16
+ const os = require('os');
17
+ const fs = require('fs');
18
+ let sqliteVec = null;
19
+
20
+ try {
21
+ sqliteVec = require('sqlite-vec');
22
+ } catch (err) {
23
+ // Defer logging until connect() so we have logger context initialized
24
+ sqliteVec = null;
25
+ }
26
+
27
+ // Database paths
28
+ const LEGACY_DB_PATH = path.join(os.homedir(), '.spinelift', 'memories.db');
29
+ // Default to ~/.claude/mama-memory.db for Claude Code/Desktop compatibility
30
+ const DEFAULT_DB_PATH = path.join(os.homedir(), '.claude', 'mama-memory.db');
31
+
32
+ class SQLiteAdapter extends DatabaseAdapter {
33
+ constructor(config = {}) {
34
+ super();
35
+ this.config = config;
36
+ this.db = null;
37
+ this.lastRowid = null;
38
+ this.vectorSearchEnabled = false;
39
+ }
40
+
41
+ /**
42
+ * Get database path with backward compatibility
43
+ */
44
+ getDbPath() {
45
+ // Support both MAMA_DB_PATH and MAMA_DATABASE_PATH for backward compatibility
46
+ const envPath = process.env.MAMA_DB_PATH || process.env.MAMA_DATABASE_PATH;
47
+ const configPath = this.config.dbPath;
48
+
49
+ // Expand ${HOME} or ~ in environment variable
50
+ let expandedEnvPath = envPath;
51
+ if (envPath) {
52
+ expandedEnvPath = envPath.replace(/\$\{HOME\}/g, os.homedir()).replace(/^~/, os.homedir());
53
+ }
54
+
55
+ // Priority: config > env > default
56
+ const targetPath = configPath || expandedEnvPath || DEFAULT_DB_PATH;
57
+
58
+ // Backward compatibility: Check legacy path if not explicitly set
59
+ if (!configPath && !envPath && fs.existsSync(LEGACY_DB_PATH)) {
60
+ info(
61
+ '[sqlite-adapter] Found legacy database at ~/.spinelift/memories.db, using it for backward compatibility'
62
+ );
63
+ return LEGACY_DB_PATH;
64
+ }
65
+
66
+ return targetPath;
67
+ }
68
+
69
+ /**
70
+ * Connect to SQLite database
71
+ */
72
+ connect() {
73
+ if (this.db) {
74
+ return this.db;
75
+ }
76
+
77
+ const dbPath = this.getDbPath();
78
+ const dbDir = path.dirname(dbPath);
79
+
80
+ // Ensure directory exists
81
+ if (!fs.existsSync(dbDir)) {
82
+ fs.mkdirSync(dbDir, { recursive: true });
83
+ info(`[sqlite-adapter] Created database directory: ${dbDir}`);
84
+ }
85
+
86
+ // Open database
87
+ this.db = new Database(dbPath, { verbose: null });
88
+ info(`[sqlite-adapter] Opened database at: ${dbPath}`);
89
+
90
+ // Production configuration
91
+ this.db.pragma('journal_mode = WAL');
92
+ this.db.pragma('busy_timeout = 5000');
93
+ this.db.pragma('synchronous = NORMAL');
94
+ this.db.pragma('cache_size = -64000'); // 64MB cache
95
+ this.db.pragma('temp_store = MEMORY');
96
+ this.db.pragma('foreign_keys = ON');
97
+
98
+ // Load sqlite-vec extension (graceful degradation if unavailable)
99
+ if (sqliteVec) {
100
+ try {
101
+ sqliteVec.load(this.db);
102
+ this.vectorSearchEnabled = true;
103
+ info('[sqlite-adapter] Loaded sqlite-vec extension');
104
+ } catch (err) {
105
+ this.vectorSearchEnabled = false;
106
+ warn(`[sqlite-adapter] sqlite-vec unavailable (Tier 2 fallback): ${err.message}`);
107
+ }
108
+ } else {
109
+ this.vectorSearchEnabled = false;
110
+ warn('[sqlite-adapter] sqlite-vec package not installed; vector search disabled');
111
+ }
112
+
113
+ return this.db;
114
+ }
115
+
116
+ /**
117
+ * Disconnect from database
118
+ */
119
+ disconnect() {
120
+ if (this.db) {
121
+ this.db.close();
122
+ this.db = null;
123
+ info('[sqlite-adapter] Disconnected from database');
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Check if connected
129
+ */
130
+ isConnected() {
131
+ return this.db !== null && this.db.open;
132
+ }
133
+
134
+ /**
135
+ * Prepare a SQL statement
136
+ */
137
+ prepare(sql) {
138
+ if (!this.isConnected()) {
139
+ throw new Error('Database not connected');
140
+ }
141
+ const stmt = this.db.prepare(sql);
142
+ return new SQLiteStatement(stmt);
143
+ }
144
+
145
+ /**
146
+ * Execute raw SQL
147
+ */
148
+ exec(sql) {
149
+ if (!this.isConnected()) {
150
+ throw new Error('Database not connected');
151
+ }
152
+ return this.db.exec(sql);
153
+ }
154
+
155
+ /**
156
+ * Execute function in transaction
157
+ */
158
+ transaction(fn) {
159
+ if (!this.isConnected()) {
160
+ throw new Error('Database not connected');
161
+ }
162
+ const txn = this.db.transaction(fn);
163
+ return txn();
164
+ }
165
+
166
+ /**
167
+ * Vector similarity search using sqlite-vec (vec0 virtual table)
168
+ */
169
+ vectorSearch(embedding, limit = 5) {
170
+ if (!this.isConnected()) {
171
+ throw new Error('Database not connected');
172
+ }
173
+ if (!this.vectorSearchEnabled) {
174
+ return null;
175
+ }
176
+
177
+ const embeddingJson = JSON.stringify(Array.from(embedding));
178
+ const stmt = this.prepare(`
179
+ SELECT
180
+ rowid,
181
+ embedding,
182
+ distance
183
+ FROM vss_memories
184
+ WHERE embedding MATCH vec_f32(?)
185
+ LIMIT ?
186
+ `);
187
+
188
+ const queryVector =
189
+ embedding instanceof Float32Array ? embedding : Float32Array.from(embedding);
190
+ const results = stmt.all(embeddingJson, Math.max(limit, 1));
191
+
192
+ return results
193
+ .map((row) => {
194
+ const candidate = bufferToVector(row.embedding);
195
+ if (!candidate) {
196
+ return null;
197
+ }
198
+ const similarity = cosineSimilarity(candidate, queryVector);
199
+ return {
200
+ rowid: row.rowid,
201
+ similarity,
202
+ distance: 1 - similarity,
203
+ };
204
+ })
205
+ .filter(Boolean);
206
+ }
207
+
208
+ /**
209
+ * Insert vector embedding
210
+ */
211
+ insertEmbedding(rowid, embedding) {
212
+ if (!this.isConnected()) {
213
+ throw new Error('Database not connected');
214
+ }
215
+ if (!this.vectorSearchEnabled) {
216
+ return null;
217
+ }
218
+
219
+ const embeddingJson = JSON.stringify(Array.from(embedding));
220
+
221
+ // CRITICAL FIX: sqlite-vec virtual tables accept rowid as literal but not via ? placeholder
222
+ // Using template literal with Number() cast for safety (prevents SQL injection)
223
+ const safeRowid = Number(rowid);
224
+ if (!Number.isInteger(safeRowid) || safeRowid < 1) {
225
+ throw new Error(`Invalid rowid: ${rowid}`);
226
+ }
227
+
228
+ const stmt = this.prepare(`
229
+ INSERT OR REPLACE INTO vss_memories(rowid, embedding)
230
+ VALUES (${safeRowid}, ?)
231
+ `);
232
+
233
+ return stmt.run(embeddingJson);
234
+ }
235
+
236
+ /**
237
+ * Get last inserted row ID
238
+ */
239
+ getLastInsertRowid() {
240
+ if (!this.isConnected()) {
241
+ throw new Error('Database not connected');
242
+ }
243
+ // better-sqlite3 provides this via Database instance
244
+ return this.db.prepare('SELECT last_insert_rowid() as rowid').get().rowid;
245
+ }
246
+
247
+ /**
248
+ * Run migrations
249
+ */
250
+ runMigrations(migrationsDir) {
251
+ if (!this.isConnected()) {
252
+ throw new Error('Database not connected');
253
+ }
254
+
255
+ // Check if schema_version table exists
256
+ const tables = this.prepare(
257
+ `
258
+ SELECT name FROM sqlite_master
259
+ WHERE type='table' AND name='schema_version'
260
+ `
261
+ ).all();
262
+
263
+ let currentVersion = 0;
264
+ if (tables.length > 0) {
265
+ const version = this.prepare('SELECT MAX(version) as version FROM schema_version').get();
266
+ currentVersion = version?.version || 0;
267
+ }
268
+
269
+ info(`[sqlite-adapter] Current schema version: ${currentVersion}`);
270
+
271
+ // Get all migration files
272
+ const migrationFiles = fs
273
+ .readdirSync(migrationsDir)
274
+ .filter((file) => file.endsWith('.sql'))
275
+ .sort();
276
+
277
+ // Apply migrations
278
+ for (const file of migrationFiles) {
279
+ const versionMatch = file.match(/^(\d+)-/);
280
+ if (!versionMatch) {
281
+ continue;
282
+ }
283
+
284
+ const version = parseInt(versionMatch[1], 10);
285
+ if (version <= currentVersion) {
286
+ continue;
287
+ }
288
+
289
+ const migrationPath = path.join(migrationsDir, file);
290
+ const migrationSQL = fs.readFileSync(migrationPath, 'utf8');
291
+
292
+ info(`[sqlite-adapter] Applying migration: ${file}`);
293
+
294
+ try {
295
+ this.exec('BEGIN TRANSACTION');
296
+ this.exec(migrationSQL);
297
+ this.exec('COMMIT');
298
+
299
+ // Record migration in schema_version table (outside transaction for idempotency)
300
+ this.prepare('INSERT OR IGNORE INTO schema_version (version) VALUES (?)').run(version);
301
+ info(`[sqlite-adapter] Migration ${file} applied successfully`);
302
+ } catch (err) {
303
+ this.exec('ROLLBACK');
304
+
305
+ // Handle duplicate column errors as idempotent (migration 003)
306
+ if (err.message && err.message.includes('duplicate column')) {
307
+ warn(`[sqlite-adapter] Migration ${file} skipped (duplicate column - already applied)`);
308
+ // Record migration as applied
309
+ this.prepare('INSERT OR IGNORE INTO schema_version (version) VALUES (?)').run(version);
310
+ continue;
311
+ }
312
+
313
+ if (err.message && err.message.includes('no such table')) {
314
+ // Only skip if this migration contains an ALTER TABLE (adding column to optional table)
315
+ // Note: migrationSQL may contain comments, so check if ALTER TABLE exists anywhere
316
+ const hasAlterTable = migrationSQL.toUpperCase().includes('ALTER TABLE');
317
+ if (!hasAlterTable) {
318
+ logError(`[sqlite-adapter] Migration ${file} failed (missing required table):`, err);
319
+ throw new Error(`Migration ${file} failed: ${err.message}`);
320
+ }
321
+ warn(
322
+ `[sqlite-adapter] Migration ${file} skipped: ALTER TABLE on non-existent table (${err.message})`
323
+ );
324
+ this.prepare('INSERT OR IGNORE INTO schema_version (version) VALUES (?)').run(version);
325
+ continue;
326
+ }
327
+
328
+ logError(`[sqlite-adapter] Migration ${file} failed:`, err);
329
+ throw new Error(`Migration ${file} failed: ${err.message}`);
330
+ }
331
+ }
332
+
333
+ // Create vss_memories table if not exists
334
+ if (this.vectorSearchEnabled) {
335
+ const vssTables = this.prepare(
336
+ `
337
+ SELECT name FROM sqlite_master
338
+ WHERE type='table' AND name='vss_memories'
339
+ `
340
+ ).all();
341
+
342
+ if (vssTables.length === 0) {
343
+ info('[sqlite-adapter] Creating vss_memories virtual table via sqlite-vec');
344
+ this.exec(`
345
+ CREATE VIRTUAL TABLE vss_memories USING vec0(
346
+ embedding float[384]
347
+ )
348
+ `);
349
+ }
350
+ } else {
351
+ warn('[sqlite-adapter] Skipping vss_memories creation (sqlite-vec unavailable)');
352
+ }
353
+ }
354
+ }
355
+
356
+ module.exports = SQLiteAdapter;
357
+
358
+ function bufferToVector(buffer) {
359
+ if (!buffer) {
360
+ return null;
361
+ }
362
+ const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
363
+ return new Float32Array(arrayBuffer);
364
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Unified Statement Interface
3
+ *
4
+ * Wraps database-specific prepared statements to provide consistent API
5
+ * Compatible with better-sqlite3 and pg
6
+ *
7
+ * @module statement
8
+ */
9
+
10
+ /**
11
+ * Base statement interface
12
+ * All statement wrappers must implement these methods
13
+ */
14
+ class Statement {
15
+ /**
16
+ * Execute statement and return all rows
17
+ * @param {...*} _params - Query parameters
18
+ * @returns {Array<Object>} All matching rows
19
+ */
20
+ all(..._params) {
21
+ throw new Error('all() must be implemented by subclass');
22
+ }
23
+
24
+ /**
25
+ * Execute statement and return first row
26
+ * @param {...*} _params - Query parameters
27
+ * @returns {Object|undefined} First matching row or undefined
28
+ */
29
+ get(..._params) {
30
+ throw new Error('get() must be implemented by subclass');
31
+ }
32
+
33
+ /**
34
+ * Execute statement without returning rows
35
+ * @param {...*} _params - Query parameters
36
+ * @returns {Object} Execution info (changes, lastInsertRowid)
37
+ */
38
+ run(..._params) {
39
+ throw new Error('run() must be implemented by subclass');
40
+ }
41
+
42
+ /**
43
+ * Release statement resources
44
+ */
45
+ finalize() {
46
+ // Optional: Some drivers don't require cleanup
47
+ }
48
+ }
49
+
50
+ /**
51
+ * SQLite statement wrapper (better-sqlite3)
52
+ */
53
+ class SQLiteStatement extends Statement {
54
+ constructor(stmt) {
55
+ super();
56
+ this.stmt = stmt;
57
+ }
58
+
59
+ all(...params) {
60
+ return this.stmt.all(...params);
61
+ }
62
+
63
+ get(...params) {
64
+ return this.stmt.get(...params);
65
+ }
66
+
67
+ run(...params) {
68
+ return this.stmt.run(...params);
69
+ }
70
+
71
+ finalize() {
72
+ // better-sqlite3 statements don't need explicit cleanup
73
+ }
74
+ }
75
+
76
+ /**
77
+ * PostgreSQL statement wrapper (pg)
78
+ *
79
+ * Maps pg's async query interface to synchronous-like API
80
+ * Note: This requires careful handling in the adapter
81
+ */
82
+ class PostgreSQLStatement extends Statement {
83
+ constructor(client, sql, paramMapping) {
84
+ super();
85
+ this.client = client;
86
+ this.sql = sql;
87
+ this.paramMapping = paramMapping; // Map ? placeholders to $1, $2, etc.
88
+ }
89
+
90
+ /**
91
+ * Convert SQLite ? placeholders to PostgreSQL $1, $2, ...
92
+ * @param {string} sql - SQL with ? placeholders
93
+ * @returns {string} SQL with $N placeholders
94
+ */
95
+ static convertPlaceholders(sql) {
96
+ let index = 0;
97
+ return sql.replace(/\?/g, () => `$${++index}`);
98
+ }
99
+
100
+ async all(...params) {
101
+ const result = await this.client.query(this.sql, params);
102
+ return result.rows;
103
+ }
104
+
105
+ async get(...params) {
106
+ const result = await this.client.query(this.sql, params);
107
+ return result.rows[0];
108
+ }
109
+
110
+ async run(...params) {
111
+ const result = await this.client.query(this.sql, params);
112
+ return {
113
+ changes: result.rowCount,
114
+ lastInsertRowid: result.rows[0]?.id || null, // PostgreSQL doesn't have rowid
115
+ };
116
+ }
117
+
118
+ finalize() {
119
+ // pg statements don't need explicit cleanup
120
+ }
121
+ }
122
+
123
+ module.exports = {
124
+ Statement,
125
+ SQLiteStatement,
126
+ PostgreSQLStatement,
127
+ };