@jungjaehoon/mama-server 1.0.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.
@@ -0,0 +1,913 @@
1
+ /**
2
+ * MAMA (Memory-Augmented MCP Architecture) - Simple Public API
3
+ *
4
+ * Clean wrapper around MAMA's internal functions
5
+ * Follows Claude-First Design: Simple, Transparent, Non-Intrusive
6
+ *
7
+ * Core Principle: MAMA = Librarian, Claude = Researcher
8
+ * - MAMA stores (organize books), retrieves (find books), indexes (catalog)
9
+ * - Claude decides what to save and how to use recalled decisions
10
+ *
11
+ * @module mama-api
12
+ * @version 1.0
13
+ * @date 2025-11-14
14
+ */
15
+
16
+ const { learnDecision } = require('./decision-tracker');
17
+ const { injectDecisionContext } = require('./memory-inject');
18
+ const { queryDecisionGraph, querySemanticEdges, getDB, getAdapter } = require('./memory-store');
19
+ const { formatRecall, formatList } = require('./decision-formatter');
20
+
21
+ /**
22
+ * Save a decision or insight to MAMA's memory
23
+ *
24
+ * Simple API for Claude to save insights without complex configuration
25
+ * AC #1: Simple API - no complex configuration required
26
+ *
27
+ * @param {Object} params - Decision parameters
28
+ * @param {string} params.topic - Decision topic (e.g., 'auth_strategy', 'date_format')
29
+ * @param {string} params.decision - The decision made (e.g., 'JWT', 'ISO 8601 + Unix')
30
+ * @param {string} params.reasoning - Why this decision was made
31
+ * @param {number} [params.confidence=0.5] - Confidence score 0.0-1.0 (optional)
32
+ * @param {string} [params.type='user_decision'] - 'user_decision' or 'assistant_insight' (optional)
33
+ * @param {string} [params.outcome='pending'] - 'pending', 'success', 'failure', 'partial', 'superseded' (optional)
34
+ * @param {string} [params.failure_reason] - Why this decision failed (optional, used with outcome='failure')
35
+ * @param {string} [params.limitation] - Known limitations of this decision (optional)
36
+ * @returns {Promise<string>} Decision ID
37
+ *
38
+ * @example
39
+ * const decisionId = await mama.save({
40
+ * topic: 'date_calculation_format',
41
+ * decision: 'Support both ISO 8601 and Unix timestamp formats',
42
+ * reasoning: 'Bootstrap data stored as ISO 8601 causing NaN errors',
43
+ * confidence: 0.95,
44
+ * type: 'assistant_insight',
45
+ * outcome: 'success'
46
+ * });
47
+ */
48
+ async function save({
49
+ topic,
50
+ decision,
51
+ reasoning,
52
+ confidence = 0.5,
53
+ type = 'user_decision',
54
+ outcome = 'pending',
55
+ failure_reason = null,
56
+ limitation = null,
57
+ trust_context = null,
58
+ }) {
59
+ // Validate required fields
60
+ if (!topic || typeof topic !== 'string') {
61
+ throw new Error('mama.save() requires topic (string)');
62
+ }
63
+ if (!decision || typeof decision !== 'string') {
64
+ throw new Error('mama.save() requires decision (string)');
65
+ }
66
+ if (!reasoning || typeof reasoning !== 'string') {
67
+ throw new Error('mama.save() requires reasoning (string)');
68
+ }
69
+
70
+ // Validate confidence range
71
+ if (typeof confidence !== 'number' || confidence < 0 || confidence > 1) {
72
+ throw new Error('mama.save() confidence must be a number between 0.0 and 1.0');
73
+ }
74
+
75
+ // Validate type
76
+ if (type !== 'user_decision' && type !== 'assistant_insight') {
77
+ throw new Error('mama.save() type must be "user_decision" or "assistant_insight"');
78
+ }
79
+
80
+ // Validate outcome
81
+ const validOutcomes = ['pending', 'success', 'failure', 'partial', 'superseded'];
82
+ if (outcome && !validOutcomes.includes(outcome)) {
83
+ throw new Error(
84
+ `mama.save() outcome must be one of: ${validOutcomes.join(', ')} (got: ${outcome})`
85
+ );
86
+ }
87
+
88
+ // Map type to user_involvement field
89
+ // Note: Current schema uses user_involvement ('requested', 'approved', 'rejected')
90
+ // Future: Will use decision_type column for proper distinction
91
+ const userInvolvement = type === 'user_decision' ? 'approved' : null;
92
+
93
+ // Create detection object for learnDecision()
94
+ const detection = {
95
+ topic,
96
+ decision,
97
+ reasoning,
98
+ confidence,
99
+ outcome,
100
+ failure_reason,
101
+ limitation,
102
+ trust_context,
103
+ };
104
+
105
+ // Create tool execution context
106
+ // Use current timestamp and generate session ID
107
+ const sessionId = `mama_api_${Date.now()}`;
108
+ const toolExecution = {
109
+ tool_name: 'mama.save',
110
+ tool_input: { topic, decision },
111
+ exit_code: 0,
112
+ session_id: sessionId,
113
+ timestamp: Date.now(),
114
+ };
115
+
116
+ // Create session context
117
+ const sessionContext = {
118
+ session_id: sessionId,
119
+ latest_user_message: `Save ${type}: ${topic}`,
120
+ recent_exchange: `Claude: ${reasoning.substring(0, 100)}...`,
121
+ };
122
+
123
+ // Call internal learnDecision function
124
+ // Note: learnDecision returns { decisionId, notification }
125
+ const { decisionId } = await learnDecision(detection, toolExecution, sessionContext);
126
+
127
+ // Update user_involvement, outcome, failure_reason, limitation
128
+ // Note: learnDecision always sets 'requested', we need to override it
129
+ const adapter = getAdapter();
130
+
131
+ // Build UPDATE query dynamically based on what fields are provided
132
+ const updates = [];
133
+ const values = [];
134
+
135
+ // user_involvement based on type
136
+ if (type === 'assistant_insight') {
137
+ updates.push('user_involvement = NULL');
138
+ } else if (type === 'user_decision') {
139
+ updates.push('user_involvement = ?');
140
+ values.push('approved');
141
+ }
142
+
143
+ // outcome (always set, default is 'pending')
144
+ // Story M4.1 fix: Map to DB format (uppercase, pending → NULL)
145
+ if (outcome) {
146
+ const outcomeMap = {
147
+ pending: null,
148
+ success: 'SUCCESS',
149
+ failure: 'FAILED',
150
+ partial: 'PARTIAL',
151
+ superseded: null,
152
+ };
153
+ const dbOutcome = outcomeMap[outcome] !== undefined ? outcomeMap[outcome] : outcome;
154
+
155
+ updates.push('outcome = ?');
156
+ values.push(dbOutcome);
157
+ }
158
+
159
+ // failure_reason (optional)
160
+ if (failure_reason) {
161
+ updates.push('failure_reason = ?');
162
+ values.push(failure_reason);
163
+ }
164
+
165
+ // limitation (optional)
166
+ if (limitation) {
167
+ updates.push('limitation = ?');
168
+ values.push(limitation);
169
+ }
170
+
171
+ // Execute UPDATE if we have any fields to update
172
+ if (updates.length > 0) {
173
+ values.push(decisionId); // WHERE id = ?
174
+ const stmt = adapter.prepare(`
175
+ UPDATE decisions
176
+ SET ${updates.join(', ')}
177
+ WHERE id = ?
178
+ `);
179
+ await stmt.run(...values);
180
+ }
181
+
182
+ return decisionId;
183
+ }
184
+
185
+ /**
186
+ * Recall decisions by topic
187
+ *
188
+ * DEFAULT: Returns JSON object with decisions and edges (LLM-first design)
189
+ * OPTIONAL: Returns Markdown string if format='markdown' (for human display)
190
+ *
191
+ * @param {string} topic - Decision topic to recall
192
+ * @param {Object} [options] - Options
193
+ * @param {string} [options.format='json'] - Output format: 'json' (default) or 'markdown'
194
+ * @returns {Promise<Object|string>} Decision history as JSON or Markdown
195
+ *
196
+ * @example
197
+ * // LLM usage (default)
198
+ * const data = await mama.recall('auth_strategy');
199
+ * // → { topic, decisions: [...], edges: [...], meta: {...} }
200
+ *
201
+ * // Human display
202
+ * const markdown = await mama.recall('auth_strategy', { format: 'markdown' });
203
+ * // → "📋 Decision History: auth_strategy\n━━━━━━━━..."
204
+ */
205
+ async function recall(topic, options = {}) {
206
+ if (!topic || typeof topic !== 'string') {
207
+ throw new Error('mama.recall() requires topic (string)');
208
+ }
209
+
210
+ const { format = 'json' } = options;
211
+
212
+ try {
213
+ const decisions = await queryDecisionGraph(topic);
214
+
215
+ if (!decisions || decisions.length === 0) {
216
+ if (format === 'markdown') {
217
+ return `❌ No decisions found for topic: ${topic}`;
218
+ }
219
+ return {
220
+ topic,
221
+ supersedes_chain: [],
222
+ semantic_edges: { refines: [], refined_by: [], contradicts: [], contradicted_by: [] },
223
+ meta: { count: 0 },
224
+ };
225
+ }
226
+
227
+ // Query semantic edges for all decisions
228
+ const decisionIds = decisions.map((d) => d.id);
229
+ const semanticEdges = await querySemanticEdges(decisionIds);
230
+
231
+ // Markdown format (for human display)
232
+ if (format === 'markdown') {
233
+ // Pass semantic edges to formatter
234
+ return formatRecall(decisions, semanticEdges);
235
+ }
236
+
237
+ // JSON format (default - LLM-first)
238
+ // Separate supersedes chain from semantic edges
239
+ return {
240
+ topic,
241
+ supersedes_chain: decisions.map((d) => ({
242
+ id: d.id,
243
+ decision: d.decision,
244
+ reasoning: d.reasoning,
245
+ confidence: d.confidence,
246
+ outcome: d.outcome,
247
+ failure_reason: d.failure_reason,
248
+ limitation: d.limitation,
249
+ created_at: d.created_at,
250
+ updated_at: d.updated_at,
251
+ superseded_by: d.superseded_by,
252
+ supersedes: d.supersedes,
253
+ trust_context: d.trust_context,
254
+ })),
255
+ semantic_edges: {
256
+ refines: semanticEdges.refines.map((e) => ({
257
+ to_topic: e.topic,
258
+ to_decision: e.decision,
259
+ to_id: e.to_id,
260
+ reason: e.reason,
261
+ confidence: e.confidence,
262
+ created_at: e.created_at,
263
+ })),
264
+ refined_by: semanticEdges.refined_by.map((e) => ({
265
+ from_topic: e.topic,
266
+ from_decision: e.decision,
267
+ from_id: e.from_id,
268
+ reason: e.reason,
269
+ confidence: e.confidence,
270
+ created_at: e.created_at,
271
+ })),
272
+ contradicts: semanticEdges.contradicts.map((e) => ({
273
+ to_topic: e.topic,
274
+ to_decision: e.decision,
275
+ to_id: e.to_id,
276
+ reason: e.reason,
277
+ created_at: e.created_at,
278
+ })),
279
+ contradicted_by: semanticEdges.contradicted_by.map((e) => ({
280
+ from_topic: e.topic,
281
+ from_decision: e.decision,
282
+ from_id: e.from_id,
283
+ reason: e.reason,
284
+ created_at: e.created_at,
285
+ })),
286
+ },
287
+ meta: {
288
+ count: decisions.length,
289
+ latest_id: decisions[0]?.id,
290
+ has_supersedes_chain: decisions.some((d) => d.supersedes),
291
+ has_semantic_edges:
292
+ semanticEdges.refines.length > 0 ||
293
+ semanticEdges.refined_by.length > 0 ||
294
+ semanticEdges.contradicts.length > 0 ||
295
+ semanticEdges.contradicted_by.length > 0,
296
+ semantic_edges_count: {
297
+ refines: semanticEdges.refines.length,
298
+ refined_by: semanticEdges.refined_by.length,
299
+ contradicts: semanticEdges.contradicts.length,
300
+ contradicted_by: semanticEdges.contradicted_by.length,
301
+ },
302
+ },
303
+ };
304
+ } catch (error) {
305
+ throw new Error(`mama.recall() failed: ${error.message}`);
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Update outcome of a decision
311
+ *
312
+ * Track whether a decision succeeded, failed, or partially worked
313
+ * AC: Evolutionary Decision Memory - Learn from outcomes
314
+ *
315
+ * @param {string} decisionId - Decision ID to update
316
+ * @param {Object} outcome - Outcome details
317
+ * @param {string} outcome.outcome - 'SUCCESS', 'FAILED', or 'PARTIAL'
318
+ * @param {string} [outcome.failure_reason] - Reason for failure (if FAILED)
319
+ * @param {string} [outcome.limitation] - Limitation description (if PARTIAL)
320
+ * @returns {Promise<void>}
321
+ *
322
+ * @example
323
+ * await mama.updateOutcome('decision_auth_strategy_123456_abc', {
324
+ * outcome: 'FAILED',
325
+ * failure_reason: 'Missing token expiration handling'
326
+ * });
327
+ */
328
+ async function updateOutcome(decisionId, { outcome, failure_reason, limitation }) {
329
+ if (!decisionId || typeof decisionId !== 'string') {
330
+ throw new Error('mama.updateOutcome() requires decisionId (string)');
331
+ }
332
+
333
+ if (!outcome || !['SUCCESS', 'FAILED', 'PARTIAL'].includes(outcome)) {
334
+ throw new Error('mama.updateOutcome() outcome must be "SUCCESS", "FAILED", or "PARTIAL"');
335
+ }
336
+
337
+ try {
338
+ const adapter = getAdapter();
339
+
340
+ // Update outcome and related fields
341
+ const stmt = adapter.prepare(
342
+ `
343
+ UPDATE decisions
344
+ SET
345
+ outcome = ?,
346
+ failure_reason = ?,
347
+ limitation = ?,
348
+ updated_at = ?
349
+ WHERE id = ?
350
+ `
351
+ );
352
+ await stmt.run(outcome, failure_reason || null, limitation || null, Date.now(), decisionId);
353
+
354
+ return;
355
+ } catch (error) {
356
+ throw new Error(`mama.updateOutcome() failed: ${error.message}`);
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Expand search results with graph context (Phase 1 - Graph-Enhanced Retrieval)
362
+ *
363
+ * For each candidate decision:
364
+ * 1. Add supersedes chain (evolution history)
365
+ * 2. Add semantic edges (refines, contradicts)
366
+ * 3. Deduplicate by ID
367
+ * 4. Re-rank by relevance (primary candidates ranked higher)
368
+ *
369
+ * @param {Array} candidates - Initial search results from vector/keyword search
370
+ * @returns {Promise<Array>} Graph-enhanced results with evolution context
371
+ */
372
+ async function expandWithGraph(candidates) {
373
+ const graphEnhanced = new Map(); // Use Map for deduplication by ID
374
+ const primaryIds = new Set(candidates.map((c) => c.id)); // Track primary candidates
375
+
376
+ // Process each candidate
377
+ for (const candidate of candidates) {
378
+ // Add primary candidate with higher rank
379
+ if (!graphEnhanced.has(candidate.id)) {
380
+ graphEnhanced.set(candidate.id, {
381
+ ...candidate,
382
+ graph_source: 'primary', // Mark as primary result
383
+ graph_rank: 1.0, // Highest rank
384
+ });
385
+ }
386
+
387
+ // 1. Add supersedes chain (evolution history)
388
+ try {
389
+ const chain = await queryDecisionGraph(candidate.topic);
390
+ for (const decision of chain) {
391
+ if (!graphEnhanced.has(decision.id)) {
392
+ graphEnhanced.set(decision.id, {
393
+ ...decision,
394
+ graph_source: 'supersedes_chain',
395
+ graph_rank: 0.8, // Lower rank than primary
396
+ similarity: candidate.similarity * 0.9, // Inherit similarity, slightly reduced
397
+ related_to: candidate.id, // Track relationship
398
+ });
399
+ }
400
+ }
401
+ } catch (error) {
402
+ console.warn(`Failed to get supersedes chain for ${candidate.topic}: ${error.message}`);
403
+ }
404
+
405
+ // 2. Add semantic edges (refines, contradicts)
406
+ try {
407
+ const edges = await querySemanticEdges([candidate.id]);
408
+
409
+ // Add refines edges
410
+ for (const edge of edges.refines) {
411
+ if (!graphEnhanced.has(edge.to_id)) {
412
+ graphEnhanced.set(edge.to_id, {
413
+ id: edge.to_id,
414
+ topic: edge.topic,
415
+ decision: edge.decision,
416
+ confidence: edge.confidence,
417
+ created_at: edge.created_at,
418
+ graph_source: 'refines',
419
+ graph_rank: 0.7,
420
+ similarity: candidate.similarity * 0.85,
421
+ related_to: candidate.id,
422
+ edge_reason: edge.reason,
423
+ });
424
+ }
425
+ }
426
+
427
+ // Add refined_by edges
428
+ for (const edge of edges.refined_by) {
429
+ if (!graphEnhanced.has(edge.from_id)) {
430
+ graphEnhanced.set(edge.from_id, {
431
+ id: edge.from_id,
432
+ topic: edge.topic,
433
+ decision: edge.decision,
434
+ confidence: edge.confidence,
435
+ created_at: edge.created_at,
436
+ graph_source: 'refined_by',
437
+ graph_rank: 0.7,
438
+ similarity: candidate.similarity * 0.85,
439
+ related_to: candidate.id,
440
+ edge_reason: edge.reason,
441
+ });
442
+ }
443
+ }
444
+
445
+ // Add contradicts edges (lower rank, but still relevant)
446
+ for (const edge of edges.contradicts) {
447
+ if (!graphEnhanced.has(edge.to_id)) {
448
+ graphEnhanced.set(edge.to_id, {
449
+ id: edge.to_id,
450
+ topic: edge.topic,
451
+ decision: edge.decision,
452
+ confidence: edge.confidence,
453
+ created_at: edge.created_at,
454
+ graph_source: 'contradicts',
455
+ graph_rank: 0.6,
456
+ similarity: candidate.similarity * 0.8,
457
+ related_to: candidate.id,
458
+ edge_reason: edge.reason,
459
+ });
460
+ }
461
+ }
462
+ } catch (error) {
463
+ console.warn(`Failed to get semantic edges for ${candidate.id}: ${error.message}`);
464
+ }
465
+ }
466
+
467
+ // 3. Convert Map to Array and sort by graph_rank + similarity
468
+ const results = Array.from(graphEnhanced.values());
469
+
470
+ // 4. Sort: Primary first, then by graph_rank, then by final_score (or similarity)
471
+ results.sort((a, b) => {
472
+ // Primary candidates always first
473
+ if (primaryIds.has(a.id) && !primaryIds.has(b.id)) return -1;
474
+ if (!primaryIds.has(a.id) && primaryIds.has(b.id)) return 1;
475
+
476
+ // Then by graph_rank
477
+ if (a.graph_rank !== b.graph_rank) {
478
+ return b.graph_rank - a.graph_rank;
479
+ }
480
+
481
+ // Finally by final_score (recency-boosted) or similarity (fallback)
482
+ const scoreA = a.final_score || a.similarity || 0;
483
+ const scoreB = b.final_score || b.similarity || 0;
484
+ return scoreB - scoreA;
485
+ });
486
+
487
+ return results;
488
+ }
489
+
490
+ /**
491
+ * Apply Gaussian Decay recency boosting (Elasticsearch-style)
492
+ * Allows Claude to dynamically adjust search strategy based on results
493
+ *
494
+ * @param {Array} results - Search results with similarity scores
495
+ * @param {Object} options - Recency boosting options
496
+ * @returns {Array} Results with recency-boosted final scores
497
+ */
498
+ function applyRecencyBoost(results, options = {}) {
499
+ const {
500
+ recencyWeight = 0.3,
501
+ recencyScale = 7,
502
+ recencyDecay = 0.5,
503
+ disableRecency = false,
504
+ } = options;
505
+
506
+ if (disableRecency || recencyWeight === 0) {
507
+ return results;
508
+ }
509
+
510
+ const now = Date.now(); // Current timestamp in milliseconds
511
+
512
+ return results
513
+ .map((r) => {
514
+ // created_at is stored in milliseconds in the database
515
+ const ageInDays = (now - r.created_at) / (86400 * 1000);
516
+
517
+ // Gaussian Decay: exp(-((age / scale)^2) / (2 * ln(1 / decay)))
518
+ // At scale days: score = decay (e.g., 7 days = 50%)
519
+ const gaussianDecay = Math.exp(
520
+ -Math.pow(ageInDays / recencyScale, 2) / (2 * Math.log(1 / recencyDecay))
521
+ );
522
+
523
+ // Combine semantic similarity with recency
524
+ const finalScore = r.similarity * (1 - recencyWeight) + gaussianDecay * recencyWeight;
525
+
526
+ return {
527
+ ...r,
528
+ recency_score: gaussianDecay,
529
+ recency_age_days: Math.round(ageInDays * 10) / 10,
530
+ final_score: finalScore,
531
+ };
532
+ })
533
+ .sort((a, b) => b.final_score - a.final_score);
534
+ }
535
+
536
+ /**
537
+ * Suggest relevant decisions based on user question
538
+ *
539
+ * DEFAULT: Returns JSON object with search results (LLM-first design)
540
+ * OPTIONAL: Returns Markdown string if format='markdown' (for human display)
541
+ *
542
+ * Simplified: Direct vector search without LLM intent analysis
543
+ * Works with short queries, long questions, Korean/English
544
+ *
545
+ * @param {string} userQuestion - User's question or intent
546
+ * @param {Object} options - Search options
547
+ * @param {string} [options.format='json'] - Output format: 'json' (default) or 'markdown'
548
+ * @param {number} [options.limit=5] - Max results to return
549
+ * @param {number} [options.threshold=0.6] - Minimum similarity (adaptive by query length)
550
+ * @param {boolean} [options.useReranking=false] - Use LLM re-ranking (optional, slower)
551
+ * @returns {Promise<Object|string|null>} Search results as JSON or Markdown, null if no results
552
+ *
553
+ * @example
554
+ * // LLM usage (default)
555
+ * const data = await mama.suggest('Why did we choose JWT?');
556
+ * // → { query, results: [...], meta: {...} }
557
+ *
558
+ * // Human display
559
+ * const markdown = await mama.suggest('mesh optimization', { format: 'markdown' });
560
+ * // → "💡 MAMA found 3 related topics:\n1. ..."
561
+ */
562
+ async function suggest(userQuestion, options = {}) {
563
+ if (!userQuestion || typeof userQuestion !== 'string') {
564
+ throw new Error('mama.suggest() requires userQuestion (string)');
565
+ }
566
+
567
+ const {
568
+ format = 'json',
569
+ limit = 5,
570
+ threshold,
571
+ useReranking = false,
572
+ // Recency boosting parameters (Gaussian Decay - Elasticsearch style)
573
+ recencyWeight = 0.3, // 0-1: How much to weight recency (0.3 = 70% semantic, 30% recency)
574
+ recencyScale = 7, // Days until recency score drops to 50%
575
+ recencyDecay = 0.5, // Score at scale point (0.5 = 50%)
576
+ disableRecency = false, // Set true to disable recency boosting entirely
577
+ } = options;
578
+
579
+ try {
580
+ // 1. Try vector search first (if sqlite-vss is available)
581
+ const { getPreparedStmt, getDB } = require('./memory-store');
582
+ let results = [];
583
+ let searchMethod = 'vector';
584
+
585
+ try {
586
+ // Check if vectorSearch prepared statement exists
587
+ getPreparedStmt('vectorSearch');
588
+
589
+ // Generate query embedding
590
+ const { generateEmbedding } = require('./embeddings');
591
+ const queryEmbedding = await generateEmbedding(userQuestion);
592
+
593
+ // Adaptive threshold (shorter queries need higher confidence)
594
+ const wordCount = userQuestion.split(/\s+/).length;
595
+ const adaptiveThreshold = threshold !== undefined ? threshold : wordCount < 3 ? 0.7 : 0.6;
596
+
597
+ // Vector search
598
+ const { vectorSearch } = require('./memory-store');
599
+ results = await vectorSearch(queryEmbedding, limit * 2, 0.5); // Get more candidates
600
+
601
+ // Filter by adaptive threshold
602
+ results = results.filter((r) => r.similarity >= adaptiveThreshold);
603
+
604
+ // Stage 1.5: Apply recency boosting (Gaussian Decay)
605
+ // Allows Claude to adjust search strategy (recent vs historical)
606
+ if (results.length > 0 && !disableRecency) {
607
+ results = applyRecencyBoost(results, {
608
+ recencyWeight,
609
+ recencyScale,
610
+ recencyDecay,
611
+ disableRecency,
612
+ });
613
+ searchMethod = 'vector+recency';
614
+ }
615
+
616
+ // Stage 2: Graph expansion (NEW - Phase 1)
617
+ // Expand candidates with supersedes chain and semantic edges
618
+ if (results.length > 0) {
619
+ const graphEnhanced = await expandWithGraph(results);
620
+ results = graphEnhanced;
621
+ searchMethod = disableRecency ? 'vector+graph' : 'vector+recency+graph';
622
+ }
623
+ } catch (vectorError) {
624
+ // Fallback to keyword search if vector search unavailable
625
+ console.warn(`Vector search failed: ${vectorError.message}, falling back to keyword search`);
626
+ searchMethod = 'keyword';
627
+
628
+ // Keyword search fallback
629
+ const adapter = getAdapter();
630
+ const keywords = userQuestion
631
+ .toLowerCase()
632
+ .split(/\s+/)
633
+ .filter((w) => w.length > 2); // Filter short words
634
+
635
+ if (keywords.length === 0) {
636
+ return `💡 Hint: Please be more specific.\nExample: "Railway Volume settings" or "mesh parameter optimization"`;
637
+ }
638
+
639
+ // Build LIKE query for each keyword
640
+ const likeConditions = keywords.map(() => '(topic LIKE ? OR decision LIKE ?)').join(' OR ');
641
+ const likeParams = keywords.flatMap((k) => [`%${k}%`, `%${k}%`]);
642
+
643
+ const stmt = adapter.prepare(`
644
+ SELECT * FROM decisions
645
+ WHERE ${likeConditions}
646
+ AND superseded_by IS NULL
647
+ ORDER BY created_at DESC
648
+ LIMIT ?
649
+ `);
650
+
651
+ const rows = await stmt.all(...likeParams, limit);
652
+ results = rows.map((row) => ({
653
+ ...row,
654
+ similarity: 0.75, // Assign moderate similarity for keyword matches
655
+ }));
656
+
657
+ // Stage 2: Graph expansion for keyword results (Phase 1)
658
+ if (results.length > 0) {
659
+ const graphEnhanced = await expandWithGraph(results);
660
+ results = graphEnhanced;
661
+ searchMethod = 'keyword+graph';
662
+ }
663
+ }
664
+
665
+ if (results.length === 0) {
666
+ if (format === 'markdown') {
667
+ const wordCount = userQuestion.split(/\s+/).length;
668
+ if (wordCount < 3) {
669
+ return `💡 Hint: Please be more specific.\nExample: "Why did we choose COMPLEX mesh structure?" or "What parameters are used for large layers?"`;
670
+ }
671
+ }
672
+ return null;
673
+ }
674
+
675
+ // 5. Optional: LLM re-ranking (only if requested)
676
+ if (useReranking) {
677
+ results = await rerankWithLLM(userQuestion, results);
678
+ }
679
+
680
+ // Slice to limit
681
+ const finalResults = results.slice(0, limit);
682
+
683
+ // Markdown format (for human display)
684
+ if (format === 'markdown') {
685
+ const { formatContext } = require('./decision-formatter');
686
+ const context = formatContext(finalResults, { maxTokens: 500 });
687
+
688
+ // Add graph expansion summary if applicable
689
+ let graphSummary = '';
690
+ if (searchMethod.includes('graph')) {
691
+ const primaryCount = finalResults.filter((r) => r.graph_source === 'primary').length;
692
+ const expandedCount = finalResults.filter((r) => r.graph_source !== 'primary').length;
693
+
694
+ graphSummary = `\n📊 Graph expansion: ${primaryCount} primary + ${expandedCount} related (supersedes/refines/contradicts)\n`;
695
+ }
696
+
697
+ return `🔍 Search method: ${searchMethod}${graphSummary}\n${context}`;
698
+ }
699
+
700
+ // Calculate graph expansion stats
701
+ const graphStats = {
702
+ total_results: finalResults.length,
703
+ primary_count: finalResults.filter((r) => r.graph_source === 'primary').length,
704
+ expanded_count: finalResults.filter((r) => r.graph_source !== 'primary').length,
705
+ sources: {
706
+ primary: finalResults.filter((r) => r.graph_source === 'primary').length,
707
+ supersedes_chain: finalResults.filter((r) => r.graph_source === 'supersedes_chain').length,
708
+ refines: finalResults.filter((r) => r.graph_source === 'refines').length,
709
+ refined_by: finalResults.filter((r) => r.graph_source === 'refined_by').length,
710
+ contradicts: finalResults.filter((r) => r.graph_source === 'contradicts').length,
711
+ },
712
+ };
713
+
714
+ // JSON format (default - LLM-first)
715
+ return {
716
+ query: userQuestion,
717
+ results: finalResults.map((r) => ({
718
+ id: r.id,
719
+ topic: r.topic,
720
+ decision: r.decision,
721
+ reasoning: r.reasoning,
722
+ confidence: r.confidence,
723
+ similarity: r.similarity,
724
+ created_at: r.created_at,
725
+ // Recency metadata (NEW - Gaussian Decay)
726
+ recency_score: r.recency_score,
727
+ recency_age_days: r.recency_age_days,
728
+ final_score: r.final_score || r.similarity, // Falls back to similarity if no recency
729
+ // Graph metadata (NEW - Phase 1)
730
+ graph_source: r.graph_source || 'primary',
731
+ graph_rank: r.graph_rank || 1.0,
732
+ related_to: r.related_to || null,
733
+ edge_reason: r.edge_reason || null,
734
+ })),
735
+ meta: {
736
+ count: finalResults.length,
737
+ search_method: searchMethod,
738
+ threshold: threshold || 'adaptive',
739
+ // Recency boosting config (NEW - Gaussian Decay)
740
+ recency_boost: disableRecency
741
+ ? null
742
+ : {
743
+ weight: recencyWeight,
744
+ scale: recencyScale,
745
+ decay: recencyDecay,
746
+ },
747
+ // Graph expansion stats (NEW - Phase 1)
748
+ graph_expansion: searchMethod.includes('graph') ? graphStats : null,
749
+ },
750
+ };
751
+ } catch (error) {
752
+ // Graceful degradation
753
+ console.warn(`mama.suggest() failed: ${error.message}`);
754
+ return null;
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Re-rank search results using local LLM (optional enhancement)
760
+ *
761
+ * @param {string} userQuestion - User's question
762
+ * @param {Array} results - Vector search results
763
+ * @returns {Promise<Array>} Re-ranked results
764
+ */
765
+ async function rerankWithLLM(userQuestion, results) {
766
+ try {
767
+ const { generate } = require('./ollama-client');
768
+
769
+ const prompt = `User asked: "${userQuestion}"
770
+
771
+ Found decisions (ranked by vector similarity):
772
+ ${results.map((r, i) => `${i + 1}. [${r.similarity.toFixed(3)}] ${r.topic}: ${r.decision.substring(0, 60)}...`).join('\n')}
773
+
774
+ Re-rank these by actual relevance to the user's intent (not just keyword similarity).
775
+ Return JSON: { "ranking": [index1, index2, ...] } (0-based indices)
776
+
777
+ Example: { "ranking": [2, 0, 4, 1, 3] } means 3rd is most relevant, then 1st, then 5th...`;
778
+
779
+ const response = await generate(prompt, {
780
+ format: 'json',
781
+ temperature: 0.3,
782
+ max_tokens: 100,
783
+ timeout: 3000,
784
+ });
785
+
786
+ const parsed = typeof response === 'string' ? JSON.parse(response) : response;
787
+
788
+ // Reorder results based on LLM ranking
789
+ return parsed.ranking.map((idx) => results[idx]).filter(Boolean);
790
+ } catch (error) {
791
+ console.warn(`Re-ranking failed: ${error.message}, using vector ranking`);
792
+ return results; // Fallback to vector ranking
793
+ }
794
+ }
795
+
796
+ /**
797
+ * List recent decisions (all topics, chronological)
798
+ *
799
+ * DEFAULT: Returns JSON array with recent decisions (LLM-first design)
800
+ * OPTIONAL: Returns Markdown string if format='markdown' (for human display)
801
+ *
802
+ * @param {Object} [options] - Options
803
+ * @param {number} [options.limit=10] - Max results
804
+ * @param {string} [options.format='json'] - Output format
805
+ * @returns {Promise<Array|string>} Recent decisions
806
+ */
807
+ async function listDecisions(options = {}) {
808
+ const { limit = 10, format = 'json' } = options;
809
+
810
+ try {
811
+ const adapter = getAdapter();
812
+ const stmt = adapter.prepare(`
813
+ SELECT * FROM decisions
814
+ WHERE superseded_by IS NULL
815
+ ORDER BY created_at DESC
816
+ LIMIT ?
817
+ `);
818
+ const decisions = await stmt.all(limit);
819
+
820
+ if (format === 'markdown') {
821
+ return formatList(decisions);
822
+ }
823
+
824
+ return decisions;
825
+ } catch (error) {
826
+ throw new Error(`mama.listDecisions() failed: ${error.message}`);
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Save current session checkpoint (New Feature: Session Continuity)
832
+ *
833
+ * @param {string} summary - Summary of current session state
834
+ * @param {Array<string>} openFiles - List of currently open files
835
+ * @param {string} nextSteps - Next steps to be taken
836
+ * @returns {Promise<number>} Checkpoint ID
837
+ */
838
+ async function saveCheckpoint(summary, openFiles = [], nextSteps = '') {
839
+ if (!summary) throw new Error('Summary is required for checkpoint');
840
+
841
+ try {
842
+ const adapter = getAdapter();
843
+ const stmt = adapter.prepare(`
844
+ INSERT INTO checkpoints (timestamp, summary, open_files, next_steps, status)
845
+ VALUES (?, ?, ?, ?, 'active')
846
+ `);
847
+
848
+ const result = stmt.run(
849
+ Date.now(),
850
+ summary,
851
+ JSON.stringify(openFiles),
852
+ nextSteps
853
+ );
854
+
855
+ return result.lastInsertRowid;
856
+ } catch (error) {
857
+ throw new Error(`Failed to save checkpoint: ${error.message}`);
858
+ }
859
+ }
860
+
861
+ /**
862
+ * Load latest active checkpoint (New Feature: Session Continuity)
863
+ *
864
+ * @returns {Promise<Object|null>} Latest checkpoint or null
865
+ */
866
+ async function loadCheckpoint() {
867
+ try {
868
+ const adapter = getAdapter();
869
+ const stmt = adapter.prepare(`
870
+ SELECT * FROM checkpoints
871
+ WHERE status = 'active'
872
+ ORDER BY timestamp DESC
873
+ LIMIT 1
874
+ `);
875
+
876
+ const checkpoint = stmt.get();
877
+
878
+ if (checkpoint) {
879
+ try {
880
+ checkpoint.open_files = JSON.parse(checkpoint.open_files);
881
+ } catch (e) {
882
+ checkpoint.open_files = [];
883
+ }
884
+ }
885
+
886
+ return checkpoint || null;
887
+ } catch (error) {
888
+ throw new Error(`Failed to load checkpoint: ${error.message}`);
889
+ }
890
+ }
891
+ /**
892
+ * MAMA Public API
893
+ *
894
+ * Simple, clean interface for Claude to interact with MAMA
895
+ * Hides complex implementation details (embeddings, vector search, graph queries)
896
+ *
897
+ * Key Principles:
898
+ * 1. Simple API First - No complex configuration
899
+ * 2. Transparent Process - Each step is visible
900
+ * 3. Claude-First Design - Claude decides what to save
901
+ * 4. Non-Intrusive - Silent failures for helpers (suggest)
902
+ */
903
+ const mama = {
904
+ save,
905
+ recall,
906
+ updateOutcome,
907
+ suggest,
908
+ list: listDecisions,
909
+ saveCheckpoint,
910
+ loadCheckpoint,
911
+ };
912
+
913
+ module.exports = mama;