@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.
- package/README.md +171 -0
- package/package.json +68 -0
- package/src/config-loader.js +218 -0
- package/src/db-adapter/README.md +110 -0
- package/src/db-adapter/base-adapter.js +91 -0
- package/src/db-adapter/index.js +31 -0
- package/src/db-adapter/sqlite-adapter.js +364 -0
- package/src/db-adapter/statement.js +127 -0
- package/src/db-manager.js +671 -0
- package/src/debug-logger.js +86 -0
- package/src/decision-formatter.js +1276 -0
- package/src/decision-tracker.js +621 -0
- package/src/embedding-cache.js +222 -0
- package/src/embedding-client.js +141 -0
- package/src/embedding-server/index.js +424 -0
- package/src/embedding-server/mobile/auth.js +160 -0
- package/src/embedding-server/mobile/daemon.js +313 -0
- package/src/embedding-server/mobile/output-parser.js +281 -0
- package/src/embedding-server/mobile/session-api.js +279 -0
- package/src/embedding-server/mobile/session-manager.js +377 -0
- package/src/embedding-server/mobile/websocket-handler.js +389 -0
- package/src/embeddings.js +305 -0
- package/src/errors.js +326 -0
- package/src/index.js +41 -0
- package/src/mama-api.js +2614 -0
- package/src/memory-inject.js +174 -0
- package/src/memory-store.js +89 -0
- package/src/notification-manager.js +3 -0
- package/src/ollama-client.js +391 -0
- package/src/outcome-tracker.js +351 -0
- package/src/progress-indicator.js +110 -0
- package/src/query-intent.js +237 -0
- package/src/relevance-scorer.js +286 -0
- package/src/tier-validator.js +269 -0
- package/src/time-formatter.js +98 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MAMA Database Manager (SQLite-only)
|
|
3
|
+
*
|
|
4
|
+
* SQLite-exclusive database interface for MAMA Plugin.
|
|
5
|
+
* Uses better-sqlite3 + sqlite-vec for local storage.
|
|
6
|
+
*
|
|
7
|
+
* PostgreSQL support is only available in the legacy mcp-server repository.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - WAL mode for better concurrency
|
|
11
|
+
* - synchronous=NORMAL for performance
|
|
12
|
+
* - Automatic migration management
|
|
13
|
+
* - Vector similarity search (when sqlite-vec available)
|
|
14
|
+
*
|
|
15
|
+
* @module db-manager
|
|
16
|
+
* @version 2.1 (Plugin - SQLite-only)
|
|
17
|
+
* @date 2026-02-01
|
|
18
|
+
* @source-of-truth packages/mama-core/src/db-manager.js (mama-core)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { info, warn, error: logError } = require('./debug-logger');
|
|
22
|
+
const { logProgress: _logProgress, logComplete, logSearching } = require('./progress-indicator');
|
|
23
|
+
const { createAdapter } = require('./db-adapter');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
// Database adapter instance (singleton)
|
|
27
|
+
let dbAdapter = null;
|
|
28
|
+
let dbConnection = null;
|
|
29
|
+
let isInitialized = false;
|
|
30
|
+
let initializingPromise = null; // Single-flight guard for concurrent callers
|
|
31
|
+
|
|
32
|
+
// Migration directory (moved to src/db/migrations for M1.2)
|
|
33
|
+
const MIGRATIONS_DIR = path.join(__dirname, '..', 'db', 'migrations');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Initialize SQLite database adapter and connect
|
|
37
|
+
*
|
|
38
|
+
* Lazy initialization: Only connects when first accessed
|
|
39
|
+
* Creates database file at ~/.claude/mama-memory.db by default
|
|
40
|
+
*
|
|
41
|
+
* Single-flight guard: Concurrent callers await the same promise
|
|
42
|
+
* to prevent multiple adapters/migrations running simultaneously.
|
|
43
|
+
*
|
|
44
|
+
* @returns {Promise<Object>} SQLite database connection
|
|
45
|
+
*/
|
|
46
|
+
async function initDB() {
|
|
47
|
+
// Already initialized - return immediately
|
|
48
|
+
if (isInitialized) {
|
|
49
|
+
return dbConnection;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Single-flight guard: If initialization is in progress, wait for it
|
|
53
|
+
if (initializingPromise) {
|
|
54
|
+
return initializingPromise;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Start initialization and store promise for concurrent callers
|
|
58
|
+
initializingPromise = (async () => {
|
|
59
|
+
try {
|
|
60
|
+
logSearching('Initializing database...');
|
|
61
|
+
|
|
62
|
+
// Create SQLite adapter
|
|
63
|
+
dbAdapter = createAdapter();
|
|
64
|
+
|
|
65
|
+
// Connect to database
|
|
66
|
+
dbConnection = await dbAdapter.connect();
|
|
67
|
+
|
|
68
|
+
// Run migrations (includes 012-create-checkpoints-table.sql)
|
|
69
|
+
await dbAdapter.runMigrations(MIGRATIONS_DIR);
|
|
70
|
+
|
|
71
|
+
isInitialized = true;
|
|
72
|
+
|
|
73
|
+
info(`[db-manager] Database initialized (${dbAdapter.constructor.name})`);
|
|
74
|
+
logComplete('Database ready');
|
|
75
|
+
|
|
76
|
+
return dbConnection;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// Clear state on failure so retry is possible
|
|
79
|
+
initializingPromise = null;
|
|
80
|
+
dbAdapter = null;
|
|
81
|
+
dbConnection = null;
|
|
82
|
+
isInitialized = false;
|
|
83
|
+
throw new Error(`Failed to initialize database: ${error.message}`);
|
|
84
|
+
}
|
|
85
|
+
})();
|
|
86
|
+
|
|
87
|
+
return initializingPromise;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get database connection (singleton pattern)
|
|
92
|
+
*
|
|
93
|
+
* Returns better-sqlite3 Database instance
|
|
94
|
+
*
|
|
95
|
+
* Note: Synchronous for backward compatibility with memory-store.js
|
|
96
|
+
* Will throw if database not initialized
|
|
97
|
+
*
|
|
98
|
+
* @returns {Object} SQLite database connection
|
|
99
|
+
*/
|
|
100
|
+
function getDB() {
|
|
101
|
+
if (!dbConnection) {
|
|
102
|
+
throw new Error('Database not initialized. Call await initDB() first.');
|
|
103
|
+
}
|
|
104
|
+
return dbConnection;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get database adapter instance
|
|
109
|
+
*
|
|
110
|
+
* Used for advanced operations (vectorSearch, insertEmbedding, etc.)
|
|
111
|
+
*
|
|
112
|
+
* @returns {DatabaseAdapter} Adapter instance
|
|
113
|
+
*/
|
|
114
|
+
function getAdapter() {
|
|
115
|
+
if (!dbAdapter) {
|
|
116
|
+
throw new Error('Database adapter not initialized. Call await initDB() first.');
|
|
117
|
+
}
|
|
118
|
+
return dbAdapter;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Close database connection
|
|
123
|
+
*
|
|
124
|
+
* Call this on process exit
|
|
125
|
+
*/
|
|
126
|
+
async function closeDB() {
|
|
127
|
+
if (dbAdapter) {
|
|
128
|
+
await dbAdapter.disconnect();
|
|
129
|
+
dbAdapter = null;
|
|
130
|
+
dbConnection = null;
|
|
131
|
+
isInitialized = false;
|
|
132
|
+
initializingPromise = null; // Clear to allow re-initialization
|
|
133
|
+
info('[db-manager] Database connection closed');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Insert embedding into vector search table
|
|
139
|
+
*
|
|
140
|
+
* Uses sqlite-vec for vector similarity search
|
|
141
|
+
* Gracefully degrades if sqlite-vec is not available
|
|
142
|
+
*
|
|
143
|
+
* @param {number} decisionRowid - SQLite rowid
|
|
144
|
+
* @param {Float32Array|Array<number>} embedding - 384-dim embedding vector
|
|
145
|
+
* @returns {Promise<void>}
|
|
146
|
+
*/
|
|
147
|
+
async function insertEmbedding(decisionRowid, embedding) {
|
|
148
|
+
const adapter = getAdapter();
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await adapter.insertEmbedding(decisionRowid, embedding);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// Graceful degradation: Log warning but don't fail
|
|
154
|
+
logError(
|
|
155
|
+
`[db-manager] Failed to insert embedding (vector search unavailable): ${error.message}`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Perform vector similarity search
|
|
162
|
+
*
|
|
163
|
+
* Returns empty array if vector search not available (no keyword fallback)
|
|
164
|
+
*
|
|
165
|
+
* @param {Float32Array|Array<number>} queryEmbedding - Query embedding (384-dim)
|
|
166
|
+
* @param {number} limit - Max results to return (default: 5)
|
|
167
|
+
* @param {number} threshold - Minimum similarity threshold (default: 0.7)
|
|
168
|
+
* @returns {Promise<Array<Object>>} Array of decisions with similarity scores, or empty array
|
|
169
|
+
*/
|
|
170
|
+
async function vectorSearch(queryEmbedding, limit = 5, threshold = 0.7) {
|
|
171
|
+
const adapter = getAdapter();
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
// SQLite adapter returns null if sqlite-vec not available
|
|
175
|
+
const results = await adapter.vectorSearch(queryEmbedding, limit * 3);
|
|
176
|
+
|
|
177
|
+
if (!results || results.length === 0) {
|
|
178
|
+
return []; // No keyword fallback - fast fail
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const stmt = adapter.prepare(`SELECT * FROM decisions WHERE rowid = ?`);
|
|
182
|
+
const decisions = [];
|
|
183
|
+
|
|
184
|
+
for (const row of results) {
|
|
185
|
+
const decision = stmt.get(row.rowid);
|
|
186
|
+
|
|
187
|
+
if (!decision) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const similarity = row.similarity ?? Math.max(0, 1.0 - (row.distance ?? 1));
|
|
192
|
+
const distance = row.distance ?? Math.max(0, 1.0 - similarity);
|
|
193
|
+
|
|
194
|
+
if (similarity >= threshold) {
|
|
195
|
+
decisions.push({
|
|
196
|
+
...decision,
|
|
197
|
+
distance,
|
|
198
|
+
similarity,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (decisions.length >= limit) {
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return decisions;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
logError(`[db-manager] Vector search failed: ${error.message}`);
|
|
210
|
+
return []; // No keyword fallback - fast fail
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Insert decision with embedding
|
|
216
|
+
*
|
|
217
|
+
* Combined operation: Insert decision + Generate embedding + Insert embedding
|
|
218
|
+
* SQLite-only implementation
|
|
219
|
+
*
|
|
220
|
+
* @param {Object} decision - Decision object
|
|
221
|
+
* @returns {Promise<string>} Decision ID
|
|
222
|
+
*/
|
|
223
|
+
async function insertDecisionWithEmbedding(decision) {
|
|
224
|
+
const adapter = getAdapter();
|
|
225
|
+
const { generateEnhancedEmbedding } = require('./embeddings');
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
// Generate embedding BEFORE transaction (required for SQLite's sync transaction)
|
|
229
|
+
// Note: Redact topic for privacy - only log length
|
|
230
|
+
info(
|
|
231
|
+
`[db-manager] Generating embedding for decision (topic length: ${decision.topic?.length || 0})`
|
|
232
|
+
);
|
|
233
|
+
const embedding = await generateEnhancedEmbedding(decision);
|
|
234
|
+
info(`[db-manager] Embedding generated: ${embedding ? embedding.length : 'null'} dimensions`);
|
|
235
|
+
|
|
236
|
+
// SQLite: Synchronous transaction including embedding
|
|
237
|
+
// eslint-disable-next-line no-unused-vars
|
|
238
|
+
const decisionRowid = adapter.transaction(() => {
|
|
239
|
+
// Prepare INSERT statement
|
|
240
|
+
const stmt = adapter.prepare(`
|
|
241
|
+
INSERT INTO decisions (
|
|
242
|
+
id, topic, decision, reasoning,
|
|
243
|
+
outcome, failure_reason, limitation,
|
|
244
|
+
user_involvement, session_id,
|
|
245
|
+
supersedes, superseded_by, refined_from,
|
|
246
|
+
confidence, created_at, updated_at,
|
|
247
|
+
needs_validation, validation_attempts, last_validated_at, usage_count,
|
|
248
|
+
trust_context, usage_success, usage_failure, time_saved,
|
|
249
|
+
evidence, alternatives, risks
|
|
250
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
251
|
+
`);
|
|
252
|
+
|
|
253
|
+
const insertResult = stmt.run(
|
|
254
|
+
decision.id,
|
|
255
|
+
decision.topic,
|
|
256
|
+
decision.decision,
|
|
257
|
+
decision.reasoning || null,
|
|
258
|
+
decision.outcome || null,
|
|
259
|
+
decision.failure_reason || null,
|
|
260
|
+
decision.limitation || null,
|
|
261
|
+
decision.user_involvement || null,
|
|
262
|
+
decision.session_id || null,
|
|
263
|
+
decision.supersedes || null,
|
|
264
|
+
decision.superseded_by || null,
|
|
265
|
+
decision.refined_from ? JSON.stringify(decision.refined_from) : null,
|
|
266
|
+
decision.confidence !== undefined ? decision.confidence : 0.5,
|
|
267
|
+
// IMPORTANT: All timestamps are stored in milliseconds (Date.now()).
|
|
268
|
+
// The schema DEFAULT uses unixepoch() (seconds) but is never used
|
|
269
|
+
// since all inserts go through this function which always provides ms.
|
|
270
|
+
decision.created_at || Date.now(),
|
|
271
|
+
decision.updated_at || Date.now(),
|
|
272
|
+
decision.needs_validation !== undefined ? decision.needs_validation : 0,
|
|
273
|
+
decision.validation_attempts || 0,
|
|
274
|
+
decision.last_validated_at || null,
|
|
275
|
+
decision.usage_count || 0,
|
|
276
|
+
decision.trust_context || null,
|
|
277
|
+
decision.usage_success || 0,
|
|
278
|
+
decision.usage_failure || 0,
|
|
279
|
+
decision.time_saved || 0,
|
|
280
|
+
decision.evidence || null,
|
|
281
|
+
decision.alternatives || null,
|
|
282
|
+
decision.risks || null
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const rowid = Number(insertResult.lastInsertRowid);
|
|
286
|
+
|
|
287
|
+
// Insert embedding in same transaction to ensure rowid matching
|
|
288
|
+
info(`[db-manager] Vector search enabled: ${adapter.vectorSearchEnabled}`);
|
|
289
|
+
if (adapter.vectorSearchEnabled) {
|
|
290
|
+
try {
|
|
291
|
+
info(`[db-manager] Inserting embedding for rowid: ${rowid}`);
|
|
292
|
+
adapter.insertEmbedding(rowid, embedding);
|
|
293
|
+
info(`[db-manager] ✅ Embedding inserted successfully`);
|
|
294
|
+
} catch (embErr) {
|
|
295
|
+
// Log but don't fail transaction if embedding fails
|
|
296
|
+
logError(`[db-manager] ❌ Embedding insert failed: ${embErr.message}`);
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
info(`[db-manager] ⚠️ Vector search disabled, skipping embedding`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return rowid;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
if (process.env.MAMA_DEBUG) {
|
|
306
|
+
info(`[db-manager] Decision stored: ${decision.id}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return decision.id;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
throw new Error(`Failed to insert decision with embedding: ${error.message}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Query decision graph for topic
|
|
317
|
+
*
|
|
318
|
+
* Recursive CTE to traverse supersedes chain
|
|
319
|
+
* SQLite implementation using WITH RECURSIVE
|
|
320
|
+
*
|
|
321
|
+
* @param {string} topic - Decision topic to query
|
|
322
|
+
* @returns {Promise<Array<Object>>} Array of decisions (ordered by recency)
|
|
323
|
+
*/
|
|
324
|
+
async function queryDecisionGraph(topic) {
|
|
325
|
+
const adapter = getAdapter();
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
// Story 014.14 Fix: Prioritize exact topic match over fuzzy matching
|
|
329
|
+
// First try exact match, then fallback to fuzzy if no results
|
|
330
|
+
|
|
331
|
+
// Try exact match first
|
|
332
|
+
let stmt = adapter.prepare(`
|
|
333
|
+
WITH RECURSIVE decision_chain AS (
|
|
334
|
+
-- Base case: Get current decision (not superseded)
|
|
335
|
+
SELECT * FROM decisions
|
|
336
|
+
WHERE topic = ? AND superseded_by IS NULL
|
|
337
|
+
|
|
338
|
+
UNION ALL
|
|
339
|
+
|
|
340
|
+
-- Recursive case: Get previous decisions
|
|
341
|
+
SELECT d.* FROM decisions d
|
|
342
|
+
JOIN decision_chain dc ON d.id = dc.supersedes
|
|
343
|
+
)
|
|
344
|
+
SELECT * FROM decision_chain
|
|
345
|
+
ORDER BY created_at DESC
|
|
346
|
+
`);
|
|
347
|
+
|
|
348
|
+
let decisions = await stmt.all(topic);
|
|
349
|
+
|
|
350
|
+
// If no exact match, try fuzzy matching as fallback
|
|
351
|
+
if (decisions.length === 0) {
|
|
352
|
+
const topicKeyword = topic.split('_')[0];
|
|
353
|
+
|
|
354
|
+
stmt = adapter.prepare(`
|
|
355
|
+
WITH RECURSIVE decision_chain AS (
|
|
356
|
+
-- Base case: Get current decision (not superseded)
|
|
357
|
+
SELECT * FROM decisions
|
|
358
|
+
WHERE topic LIKE ? || '%' AND superseded_by IS NULL
|
|
359
|
+
|
|
360
|
+
UNION ALL
|
|
361
|
+
|
|
362
|
+
-- Recursive case: Get previous decisions
|
|
363
|
+
SELECT d.* FROM decisions d
|
|
364
|
+
JOIN decision_chain dc ON d.id = dc.supersedes
|
|
365
|
+
)
|
|
366
|
+
SELECT * FROM decision_chain
|
|
367
|
+
ORDER BY created_at DESC
|
|
368
|
+
`);
|
|
369
|
+
|
|
370
|
+
decisions = await stmt.all(topicKeyword);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Join with decision_edges to include relationships
|
|
374
|
+
// Prepare statement once outside loop for performance
|
|
375
|
+
const edgesStmt = adapter.prepare(`
|
|
376
|
+
SELECT * FROM decision_edges
|
|
377
|
+
WHERE from_id = ?
|
|
378
|
+
AND (approved_by_user = 1 OR approved_by_user IS NULL)
|
|
379
|
+
`);
|
|
380
|
+
for (const decision of decisions) {
|
|
381
|
+
decision.edges = await edgesStmt.all(decision.id);
|
|
382
|
+
|
|
383
|
+
// Parse refined_from JSON if exists
|
|
384
|
+
if (decision.refined_from) {
|
|
385
|
+
try {
|
|
386
|
+
decision.refined_from = JSON.parse(decision.refined_from);
|
|
387
|
+
} catch (e) {
|
|
388
|
+
decision.refined_from = [];
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return decisions;
|
|
394
|
+
} catch (error) {
|
|
395
|
+
throw new Error(`Decision graph query failed: ${error.message}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Query semantic edges for a list of decisions
|
|
401
|
+
*
|
|
402
|
+
* Returns both outgoing (from_id) and incoming (to_id) edges
|
|
403
|
+
* for refines and contradicts relationships
|
|
404
|
+
*
|
|
405
|
+
* @param {Array<string>} decisionIds - Decision IDs to query edges for
|
|
406
|
+
* @returns {Promise<Object>} Categorized edges { refines, refined_by, contradicts, contradicted_by, builds_on, built_on_by, debates, debated_by, synthesizes, synthesized_by }
|
|
407
|
+
*/
|
|
408
|
+
async function querySemanticEdges(decisionIds) {
|
|
409
|
+
const adapter = getAdapter();
|
|
410
|
+
|
|
411
|
+
if (!decisionIds || decisionIds.length === 0) {
|
|
412
|
+
return {
|
|
413
|
+
refines: [],
|
|
414
|
+
refined_by: [],
|
|
415
|
+
contradicts: [],
|
|
416
|
+
contradicted_by: [],
|
|
417
|
+
// Story 2.1: Extended edge types
|
|
418
|
+
builds_on: [],
|
|
419
|
+
built_on_by: [],
|
|
420
|
+
debates: [],
|
|
421
|
+
debated_by: [],
|
|
422
|
+
synthesizes: [],
|
|
423
|
+
synthesized_by: [],
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
// Build placeholders for IN clause
|
|
429
|
+
const placeholders = decisionIds.map(() => '?').join(',');
|
|
430
|
+
|
|
431
|
+
// Story 2.1: Include new edge types in query
|
|
432
|
+
const edgeTypes = ['refines', 'contradicts', 'builds_on', 'debates', 'synthesizes'];
|
|
433
|
+
const edgeTypePlaceholders = edgeTypes.map(() => '?').join(',');
|
|
434
|
+
|
|
435
|
+
// Query outgoing edges (from_id = decision)
|
|
436
|
+
const outgoingStmt = adapter.prepare(`
|
|
437
|
+
SELECT e.*, d.topic, d.decision, d.confidence, d.created_at
|
|
438
|
+
FROM decision_edges e
|
|
439
|
+
JOIN decisions d ON e.to_id = d.id
|
|
440
|
+
WHERE e.from_id IN (${placeholders})
|
|
441
|
+
AND e.relationship IN (${edgeTypePlaceholders})
|
|
442
|
+
AND (e.approved_by_user = 1 OR e.approved_by_user IS NULL)
|
|
443
|
+
ORDER BY e.created_at DESC
|
|
444
|
+
`);
|
|
445
|
+
const outgoingEdges = await outgoingStmt.all(...decisionIds, ...edgeTypes);
|
|
446
|
+
|
|
447
|
+
// Query incoming edges (to_id = decision)
|
|
448
|
+
const incomingStmt = adapter.prepare(`
|
|
449
|
+
SELECT e.*, d.topic, d.decision, d.confidence, d.created_at
|
|
450
|
+
FROM decision_edges e
|
|
451
|
+
JOIN decisions d ON e.from_id = d.id
|
|
452
|
+
WHERE e.to_id IN (${placeholders})
|
|
453
|
+
AND e.relationship IN (${edgeTypePlaceholders})
|
|
454
|
+
AND (e.approved_by_user = 1 OR e.approved_by_user IS NULL)
|
|
455
|
+
ORDER BY e.created_at DESC
|
|
456
|
+
`);
|
|
457
|
+
const incomingEdges = await incomingStmt.all(...decisionIds, ...edgeTypes);
|
|
458
|
+
|
|
459
|
+
// Categorize edges (original + v1.3 extended)
|
|
460
|
+
const refines = outgoingEdges.filter((e) => e.relationship === 'refines');
|
|
461
|
+
const refined_by = incomingEdges.filter((e) => e.relationship === 'refines');
|
|
462
|
+
const contradicts = outgoingEdges.filter((e) => e.relationship === 'contradicts');
|
|
463
|
+
const contradicted_by = incomingEdges.filter((e) => e.relationship === 'contradicts');
|
|
464
|
+
// Story 2.1: New edge type categories
|
|
465
|
+
const builds_on = outgoingEdges.filter((e) => e.relationship === 'builds_on');
|
|
466
|
+
const built_on_by = incomingEdges.filter((e) => e.relationship === 'builds_on');
|
|
467
|
+
const debates = outgoingEdges.filter((e) => e.relationship === 'debates');
|
|
468
|
+
const debated_by = incomingEdges.filter((e) => e.relationship === 'debates');
|
|
469
|
+
const synthesizes = outgoingEdges.filter((e) => e.relationship === 'synthesizes');
|
|
470
|
+
const synthesized_by = incomingEdges.filter((e) => e.relationship === 'synthesizes');
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
refines,
|
|
474
|
+
refined_by,
|
|
475
|
+
contradicts,
|
|
476
|
+
contradicted_by,
|
|
477
|
+
builds_on,
|
|
478
|
+
built_on_by,
|
|
479
|
+
debates,
|
|
480
|
+
debated_by,
|
|
481
|
+
synthesizes,
|
|
482
|
+
synthesized_by,
|
|
483
|
+
};
|
|
484
|
+
} catch (error) {
|
|
485
|
+
throw new Error(`Semantic edges query failed: ${error.message}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Query vector search with time window and threshold
|
|
491
|
+
*
|
|
492
|
+
* Story 014.14: AC #1 - Vector Search for Related Decisions
|
|
493
|
+
*
|
|
494
|
+
* @param {Object} params - Search parameters
|
|
495
|
+
* @param {string} params.query - Search query text
|
|
496
|
+
* @param {number} params.limit - Max results (default: 10)
|
|
497
|
+
* @param {number} params.threshold - Minimum cosine similarity (0.0-1.0, default: 0.75)
|
|
498
|
+
* @param {number} params.timeWindow - Time window in ms (optional, default: 90 days)
|
|
499
|
+
* @returns {Promise<Array>} Results with similarity scores and decision data
|
|
500
|
+
*/
|
|
501
|
+
async function queryVectorSearch({
|
|
502
|
+
query,
|
|
503
|
+
limit = 10,
|
|
504
|
+
threshold = 0.75,
|
|
505
|
+
timeWindow = 90 * 24 * 60 * 60 * 1000,
|
|
506
|
+
}) {
|
|
507
|
+
const adapter = getAdapter();
|
|
508
|
+
const { generateEmbedding } = require('./embeddings');
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
// Generate embedding for query
|
|
512
|
+
const embedding = await generateEmbedding(query);
|
|
513
|
+
|
|
514
|
+
const cutoffTime = Date.now() - timeWindow;
|
|
515
|
+
const candidates = await adapter.vectorSearch(embedding, limit * 5);
|
|
516
|
+
|
|
517
|
+
if (!candidates || candidates.length === 0) {
|
|
518
|
+
return [];
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const stmt = adapter.prepare(`SELECT * FROM decisions WHERE rowid = ?`);
|
|
522
|
+
const results = [];
|
|
523
|
+
|
|
524
|
+
for (const candidate of candidates) {
|
|
525
|
+
const decision = stmt.get(candidate.rowid);
|
|
526
|
+
if (!decision) {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (decision.created_at < cutoffTime) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const similarity = candidate.similarity ?? Math.max(0, 1 - (candidate.distance ?? 1));
|
|
535
|
+
const distance = candidate.distance ?? Math.max(0, 1 - similarity);
|
|
536
|
+
|
|
537
|
+
if (similarity < threshold) {
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
results.push({
|
|
542
|
+
...decision,
|
|
543
|
+
similarity,
|
|
544
|
+
distance,
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
if (results.length >= limit) {
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return results;
|
|
553
|
+
} catch (error) {
|
|
554
|
+
logError(`[db-manager] queryVectorSearch failed: ${error.message}`);
|
|
555
|
+
return []; // Return empty array on error (graceful degradation)
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Update decision outcome
|
|
561
|
+
*
|
|
562
|
+
* @param {string} decisionId - Decision ID
|
|
563
|
+
* @param {Object} outcomeData - Outcome data
|
|
564
|
+
* @returns {Promise<void>}
|
|
565
|
+
*/
|
|
566
|
+
async function updateDecisionOutcome(decisionId, outcomeData) {
|
|
567
|
+
const adapter = getAdapter();
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
const stmt = adapter.prepare(`
|
|
571
|
+
UPDATE decisions
|
|
572
|
+
SET
|
|
573
|
+
outcome = ?,
|
|
574
|
+
failure_reason = ?,
|
|
575
|
+
limitation = ?,
|
|
576
|
+
duration_days = ?,
|
|
577
|
+
confidence = COALESCE(?, confidence),
|
|
578
|
+
updated_at = ?
|
|
579
|
+
WHERE id = ?
|
|
580
|
+
`);
|
|
581
|
+
|
|
582
|
+
await stmt.run(
|
|
583
|
+
outcomeData.outcome || null,
|
|
584
|
+
outcomeData.failure_reason || null,
|
|
585
|
+
outcomeData.limitation || null,
|
|
586
|
+
outcomeData.duration_days || null,
|
|
587
|
+
outcomeData.confidence !== undefined ? outcomeData.confidence : null,
|
|
588
|
+
Date.now(),
|
|
589
|
+
decisionId
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
info(`[db-manager] Decision outcome updated: ${decisionId} → ${outcomeData.outcome}`);
|
|
593
|
+
} catch (error) {
|
|
594
|
+
throw new Error(`Failed to update decision outcome: ${error.message}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Get prepared statement
|
|
600
|
+
*
|
|
601
|
+
* For backward compatibility with memory-store.js
|
|
602
|
+
* Returns a compatibility shim that proxies to adapter.prepare()
|
|
603
|
+
*
|
|
604
|
+
* @param {string} sql - SQL statement
|
|
605
|
+
* @returns {Object} Statement-like object with run/get/all methods
|
|
606
|
+
*/
|
|
607
|
+
function getPreparedStmt(sql) {
|
|
608
|
+
if (!dbAdapter) {
|
|
609
|
+
warn('[db-manager] getPreparedStmt() called before initialization');
|
|
610
|
+
// Return no-op object for feature detection (won't throw)
|
|
611
|
+
return {
|
|
612
|
+
run: () => ({ changes: 0, lastInsertRowid: 0 }),
|
|
613
|
+
get: () => null,
|
|
614
|
+
all: () => [],
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Proxy to adapter.prepare() for actual usage
|
|
619
|
+
try {
|
|
620
|
+
return dbAdapter.prepare(sql);
|
|
621
|
+
} catch (error) {
|
|
622
|
+
warn(`[db-manager] getPreparedStmt() failed: ${error.message}`);
|
|
623
|
+
// Return no-op object on error (graceful degradation)
|
|
624
|
+
return {
|
|
625
|
+
run: () => ({ changes: 0, lastInsertRowid: 0 }),
|
|
626
|
+
get: () => null,
|
|
627
|
+
all: () => [],
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Get database file path
|
|
634
|
+
*
|
|
635
|
+
* @returns {string} Actual database path or 'Not initialized'
|
|
636
|
+
*/
|
|
637
|
+
function getDbPath() {
|
|
638
|
+
if (!dbAdapter) {
|
|
639
|
+
return 'Not initialized';
|
|
640
|
+
}
|
|
641
|
+
// Use adapter's getDbPath method if available, fallback to description
|
|
642
|
+
if (typeof dbAdapter.getDbPath === 'function') {
|
|
643
|
+
return dbAdapter.getDbPath();
|
|
644
|
+
}
|
|
645
|
+
// Fallback: try to get path from adapter properties
|
|
646
|
+
if (dbAdapter.dbPath) {
|
|
647
|
+
return dbAdapter.dbPath;
|
|
648
|
+
}
|
|
649
|
+
return `${dbAdapter.constructor.name} (path unavailable)`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Export API (same interface as memory-store.js, but async where needed)
|
|
653
|
+
module.exports = {
|
|
654
|
+
initDB, // Async
|
|
655
|
+
getDB, // Sync (throws if not initialized)
|
|
656
|
+
getAdapter, // Sync (throws if not initialized)
|
|
657
|
+
closeDB, // Async
|
|
658
|
+
insertEmbedding, // Async
|
|
659
|
+
vectorSearch, // Async
|
|
660
|
+
queryVectorSearch, // Async - Story 014.14
|
|
661
|
+
querySemanticEdges, // Async - Graph traversal enhancement
|
|
662
|
+
insertDecisionWithEmbedding, // Async
|
|
663
|
+
queryDecisionGraph, // Async
|
|
664
|
+
updateDecisionOutcome, // Async
|
|
665
|
+
getPreparedStmt, // Compatibility shim
|
|
666
|
+
getDbPath, // Returns actual path
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
// Note: Removed auto-registered SIGINT/SIGTERM handlers that called process.exit(0)
|
|
670
|
+
// This was causing issues with host cleanup in parent processes.
|
|
671
|
+
// If graceful shutdown is needed, the host application should handle closeDB().
|