@jungjaehoon/mama-core 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mama-api.js DELETED
@@ -1,2614 +0,0 @@
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
- * v1.3 Update: Collaborative Reasoning Graph
12
- * - Auto-search on save: Find similar decisions before saving
13
- * - Collaborative invitation: Suggest build-on/debate/synthesize
14
- * - AX-first: Soft warnings, not hard blocks
15
- *
16
- * @module mama-api
17
- * @version 1.3
18
- * @date 2025-11-26
19
- */
20
-
21
- // Session-level warning cooldown cache (Story 1.1, 1.2)
22
- // Prevents spam by tracking warned topics per session
23
- const warnedTopicsCache = new Map();
24
- const WARNING_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
25
-
26
- const { learnDecision } = require('./decision-tracker');
27
- // eslint-disable-next-line no-unused-vars
28
- const { injectDecisionContext } = require('./memory-inject');
29
- // eslint-disable-next-line no-unused-vars
30
- const { queryDecisionGraph, querySemanticEdges, getDB, getAdapter } = require('./memory-store');
31
- const { formatRecall, formatList } = require('./decision-formatter');
32
- const { logProgress, logComplete, logSearching } = require('./progress-indicator');
33
- const os = require('os');
34
-
35
- /**
36
- * Save a decision or insight to MAMA's memory
37
- *
38
- * Simple API for Claude to save insights without complex configuration
39
- * AC #1: Simple API - no complex configuration required
40
- *
41
- * @param {Object} params - Decision parameters
42
- * @param {string} params.topic - Decision topic (e.g., 'auth_strategy', 'date_format')
43
- * @param {string} params.decision - The decision made (e.g., 'JWT', 'ISO 8601 + Unix')
44
- * @param {string} params.reasoning - Why this decision was made
45
- * @param {number} [params.confidence=0.5] - Confidence score 0.0-1.0 (optional)
46
- * @param {string} [params.type='user_decision'] - 'user_decision' or 'assistant_insight' (optional)
47
- * @param {string} [params.outcome='pending'] - 'pending', 'success', 'failure', 'partial', 'superseded' (optional)
48
- * @param {string} [params.failure_reason] - Why this decision failed (optional, used with outcome='failure')
49
- * @param {string} [params.limitation] - Known limitations of this decision (optional)
50
- * @returns {Promise<{success: boolean, id: string, similar_decisions?: Array, warning?: string, collaboration_hint?: string, reasoning_graph?: Object}>} Save result with decision ID and metadata
51
- *
52
- * @example
53
- * const decisionId = await mama.save({
54
- * topic: 'date_calculation_format',
55
- * decision: 'Support both ISO 8601 and Unix timestamp formats',
56
- * reasoning: 'Bootstrap data stored as ISO 8601 causing NaN errors',
57
- * confidence: 0.95,
58
- * type: 'assistant_insight',
59
- * outcome: 'success'
60
- * });
61
- */
62
- async function save({
63
- topic,
64
- decision,
65
- reasoning,
66
- confidence = 0.5,
67
- type = 'user_decision',
68
- outcome = 'pending',
69
- failure_reason = null,
70
- limitation = null,
71
- trust_context = null,
72
- }) {
73
- // Validate required fields
74
- if (!topic || typeof topic !== 'string') {
75
- throw new Error('mama.save() requires topic (string)');
76
- }
77
- if (!decision || typeof decision !== 'string') {
78
- throw new Error('mama.save() requires decision (string)');
79
- }
80
- if (!reasoning || typeof reasoning !== 'string') {
81
- throw new Error('mama.save() requires reasoning (string)');
82
- }
83
-
84
- // Validate confidence range
85
- if (typeof confidence !== 'number' || confidence < 0 || confidence > 1) {
86
- throw new Error('mama.save() confidence must be a number between 0.0 and 1.0');
87
- }
88
-
89
- // Validate type
90
- if (type !== 'user_decision' && type !== 'assistant_insight') {
91
- throw new Error('mama.save() type must be "user_decision" or "assistant_insight"');
92
- }
93
-
94
- // Validate outcome
95
- const validOutcomes = ['pending', 'success', 'failure', 'partial', 'superseded'];
96
- if (outcome && !validOutcomes.includes(outcome)) {
97
- throw new Error(
98
- `mama.save() outcome must be one of: ${validOutcomes.join(', ')} (got: ${outcome})`
99
- );
100
- }
101
-
102
- // Map type to user_involvement field
103
- // Note: Current schema uses user_involvement ('requested', 'approved', 'rejected')
104
- // Future: Will use decision_type column for proper distinction
105
- // eslint-disable-next-line no-unused-vars
106
- const userInvolvement = type === 'user_decision' ? 'approved' : null;
107
-
108
- // Create detection object for learnDecision()
109
- const detection = {
110
- topic,
111
- decision,
112
- reasoning,
113
- confidence,
114
- outcome,
115
- failure_reason,
116
- limitation,
117
- trust_context,
118
- };
119
-
120
- // Create tool execution context
121
- // Use current timestamp and generate session ID
122
- const sessionId = `mama_api_${Date.now()}`;
123
- const toolExecution = {
124
- tool_name: 'mama.save',
125
- tool_input: { topic, decision },
126
- exit_code: 0,
127
- session_id: sessionId,
128
- timestamp: Date.now(),
129
- };
130
-
131
- // Create session context
132
- const sessionContext = {
133
- session_id: sessionId,
134
- latest_user_message: `Save ${type}: ${topic}`,
135
- recent_exchange: `Claude: ${reasoning.substring(0, 100)}...`,
136
- };
137
-
138
- // Call internal learnDecision function
139
- // Note: learnDecision returns { decisionId, notification }
140
- logProgress(`Saving decision: ${topic.substring(0, 30)}...`);
141
- const { decisionId } = await learnDecision(detection, toolExecution, sessionContext);
142
- logComplete(`Decision saved: ${decisionId.substring(0, 20)}...`);
143
-
144
- // Update user_involvement, outcome, failure_reason, limitation
145
- // Note: learnDecision always sets 'requested', we need to override it
146
- const adapter = getAdapter();
147
-
148
- // Build UPDATE query dynamically based on what fields are provided
149
- const updates = [];
150
- const values = [];
151
-
152
- // user_involvement based on type
153
- if (type === 'assistant_insight') {
154
- updates.push('user_involvement = NULL');
155
- } else if (type === 'user_decision') {
156
- updates.push('user_involvement = ?');
157
- values.push('approved');
158
- }
159
-
160
- // outcome (always set, default is 'pending')
161
- // Story M4.1 fix: Map to DB format (uppercase, pending → NULL)
162
- if (outcome) {
163
- const outcomeMap = {
164
- pending: null,
165
- success: 'SUCCESS',
166
- failure: 'FAILED',
167
- partial: 'PARTIAL',
168
- superseded: null,
169
- };
170
- const dbOutcome = outcomeMap[outcome] !== undefined ? outcomeMap[outcome] : outcome;
171
-
172
- updates.push('outcome = ?');
173
- values.push(dbOutcome);
174
- }
175
-
176
- // failure_reason (optional)
177
- if (failure_reason) {
178
- updates.push('failure_reason = ?');
179
- values.push(failure_reason);
180
- }
181
-
182
- // limitation (optional)
183
- if (limitation) {
184
- updates.push('limitation = ?');
185
- values.push(limitation);
186
- }
187
-
188
- // Execute UPDATE if we have any fields to update
189
- if (updates.length > 0) {
190
- values.push(decisionId); // WHERE id = ?
191
- const stmt = adapter.prepare(`
192
- UPDATE decisions
193
- SET ${updates.join(', ')}
194
- WHERE id = ?
195
- `);
196
- await stmt.run(...values);
197
- }
198
-
199
- // ════════════════════════════════════════════════════════════════════════════
200
- // Story 1.1: Auto-Search on Save
201
- // Story 1.2: Response Enhancement
202
- // ════════════════════════════════════════════════════════════════════════════
203
- let similar_decisions = [];
204
- let warning = null;
205
- let collaboration_hint = null;
206
- let reasoning_graph = null;
207
-
208
- // Only run auto-search for decisions (not checkpoints) with a topic
209
- if (topic) {
210
- try {
211
- // Story 1.1: Auto-search using suggest()
212
- logSearching('Searching for related decisions...');
213
- const searchResults = await suggest(topic, {
214
- limit: 3,
215
- threshold: 0.7,
216
- disableRecency: true, // Pure semantic similarity for comparison
217
- });
218
-
219
- if (searchResults && searchResults.results) {
220
- // Filter out the decision we just saved
221
- similar_decisions = searchResults.results
222
- .filter((d) => d.id !== decisionId)
223
- .map((d) => ({
224
- id: d.id,
225
- topic: d.topic,
226
- decision: d.decision,
227
- similarity: d.similarity,
228
- created_at: d.created_at,
229
- }));
230
-
231
- if (similar_decisions.length > 0) {
232
- logComplete(`Found ${similar_decisions.length} related decision(s)`);
233
- }
234
-
235
- // Story 1.2: Warning logic (similarity >= 0.85)
236
- const highSimilarity = similar_decisions.find((d) => d.similarity >= 0.85);
237
- if (highSimilarity && !_isTopicInCooldown(topic)) {
238
- warning = `High similarity (${(highSimilarity.similarity * 100).toFixed(0)}%) with existing decision "${highSimilarity.decision.substring(0, 50)}..."`;
239
- _markTopicWarned(topic);
240
- }
241
-
242
- // Story 1.2: Collaboration hint
243
- if (similar_decisions.length > 0) {
244
- collaboration_hint = _generateCollaborationHint(similar_decisions);
245
- }
246
- }
247
- } catch (error) {
248
- // Story 1.1 AC3: Best-effort - save succeeds even if auto-search fails
249
- console.error('Auto-search failed:', error.message);
250
- }
251
-
252
- // Story 1.2: Reasoning graph info
253
- try {
254
- reasoning_graph = await _getReasoningGraphInfo(topic, decisionId);
255
- } catch (error) {
256
- console.error('Reasoning graph query failed:', error.message);
257
- }
258
-
259
- // Story 2.2: Parse reasoning for relationship edges (builds_on, debates, synthesizes)
260
- if (reasoning) {
261
- try {
262
- const { createEdgesFromReasoning } = require('./decision-tracker.js');
263
- await createEdgesFromReasoning(decisionId, reasoning);
264
- } catch (error) {
265
- // Best-effort - save succeeds even if edge creation fails
266
- console.error('Edge creation from reasoning failed:', error.message);
267
- }
268
- }
269
- }
270
-
271
- // Story 1.2: Enhanced response (backward compatible)
272
- return {
273
- success: true,
274
- id: decisionId,
275
- ...(similar_decisions.length > 0 && { similar_decisions }),
276
- ...(warning && { warning }),
277
- ...(collaboration_hint && { collaboration_hint }),
278
- ...(reasoning_graph && { reasoning_graph }),
279
- };
280
- }
281
-
282
- // ════════════════════════════════════════════════════════════════════════════
283
- // Story 1.2: Helper functions for Response Enhancement
284
- // ════════════════════════════════════════════════════════════════════════════
285
-
286
- /**
287
- * Check if a topic is in warning cooldown
288
- * @param {string} topic - Topic to check
289
- * @returns {boolean} True if topic was warned recently
290
- */
291
- function _isTopicInCooldown(topic) {
292
- const lastWarned = warnedTopicsCache.get(topic);
293
- if (!lastWarned) {
294
- return false;
295
- }
296
- return Date.now() - lastWarned < WARNING_COOLDOWN_MS;
297
- }
298
-
299
- /**
300
- * Mark a topic as warned (start cooldown)
301
- * @param {string} topic - Topic to mark
302
- */
303
- function _markTopicWarned(topic) {
304
- warnedTopicsCache.set(topic, Date.now());
305
- }
306
-
307
- /**
308
- * Generate collaboration hint message
309
- * @param {Array} similarDecisions - Similar decisions found
310
- * @returns {string} Collaboration hint message
311
- */
312
- function _generateCollaborationHint(similarDecisions) {
313
- const count = similarDecisions.length;
314
- if (count === 0) {
315
- return null;
316
- }
317
-
318
- return `Found ${count} related decision(s). Consider:
319
- - SUPERSEDE: Same topic replaces prior (automatic)
320
- - BUILD-ON: Add "builds_on: <id>" in reasoning to extend
321
- - DEBATE: Add "debates: <id>" in reasoning for alternative view
322
- - SYNTHESIZE: Add "synthesizes: [id1, id2]" in reasoning to unify`;
323
- }
324
-
325
- /**
326
- * Get reasoning graph info for a topic
327
- * @param {string} topic - Topic to query
328
- * @param {string} currentId - Current decision ID
329
- * @returns {Object} Reasoning graph info
330
- */
331
- async function _getReasoningGraphInfo(topic, currentId) {
332
- try {
333
- const { queryDecisionGraph } = require('./memory-store');
334
- const chain = await queryDecisionGraph(topic);
335
-
336
- if (!chain || chain.length === 0) {
337
- return {
338
- topic,
339
- depth: 1,
340
- latest: currentId,
341
- };
342
- }
343
-
344
- return {
345
- topic,
346
- depth: chain.length,
347
- latest: chain[0]?.id || currentId,
348
- };
349
- } catch (error) {
350
- return {
351
- topic,
352
- depth: 1,
353
- latest: currentId,
354
- };
355
- }
356
- }
357
-
358
- /**
359
- * Recall decisions by topic
360
- *
361
- * DEFAULT: Returns JSON object with decisions and edges (LLM-first design)
362
- * OPTIONAL: Returns Markdown string if format='markdown' (for human display)
363
- *
364
- * @param {string} topic - Decision topic to recall
365
- * @param {Object} [options] - Options
366
- * @param {string} [options.format='json'] - Output format: 'json' (default) or 'markdown'
367
- * @returns {Promise<Object|string>} Decision history as JSON or Markdown
368
- *
369
- * @example
370
- * // LLM usage (default)
371
- * const data = await mama.recall('auth_strategy');
372
- * // → { topic, decisions: [...], edges: [...], meta: {...} }
373
- *
374
- * // Human display
375
- * const markdown = await mama.recall('auth_strategy', { format: 'markdown' });
376
- * // → "📋 Decision History: auth_strategy\n━━━━━━━━..."
377
- */
378
- async function recall(topic, options = {}) {
379
- if (!topic || typeof topic !== 'string') {
380
- throw new Error('mama.recall() requires topic (string)');
381
- }
382
-
383
- const { format = 'json' } = options;
384
-
385
- try {
386
- const decisions = await queryDecisionGraph(topic);
387
-
388
- if (!decisions || decisions.length === 0) {
389
- if (format === 'markdown') {
390
- return `❌ No decisions found for topic: ${topic}`;
391
- }
392
- return {
393
- topic,
394
- supersedes_chain: [],
395
- semantic_edges: { refines: [], refined_by: [], contradicts: [], contradicted_by: [] },
396
- meta: { count: 0 },
397
- };
398
- }
399
-
400
- // Query semantic edges for all decisions
401
- const decisionIds = decisions.map((d) => d.id);
402
- const rawEdges = (await querySemanticEdges(decisionIds)) || {};
403
- const semanticEdges = {
404
- refines: rawEdges.refines || [],
405
- refined_by: rawEdges.refined_by || [],
406
- contradicts: rawEdges.contradicts || [],
407
- contradicted_by: rawEdges.contradicted_by || [],
408
- };
409
-
410
- // Markdown format (for human display)
411
- if (format === 'markdown') {
412
- // Pass semantic edges to formatter
413
- return formatRecall(decisions, semanticEdges);
414
- }
415
-
416
- // JSON format (default - LLM-first)
417
- // Separate supersedes chain from semantic edges
418
- return {
419
- topic,
420
- supersedes_chain: decisions.map((d) => ({
421
- id: d.id,
422
- decision: d.decision,
423
- reasoning: d.reasoning,
424
- confidence: d.confidence,
425
- outcome: d.outcome,
426
- failure_reason: d.failure_reason,
427
- limitation: d.limitation,
428
- created_at: d.created_at,
429
- updated_at: d.updated_at,
430
- superseded_by: d.superseded_by,
431
- supersedes: d.supersedes,
432
- trust_context: d.trust_context,
433
- })),
434
- semantic_edges: {
435
- refines: semanticEdges.refines.map((e) => ({
436
- to_topic: e.topic,
437
- to_decision: e.decision,
438
- to_id: e.to_id,
439
- reason: e.reason,
440
- confidence: e.confidence,
441
- created_at: e.created_at,
442
- })),
443
- refined_by: semanticEdges.refined_by.map((e) => ({
444
- from_topic: e.topic,
445
- from_decision: e.decision,
446
- from_id: e.from_id,
447
- reason: e.reason,
448
- confidence: e.confidence,
449
- created_at: e.created_at,
450
- })),
451
- contradicts: semanticEdges.contradicts.map((e) => ({
452
- to_topic: e.topic,
453
- to_decision: e.decision,
454
- to_id: e.to_id,
455
- reason: e.reason,
456
- created_at: e.created_at,
457
- })),
458
- contradicted_by: semanticEdges.contradicted_by.map((e) => ({
459
- from_topic: e.topic,
460
- from_decision: e.decision,
461
- from_id: e.from_id,
462
- reason: e.reason,
463
- created_at: e.created_at,
464
- })),
465
- },
466
- meta: {
467
- count: decisions.length,
468
- latest_id: decisions[0]?.id,
469
- has_supersedes_chain: decisions.some((d) => d.supersedes),
470
- has_semantic_edges:
471
- semanticEdges.refines.length > 0 ||
472
- semanticEdges.refined_by.length > 0 ||
473
- semanticEdges.contradicts.length > 0 ||
474
- semanticEdges.contradicted_by.length > 0,
475
- semantic_edges_count: {
476
- refines: semanticEdges.refines.length,
477
- refined_by: semanticEdges.refined_by.length,
478
- contradicts: semanticEdges.contradicts.length,
479
- contradicted_by: semanticEdges.contradicted_by.length,
480
- },
481
- },
482
- };
483
- } catch (error) {
484
- throw new Error(`mama.recall() failed: ${error.message}`);
485
- }
486
- }
487
-
488
- /**
489
- * Update outcome of a decision
490
- *
491
- * Track whether a decision succeeded, failed, or partially worked
492
- * AC: Evolutionary Decision Memory - Learn from outcomes
493
- *
494
- * @param {string} decisionId - Decision ID to update
495
- * @param {Object} outcome - Outcome details
496
- * @param {string} outcome.outcome - 'SUCCESS', 'FAILED', or 'PARTIAL'
497
- * @param {string} [outcome.failure_reason] - Reason for failure (if FAILED)
498
- * @param {string} [outcome.limitation] - Limitation description (if PARTIAL)
499
- * @returns {Promise<void>}
500
- *
501
- * @example
502
- * await mama.updateOutcome('decision_auth_strategy_123456_abc', {
503
- * outcome: 'FAILED',
504
- * failure_reason: 'Missing token expiration handling'
505
- * });
506
- */
507
- async function updateOutcome(decisionId, { outcome, failure_reason, limitation }) {
508
- if (!decisionId || typeof decisionId !== 'string') {
509
- throw new Error('mama.updateOutcome() requires decisionId (string)');
510
- }
511
-
512
- // AX Improvement: Be forgiving with case sensitivity
513
- const normalizedOutcome = outcome ? outcome.toUpperCase() : null;
514
-
515
- if (!normalizedOutcome || !['SUCCESS', 'FAILED', 'PARTIAL'].includes(normalizedOutcome)) {
516
- throw new Error('mama.updateOutcome() outcome must be "SUCCESS", "FAILED", or "PARTIAL"');
517
- }
518
-
519
- try {
520
- const adapter = getAdapter();
521
-
522
- // Update outcome and related fields
523
- const stmt = adapter.prepare(
524
- `
525
- UPDATE decisions
526
- SET
527
- outcome = ?,
528
- failure_reason = ?,
529
- limitation = ?,
530
- updated_at = ?
531
- WHERE id = ?
532
- `
533
- );
534
- const result = stmt.run(
535
- normalizedOutcome,
536
- failure_reason || null,
537
- limitation || null,
538
- Date.now(),
539
- decisionId
540
- );
541
-
542
- // Check if decision was found and updated
543
- if (result.changes === 0) {
544
- throw new Error(`Decision not found: ${decisionId}`);
545
- }
546
-
547
- return;
548
- } catch (error) {
549
- throw new Error(`mama.updateOutcome() failed: ${error.message}`);
550
- }
551
- }
552
-
553
- /**
554
- * Expand search results with graph context (Phase 1 - Graph-Enhanced Retrieval)
555
- *
556
- * For each candidate decision:
557
- * 1. Add supersedes chain (evolution history)
558
- * 2. Add semantic edges (refines, contradicts)
559
- * 3. Deduplicate by ID
560
- * 4. Re-rank by relevance (primary candidates ranked higher)
561
- *
562
- * @param {Array} candidates - Initial search results from vector/keyword search
563
- * @returns {Promise<Array>} Graph-enhanced results with evolution context
564
- */
565
- async function expandWithGraph(candidates) {
566
- const graphEnhanced = new Map(); // Use Map for deduplication by ID
567
- const primaryIds = new Set(candidates.map((c) => c.id)); // Track primary candidates
568
-
569
- // Process each candidate
570
- for (const candidate of candidates) {
571
- // Add primary candidate with higher rank
572
- if (!graphEnhanced.has(candidate.id)) {
573
- graphEnhanced.set(candidate.id, {
574
- ...candidate,
575
- graph_source: 'primary', // Mark as primary result
576
- graph_rank: 1.0, // Highest rank
577
- });
578
- }
579
-
580
- // 1. Add supersedes chain (evolution history)
581
- try {
582
- const chain = await queryDecisionGraph(candidate.topic);
583
- for (const decision of chain) {
584
- if (!graphEnhanced.has(decision.id)) {
585
- graphEnhanced.set(decision.id, {
586
- ...decision,
587
- graph_source: 'supersedes_chain',
588
- graph_rank: 0.8, // Lower rank than primary
589
- similarity: candidate.similarity * 0.9, // Inherit similarity, slightly reduced
590
- related_to: candidate.id, // Track relationship
591
- });
592
- }
593
- }
594
- } catch (error) {
595
- console.warn(`Failed to get supersedes chain for ${candidate.topic}: ${error.message}`);
596
- }
597
-
598
- // 2. Add semantic edges (refines, contradicts, builds_on, debates, synthesizes)
599
- try {
600
- const rawEdges = (await querySemanticEdges([candidate.id])) || {};
601
- const edges = {
602
- refines: rawEdges.refines || [],
603
- refined_by: rawEdges.refined_by || [],
604
- contradicts: rawEdges.contradicts || [],
605
- contradicted_by: rawEdges.contradicted_by || [],
606
- builds_on: rawEdges.builds_on || [],
607
- built_upon_by: rawEdges.built_upon_by || [],
608
- debates: rawEdges.debates || [],
609
- debated_by: rawEdges.debated_by || [],
610
- synthesizes: rawEdges.synthesizes || [],
611
- synthesized_by: rawEdges.synthesized_by || [],
612
- };
613
-
614
- // Helper to add edge to graph
615
- const addEdge = (edge, idField, source, rank, simFactor) => {
616
- const id = edge[idField];
617
- if (!graphEnhanced.has(id)) {
618
- graphEnhanced.set(id, {
619
- id: id,
620
- topic: edge.topic,
621
- decision: edge.decision,
622
- confidence: edge.confidence,
623
- created_at: edge.created_at,
624
- graph_source: source,
625
- graph_rank: rank,
626
- similarity: candidate.similarity * simFactor,
627
- related_to: candidate.id,
628
- edge_reason: edge.reason,
629
- });
630
- }
631
- };
632
-
633
- // Add refines edges
634
- for (const edge of edges.refines) {
635
- addEdge(edge, 'to_id', 'refines', 0.7, 0.85);
636
- }
637
-
638
- // Add refined_by edges
639
- for (const edge of edges.refined_by) {
640
- addEdge(edge, 'from_id', 'refined_by', 0.7, 0.85);
641
- }
642
-
643
- // Add contradicts edges (lower rank, but still relevant)
644
- for (const edge of edges.contradicts) {
645
- addEdge(edge, 'to_id', 'contradicts', 0.6, 0.8);
646
- }
647
-
648
- // Story 2.1: Add builds_on edges (high relevance - extending prior work)
649
- for (const edge of edges.builds_on) {
650
- addEdge(edge, 'to_id', 'builds_on', 0.75, 0.9);
651
- }
652
-
653
- // Add built_upon_by edges (someone built on this decision)
654
- for (const edge of edges.built_upon_by) {
655
- addEdge(edge, 'from_id', 'built_upon_by', 0.75, 0.9);
656
- }
657
-
658
- // Add debates edges (alternative view)
659
- for (const edge of edges.debates) {
660
- addEdge(edge, 'to_id', 'debates', 0.65, 0.85);
661
- }
662
-
663
- // Add debated_by edges
664
- for (const edge of edges.debated_by) {
665
- addEdge(edge, 'from_id', 'debated_by', 0.65, 0.85);
666
- }
667
-
668
- // Add synthesizes edges (unified approach)
669
- for (const edge of edges.synthesizes) {
670
- addEdge(edge, 'to_id', 'synthesizes', 0.7, 0.88);
671
- }
672
-
673
- // Add synthesized_by edges
674
- for (const edge of edges.synthesized_by) {
675
- addEdge(edge, 'from_id', 'synthesized_by', 0.7, 0.88);
676
- }
677
- } catch (error) {
678
- console.warn(`Failed to get semantic edges for ${candidate.id}: ${error.message}`);
679
- }
680
- }
681
-
682
- // 3. Convert Map to Array
683
- const allResults = Array.from(graphEnhanced.values());
684
-
685
- // 4. Sort: Interleave expanded results after their related primary
686
- // This ensures edge-connected decisions appear near their source
687
- const primaryResults = allResults
688
- .filter((r) => primaryIds.has(r.id))
689
- .sort((a, b) => {
690
- const scoreA = a.final_score || a.similarity || 0;
691
- const scoreB = b.final_score || b.similarity || 0;
692
- return scoreB - scoreA;
693
- });
694
-
695
- const expandedResults = allResults.filter((r) => !primaryIds.has(r.id));
696
-
697
- // Build final results: each primary followed by its related expanded results
698
- const results = [];
699
- for (const primary of primaryResults) {
700
- results.push(primary);
701
-
702
- // Find expanded results related to this primary
703
- const relatedExpanded = expandedResults.filter((e) => e.related_to === primary.id);
704
-
705
- // Sort related by graph_rank (higher first)
706
- relatedExpanded.sort((a, b) => (b.graph_rank || 0) - (a.graph_rank || 0));
707
-
708
- // Add related expanded results right after their primary
709
- results.push(...relatedExpanded);
710
- }
711
-
712
- // Add any orphaned expanded results (shouldn't happen, but safety net)
713
- const includedIds = new Set(results.map((r) => r.id));
714
- const orphaned = expandedResults.filter((e) => !includedIds.has(e.id));
715
- results.push(...orphaned);
716
-
717
- return results;
718
- }
719
-
720
- /**
721
- * Apply Gaussian Decay recency boosting (Elasticsearch-style)
722
- * Allows Claude to dynamically adjust search strategy based on results
723
- *
724
- * @param {Array} results - Search results with similarity scores
725
- * @param {Object} options - Recency boosting options
726
- * @returns {Array} Results with recency-boosted final scores
727
- */
728
- function applyRecencyBoost(results, options = {}) {
729
- const {
730
- recencyWeight = 0.3,
731
- recencyScale = 7,
732
- recencyDecay = 0.5,
733
- disableRecency = false,
734
- } = options;
735
-
736
- if (disableRecency || recencyWeight === 0) {
737
- return results;
738
- }
739
-
740
- const now = Date.now(); // Current timestamp in milliseconds
741
-
742
- return results
743
- .map((r) => {
744
- // created_at is stored in milliseconds in the database
745
- const ageInDays = (now - r.created_at) / (86400 * 1000);
746
-
747
- // Gaussian Decay: exp(-((age / scale)^2) / (2 * ln(1 / decay)))
748
- // At scale days: score = decay (e.g., 7 days = 50%)
749
- const gaussianDecay = Math.exp(
750
- -Math.pow(ageInDays / recencyScale, 2) / (2 * Math.log(1 / recencyDecay))
751
- );
752
-
753
- // Combine semantic similarity with recency
754
- const finalScore = r.similarity * (1 - recencyWeight) + gaussianDecay * recencyWeight;
755
-
756
- return {
757
- ...r,
758
- recency_score: gaussianDecay,
759
- recency_age_days: Math.round(ageInDays * 10) / 10,
760
- final_score: finalScore,
761
- };
762
- })
763
- .sort((a, b) => b.final_score - a.final_score);
764
- }
765
-
766
- /**
767
- * Suggest relevant decisions based on user question
768
- *
769
- * DEFAULT: Returns JSON object with search results (LLM-first design)
770
- * OPTIONAL: Returns Markdown string if format='markdown' (for human display)
771
- *
772
- * Simplified: Direct vector search without LLM intent analysis
773
- * Works with short queries, long questions, Korean/English
774
- *
775
- * @param {string} userQuestion - User's question or intent
776
- * @param {Object} options - Search options
777
- * @param {string} [options.format='json'] - Output format: 'json' (default) or 'markdown'
778
- * @param {number} [options.limit=5] - Max results to return
779
- * @param {number} [options.threshold=0.6] - Minimum similarity (adaptive by query length)
780
- * @param {boolean} [options.useReranking=false] - Use LLM re-ranking (optional, slower)
781
- * @returns {Promise<Object|string|null>} Search results as JSON or Markdown, null if no results
782
- *
783
- * @example
784
- * // LLM usage (default)
785
- * const data = await mama.suggest('Why did we choose JWT?');
786
- * // → { query, results: [...], meta: {...} }
787
- *
788
- * // Human display
789
- * const markdown = await mama.suggest('mesh optimization', { format: 'markdown' });
790
- * // → "💡 MAMA found 3 related topics:\n1. ..."
791
- */
792
- async function suggest(userQuestion, options = {}) {
793
- if (!userQuestion || typeof userQuestion !== 'string') {
794
- throw new Error('mama.suggest() requires userQuestion (string)');
795
- }
796
-
797
- const {
798
- format = 'json',
799
- limit = 5,
800
- threshold,
801
- useReranking = false,
802
- // Recency boosting parameters (Gaussian Decay - Elasticsearch style)
803
- recencyWeight = 0.3, // 0-1: How much to weight recency (0.3 = 70% semantic, 30% recency)
804
- recencyScale = 7, // Days until recency score drops to 50%
805
- recencyDecay = 0.5, // Score at scale point (0.5 = 50%)
806
- disableRecency = false, // Set true to disable recency boosting entirely
807
- } = options;
808
-
809
- try {
810
- // 1. Try vector search first (if sqlite-vss is available)
811
- // eslint-disable-next-line no-unused-vars
812
- const { getPreparedStmt, getDB } = require('./memory-store');
813
- let results = [];
814
- let searchMethod = 'vector';
815
-
816
- try {
817
- // Check if vectorSearch prepared statement exists
818
- getPreparedStmt('vectorSearch');
819
-
820
- // Generate query embedding
821
- const { generateEmbedding } = require('./embeddings');
822
- const queryEmbedding = await generateEmbedding(userQuestion);
823
-
824
- // Adaptive threshold (shorter queries need higher confidence)
825
- const wordCount = userQuestion.split(/\s+/).length;
826
- const adaptiveThreshold = threshold !== undefined ? threshold : wordCount < 3 ? 0.7 : 0.6;
827
-
828
- // Vector search
829
- const { vectorSearch } = require('./memory-store');
830
- results = await vectorSearch(queryEmbedding, limit * 2, 0.5); // Get more candidates
831
-
832
- // Filter by adaptive threshold
833
- results = results.filter((r) => r.similarity >= adaptiveThreshold);
834
-
835
- // Stage 1.5: Apply recency boosting (Gaussian Decay)
836
- // Allows Claude to adjust search strategy (recent vs historical)
837
- if (results.length > 0 && !disableRecency) {
838
- results = applyRecencyBoost(results, {
839
- recencyWeight,
840
- recencyScale,
841
- recencyDecay,
842
- disableRecency,
843
- });
844
- searchMethod = 'vector+recency';
845
- }
846
-
847
- // Stage 2: Graph expansion (NEW - Phase 1)
848
- // Expand candidates with supersedes chain and semantic edges
849
- if (results.length > 0) {
850
- const graphEnhanced = await expandWithGraph(results);
851
- results = graphEnhanced;
852
- searchMethod = disableRecency ? 'vector+graph' : 'vector+recency+graph';
853
- }
854
- } catch (vectorError) {
855
- // Fallback to keyword search if vector search unavailable
856
- console.warn(`Vector search failed: ${vectorError.message}, falling back to keyword search`);
857
- searchMethod = 'keyword';
858
-
859
- // Keyword search fallback
860
- const adapter = getAdapter();
861
- const keywords = userQuestion
862
- .toLowerCase()
863
- .split(/\s+/)
864
- .filter((w) => w.length > 2); // Filter short words
865
-
866
- if (keywords.length === 0) {
867
- if (format === 'markdown') {
868
- return `💡 Hint: Please be more specific.\nExample: "Railway Volume settings" or "mesh parameter optimization"`;
869
- }
870
- return null; // JSON mode returns null for empty/invalid queries
871
- }
872
-
873
- // Build LIKE query for each keyword
874
- const likeConditions = keywords.map(() => '(topic LIKE ? OR decision LIKE ?)').join(' OR ');
875
- const likeParams = keywords.flatMap((k) => [`%${k}%`, `%${k}%`]);
876
-
877
- const stmt = adapter.prepare(`
878
- SELECT * FROM decisions
879
- WHERE ${likeConditions}
880
- AND superseded_by IS NULL
881
- ORDER BY created_at DESC
882
- LIMIT ?
883
- `);
884
-
885
- const rows = await stmt.all(...likeParams, limit);
886
- results = rows.map((row) => ({
887
- ...row,
888
- similarity: 0.75, // Assign moderate similarity for keyword matches
889
- }));
890
-
891
- // Stage 2: Graph expansion for keyword results (Phase 1)
892
- if (results.length > 0) {
893
- const graphEnhanced = await expandWithGraph(results);
894
- results = graphEnhanced;
895
- searchMethod = 'keyword+graph';
896
- }
897
- }
898
-
899
- if (results.length === 0) {
900
- if (format === 'markdown') {
901
- const wordCount = userQuestion.split(/\s+/).length;
902
- if (wordCount < 3) {
903
- return `💡 Hint: Please be more specific.\nExample: "Why did we choose COMPLEX mesh structure?" or "What parameters are used for large layers?"`;
904
- }
905
- }
906
- return null;
907
- }
908
-
909
- // 5. Optional: LLM re-ranking (only if requested)
910
- if (useReranking) {
911
- results = await rerankWithLLM(userQuestion, results);
912
- }
913
-
914
- // Slice to limit
915
- const finalResults = results.slice(0, limit);
916
-
917
- // Markdown format (for human display)
918
- if (format === 'markdown') {
919
- const { formatContext } = require('./decision-formatter');
920
- const context = formatContext(finalResults, { maxTokens: 500 });
921
-
922
- // Add graph expansion summary if applicable
923
- let graphSummary = '';
924
- if (searchMethod.includes('graph')) {
925
- const primaryCount = finalResults.filter((r) => r.graph_source === 'primary').length;
926
- const expandedCount = finalResults.filter((r) => r.graph_source !== 'primary').length;
927
-
928
- graphSummary = `\n📊 Graph expansion: ${primaryCount} primary + ${expandedCount} related (supersedes/refines/contradicts)\n`;
929
- }
930
-
931
- return `🔍 Search method: ${searchMethod}${graphSummary}\n${context}`;
932
- }
933
-
934
- // Calculate graph expansion stats
935
- const graphStats = {
936
- total_results: finalResults.length,
937
- primary_count: finalResults.filter((r) => r.graph_source === 'primary').length,
938
- expanded_count: finalResults.filter((r) => r.graph_source !== 'primary').length,
939
- sources: {
940
- primary: finalResults.filter((r) => r.graph_source === 'primary').length,
941
- supersedes_chain: finalResults.filter((r) => r.graph_source === 'supersedes_chain').length,
942
- refines: finalResults.filter((r) => r.graph_source === 'refines').length,
943
- refined_by: finalResults.filter((r) => r.graph_source === 'refined_by').length,
944
- contradicts: finalResults.filter((r) => r.graph_source === 'contradicts').length,
945
- },
946
- };
947
-
948
- // JSON format (default - LLM-first)
949
- return {
950
- query: userQuestion,
951
- results: finalResults.map((r) => ({
952
- id: r.id,
953
- topic: r.topic,
954
- decision: r.decision,
955
- reasoning: r.reasoning,
956
- confidence: r.confidence,
957
- similarity: r.similarity,
958
- created_at: r.created_at,
959
- // Recency metadata (NEW - Gaussian Decay)
960
- recency_score: r.recency_score,
961
- recency_age_days: r.recency_age_days,
962
- final_score: r.final_score || r.similarity, // Falls back to similarity if no recency
963
- // Graph metadata (NEW - Phase 1)
964
- graph_source: r.graph_source || 'primary',
965
- graph_rank: r.graph_rank || 1.0,
966
- related_to: r.related_to || null,
967
- edge_reason: r.edge_reason || null,
968
- })),
969
- meta: {
970
- count: finalResults.length,
971
- search_method: searchMethod,
972
- threshold: threshold || 'adaptive',
973
- // Recency boosting config (NEW - Gaussian Decay)
974
- recency_boost: disableRecency
975
- ? null
976
- : {
977
- weight: recencyWeight,
978
- scale: recencyScale,
979
- decay: recencyDecay,
980
- },
981
- // Graph expansion stats (NEW - Phase 1)
982
- graph_expansion: searchMethod.includes('graph') ? graphStats : null,
983
- },
984
- };
985
- } catch (error) {
986
- // Graceful degradation
987
- console.warn(`mama.suggest() failed: ${error.message}`);
988
- return null;
989
- }
990
- }
991
-
992
- /**
993
- * Re-rank search results using local LLM (optional enhancement)
994
- *
995
- * @param {string} userQuestion - User's question
996
- * @param {Array} results - Vector search results
997
- * @returns {Promise<Array>} Re-ranked results
998
- */
999
- async function rerankWithLLM(userQuestion, results) {
1000
- try {
1001
- const { generate } = require('./ollama-client');
1002
-
1003
- const prompt = `User asked: "${userQuestion}"
1004
-
1005
- Found decisions (ranked by vector similarity):
1006
- ${results.map((r, i) => `${i + 1}. [${r.similarity.toFixed(3)}] ${r.topic}: ${r.decision.substring(0, 60)}...`).join('\n')}
1007
-
1008
- Re-rank these by actual relevance to the user's intent (not just keyword similarity).
1009
- Return JSON: { "ranking": [index1, index2, ...] } (0-based indices)
1010
-
1011
- Example: { "ranking": [2, 0, 4, 1, 3] } means 3rd is most relevant, then 1st, then 5th...`;
1012
-
1013
- const response = await generate(prompt, {
1014
- format: 'json',
1015
- temperature: 0.3,
1016
- max_tokens: 100,
1017
- timeout: 3000,
1018
- });
1019
-
1020
- const parsed = typeof response === 'string' ? JSON.parse(response) : response;
1021
-
1022
- // Reorder results based on LLM ranking
1023
- return parsed.ranking.map((idx) => results[idx]).filter(Boolean);
1024
- } catch (error) {
1025
- console.warn(`Re-ranking failed: ${error.message}, using vector ranking`);
1026
- return results; // Fallback to vector ranking
1027
- }
1028
- }
1029
-
1030
- /**
1031
- * List recent decisions (all topics, chronological)
1032
- *
1033
- * DEFAULT: Returns JSON array with recent decisions (LLM-first design)
1034
- * OPTIONAL: Returns Markdown string if format='markdown' (for human display)
1035
- *
1036
- * @param {Object} [options] - Options
1037
- * @param {number} [options.limit=10] - Max results
1038
- * @param {string} [options.format='json'] - Output format
1039
- * @returns {Promise<Array|string>} Recent decisions
1040
- */
1041
- async function listDecisions(options = {}) {
1042
- const { limit = 10, format = 'json' } = options;
1043
-
1044
- try {
1045
- const adapter = getAdapter();
1046
- const stmt = adapter.prepare(`
1047
- SELECT * FROM decisions
1048
- WHERE superseded_by IS NULL
1049
- ORDER BY created_at DESC
1050
- LIMIT ?
1051
- `);
1052
- const decisions = await stmt.all(limit);
1053
-
1054
- if (format === 'markdown') {
1055
- return formatList(decisions);
1056
- }
1057
-
1058
- return decisions;
1059
- } catch (error) {
1060
- throw new Error(`mama.listDecisions() failed: ${error.message}`);
1061
- }
1062
- }
1063
-
1064
- /**
1065
- * Save current session checkpoint (New Feature: Session Continuity)
1066
- *
1067
- * @param {string} summary - Summary of current session state
1068
- * @param {Array<string>} openFiles - List of currently open files
1069
- * @param {string} nextSteps - Next steps to be taken
1070
- * @returns {Promise<number>} Checkpoint ID
1071
- */
1072
- async function saveCheckpoint(summary, openFiles = [], nextSteps = '', recentConversation = []) {
1073
- if (!summary) {
1074
- throw new Error('Summary is required for checkpoint');
1075
- }
1076
-
1077
- try {
1078
- const adapter = getAdapter();
1079
- const stmt = adapter.prepare(`
1080
- INSERT INTO checkpoints (timestamp, summary, open_files, next_steps, recent_conversation, status)
1081
- VALUES (?, ?, ?, ?, ?, 'active')
1082
- `);
1083
-
1084
- const result = stmt.run(
1085
- Date.now(),
1086
- summary,
1087
- JSON.stringify(openFiles),
1088
- nextSteps,
1089
- JSON.stringify(recentConversation || [])
1090
- );
1091
-
1092
- return result.lastInsertRowid;
1093
- } catch (error) {
1094
- throw new Error(`Failed to save checkpoint: ${error.message}`);
1095
- }
1096
- }
1097
-
1098
- /**
1099
- * Load latest active checkpoint (New Feature: Session Continuity)
1100
- *
1101
- * @returns {Promise<Object|null>} Latest checkpoint or null
1102
- */
1103
- async function loadCheckpoint() {
1104
- try {
1105
- const adapter = getAdapter();
1106
- const stmt = adapter.prepare(`
1107
- SELECT * FROM checkpoints
1108
- WHERE status = 'active'
1109
- ORDER BY timestamp DESC
1110
- LIMIT 1
1111
- `);
1112
-
1113
- const checkpoint = stmt.get();
1114
-
1115
- if (checkpoint) {
1116
- try {
1117
- checkpoint.open_files = JSON.parse(checkpoint.open_files);
1118
- } catch (e) {
1119
- checkpoint.open_files = [];
1120
- }
1121
-
1122
- try {
1123
- checkpoint.recent_conversation = JSON.parse(checkpoint.recent_conversation || '[]');
1124
- } catch (e) {
1125
- checkpoint.recent_conversation = [];
1126
- }
1127
- }
1128
-
1129
- return checkpoint || null;
1130
- } catch (error) {
1131
- throw new Error(`Failed to load checkpoint: ${error.message}`);
1132
- }
1133
- }
1134
-
1135
- /**
1136
- * List recent checkpoints (New Feature: Session Continuity)
1137
- *
1138
- * @param {number} limit - Max number of checkpoints to return
1139
- * @returns {Promise<Array>} Recent checkpoints
1140
- */
1141
- async function listCheckpoints(limit = 10) {
1142
- try {
1143
- const adapter = getAdapter();
1144
- const stmt = adapter.prepare(`
1145
- SELECT * FROM checkpoints
1146
- ORDER BY timestamp DESC
1147
- LIMIT ?
1148
- `);
1149
-
1150
- const checkpoints = stmt.all(limit);
1151
-
1152
- return checkpoints.map((c) => {
1153
- try {
1154
- c.open_files = JSON.parse(c.open_files);
1155
- } catch (e) {
1156
- c.open_files = [];
1157
- }
1158
- try {
1159
- c.recent_conversation = JSON.parse(c.recent_conversation);
1160
- } catch (e) {
1161
- c.recent_conversation = [];
1162
- }
1163
- return c;
1164
- });
1165
- } catch (error) {
1166
- throw new Error(`Failed to list checkpoints: ${error.message}`);
1167
- }
1168
- }
1169
-
1170
- /**
1171
- * Propose a new link between decisions (Epic 3 - Story 3.1)
1172
- *
1173
- * LLM proposes a link for user approval. Link is created but marked as pending.
1174
- *
1175
- * @param {Object} params - Link parameters
1176
- * @param {string} params.from_id - Source decision ID
1177
- * @param {string} params.to_id - Target decision ID
1178
- * @param {string} params.relationship - 'refines' or 'contradicts'
1179
- * @param {string} params.reason - Why this link should exist
1180
- * @param {string} [params.decision_id] - Context decision where link was proposed
1181
- * @param {string} [params.evidence] - Supporting evidence
1182
- * @returns {Promise<void>}
1183
- */
1184
- async function proposeLink({ from_id, to_id, relationship, reason, decision_id, evidence }) {
1185
- if (!from_id || !to_id || !relationship || !reason) {
1186
- throw new Error('proposeLink() requires from_id, to_id, relationship, and reason');
1187
- }
1188
-
1189
- if (!['refines', 'contradicts'].includes(relationship)) {
1190
- throw new Error('proposeLink() relationship must be "refines" or "contradicts"');
1191
- }
1192
-
1193
- try {
1194
- const adapter = getAdapter();
1195
-
1196
- // Use transaction to ensure atomicity (link + audit log)
1197
- adapter.transaction(() => {
1198
- // Insert link with pending approval
1199
- const stmt = adapter.prepare(`
1200
- INSERT INTO decision_edges
1201
- (from_id, to_id, relationship, reason, created_by, approved_by_user, decision_id, evidence, created_at)
1202
- VALUES (?, ?, ?, ?, 'llm', 0, ?, ?, ?)
1203
- `);
1204
- stmt.run(
1205
- from_id,
1206
- to_id,
1207
- relationship,
1208
- reason,
1209
- decision_id || null,
1210
- evidence || null,
1211
- Date.now()
1212
- );
1213
-
1214
- // Log to audit trail
1215
- const auditStmt = adapter.prepare(`
1216
- INSERT INTO link_audit_log (from_id, to_id, relationship, action, actor, reason, created_at)
1217
- VALUES (?, ?, ?, 'proposed', 'llm', ?, ?)
1218
- `);
1219
- auditStmt.run(from_id, to_id, relationship, reason, Date.now());
1220
- });
1221
-
1222
- return;
1223
- } catch (error) {
1224
- throw new Error(`proposeLink() failed: ${error.message}`);
1225
- }
1226
- }
1227
-
1228
- /**
1229
- * Approve a proposed link (Epic 3 - Story 3.1)
1230
- *
1231
- * User approves a pending link, making it active.
1232
- *
1233
- * @param {string} from_id - Source decision ID
1234
- * @param {string} to_id - Target decision ID
1235
- * @param {string} relationship - Link relationship type
1236
- * @returns {Promise<void>}
1237
- */
1238
- async function approveLink(from_id, to_id, relationship) {
1239
- if (!from_id || !to_id || !relationship) {
1240
- throw new Error('approveLink() requires from_id, to_id, and relationship');
1241
- }
1242
-
1243
- try {
1244
- const adapter = getAdapter();
1245
-
1246
- // Update link to approved with timestamp
1247
- const stmt = adapter.prepare(`
1248
- UPDATE decision_edges
1249
- SET approved_by_user = 1, approved_at = ?
1250
- WHERE from_id = ? AND to_id = ? AND relationship = ?
1251
- `);
1252
- stmt.run(Date.now(), from_id, to_id, relationship);
1253
-
1254
- // Log approval
1255
- const auditStmt = adapter.prepare(`
1256
- INSERT INTO link_audit_log (from_id, to_id, relationship, action, actor, created_at)
1257
- VALUES (?, ?, ?, 'approved', 'user', ?)
1258
- `);
1259
- auditStmt.run(from_id, to_id, relationship, Date.now());
1260
-
1261
- return;
1262
- } catch (error) {
1263
- throw new Error(`approveLink() failed: ${error.message}`);
1264
- }
1265
- }
1266
-
1267
- /**
1268
- * Reject a proposed link (Epic 3 - Story 3.1)
1269
- *
1270
- * User rejects a pending link, removing it from the database.
1271
- *
1272
- * @param {string} from_id - Source decision ID
1273
- * @param {string} to_id - Target decision ID
1274
- * @param {string} relationship - Link relationship type
1275
- * @param {string} [reason] - Optional reason for rejection
1276
- * @returns {Promise<void>}
1277
- */
1278
- async function rejectLink(from_id, to_id, relationship, reason) {
1279
- if (!from_id || !to_id || !relationship) {
1280
- throw new Error('rejectLink() requires from_id, to_id, and relationship');
1281
- }
1282
-
1283
- try {
1284
- const adapter = getAdapter();
1285
-
1286
- // Log rejection before deletion
1287
- const auditStmt = adapter.prepare(`
1288
- INSERT INTO link_audit_log (from_id, to_id, relationship, action, actor, reason, created_at)
1289
- VALUES (?, ?, ?, 'rejected', 'user', ?, ?)
1290
- `);
1291
- auditStmt.run(from_id, to_id, relationship, reason || 'User rejected', Date.now());
1292
-
1293
- // Delete the link
1294
- const stmt = adapter.prepare(`
1295
- DELETE FROM decision_edges
1296
- WHERE from_id = ? AND to_id = ? AND relationship = ?
1297
- `);
1298
- stmt.run(from_id, to_id, relationship);
1299
-
1300
- return;
1301
- } catch (error) {
1302
- throw new Error(`rejectLink() failed: ${error.message}`);
1303
- }
1304
- }
1305
-
1306
- /**
1307
- * Get pending links awaiting approval (Epic 3 - Story 3.1)
1308
- *
1309
- * Returns all links that need user approval.
1310
- *
1311
- * @param {Object} [options] - Query options
1312
- * @param {string} [options.from_id] - Filter by source decision
1313
- * @param {string} [options.to_id] - Filter by target decision
1314
- * @returns {Promise<Array>} Pending links with decision details
1315
- */
1316
- async function getPendingLinks(options = {}) {
1317
- try {
1318
- const adapter = getAdapter();
1319
- const { from_id, to_id } = options;
1320
-
1321
- let query = `
1322
- SELECT
1323
- e.*,
1324
- d_from.topic as from_topic,
1325
- d_from.decision as from_decision,
1326
- d_to.topic as to_topic,
1327
- d_to.decision as to_decision
1328
- FROM decision_edges e
1329
- LEFT JOIN decisions d_from ON e.from_id = d_from.id
1330
- LEFT JOIN decisions d_to ON e.to_id = d_to.id
1331
- WHERE e.approved_by_user = 0
1332
- `;
1333
-
1334
- const params = [];
1335
- if (from_id) {
1336
- query += ' AND e.from_id = ?';
1337
- params.push(from_id);
1338
- }
1339
- if (to_id) {
1340
- query += ' AND e.to_id = ?';
1341
- params.push(to_id);
1342
- }
1343
-
1344
- query += ' ORDER BY e.created_at DESC';
1345
-
1346
- const stmt = adapter.prepare(query);
1347
- const links = await stmt.all(...params);
1348
-
1349
- return links;
1350
- } catch (error) {
1351
- throw new Error(`getPendingLinks() failed: ${error.message}`);
1352
- }
1353
- }
1354
-
1355
- /**
1356
- * Deprecate auto-generated links (Epic 3 - Story 3.3)
1357
- *
1358
- * Identifies and removes v0 auto-generated links that lack explicit approval context.
1359
- * Protected links (with decision_id or created_by='llm') are preserved.
1360
- *
1361
- * @param {Object} [options] - Deprecation options
1362
- * @param {boolean} [options.dryRun=true] - If true, only report without deleting
1363
- * @returns {Promise<Object>} Report with counts and deprecated links
1364
- */
1365
- async function deprecateAutoLinks(options = {}) {
1366
- const { dryRun = true } = options;
1367
-
1368
- try {
1369
- const adapter = getAdapter();
1370
-
1371
- // Identify auto-generated links (v0 legacy)
1372
- // Criteria: created_by='user' (default) AND decision_id IS NULL (no proposal context)
1373
- // Protected: decision_id IS NOT NULL OR created_by='llm' (explicitly proposed)
1374
- const identifyStmt = adapter.prepare(`
1375
- SELECT * FROM decision_edges
1376
- WHERE created_by = 'user' AND decision_id IS NULL
1377
- `);
1378
- const autoLinks = await identifyStmt.all();
1379
-
1380
- // Identify protected links for comparison
1381
- const protectedStmt = adapter.prepare(`
1382
- SELECT COUNT(*) as count FROM decision_edges
1383
- WHERE decision_id IS NOT NULL OR created_by = 'llm'
1384
- `);
1385
- const protectedResult = await protectedStmt.get();
1386
- const protectedCount = protectedResult.count;
1387
-
1388
- const totalLinks = autoLinks.length + protectedCount;
1389
- const autoLinkRatio = totalLinks > 0 ? (autoLinks.length / totalLinks) * 100 : 0;
1390
-
1391
- if (!dryRun && autoLinks.length > 0) {
1392
- // Delete auto-generated links
1393
- const deleteStmt = adapter.prepare(`
1394
- DELETE FROM decision_edges
1395
- WHERE created_by = 'user' AND decision_id IS NULL
1396
- `);
1397
- await deleteStmt.run();
1398
-
1399
- // Log deprecation to audit trail
1400
- const timestamp = Date.now();
1401
- const auditStmt = adapter.prepare(`
1402
- INSERT INTO link_audit_log (from_id, to_id, relationship, action, actor, reason, created_at)
1403
- VALUES (?, ?, ?, 'deprecated', 'system', ?, ?)
1404
- `);
1405
-
1406
- for (const link of autoLinks) {
1407
- await auditStmt.run(
1408
- link.from_id,
1409
- link.to_id,
1410
- link.relationship,
1411
- 'v0 auto-generated link removed during governance migration',
1412
- timestamp
1413
- );
1414
- }
1415
- }
1416
-
1417
- return {
1418
- dryRun,
1419
- deprecated: autoLinks.length,
1420
- protected: protectedCount,
1421
- total: totalLinks,
1422
- autoLinkRatio: autoLinkRatio.toFixed(2) + '%',
1423
- links: autoLinks.map((l) => ({
1424
- from_id: l.from_id,
1425
- to_id: l.to_id,
1426
- relationship: l.relationship,
1427
- reason: l.reason,
1428
- created_at: l.created_at,
1429
- })),
1430
- };
1431
- } catch (error) {
1432
- throw new Error(`deprecateAutoLinks() failed: ${error.message}`);
1433
- }
1434
- }
1435
-
1436
- /**
1437
- * Scan and identify auto-generated links for cleanup (Epic 5 - Story 5.1)
1438
- *
1439
- * Identifies auto-generated links lacking proper approval metadata.
1440
- * Separates deletion targets from protected links.
1441
- *
1442
- * Identification criteria:
1443
- * - approved_by_user = 0 OR (created_by IS NULL AND decision_id IS NULL)
1444
- *
1445
- * Protected (excluded from deletion):
1446
- * - approved_by_user = 1 AND (decision_id IS NOT NULL OR evidence IS NOT NULL)
1447
- *
1448
- * @returns {Object} Scan results with counts and link details
1449
- */
1450
- function scanAutoLinks() {
1451
- const adapter = getAdapter();
1452
- const db = adapter.db;
1453
-
1454
- // Total links
1455
- const totalLinks = db.prepare(`SELECT COUNT(*) as count FROM decision_edges`).get().count;
1456
-
1457
- // Auto-generated links (lacking proper metadata)
1458
- const autoLinks = db
1459
- .prepare(
1460
- `
1461
- SELECT * FROM decision_edges
1462
- WHERE approved_by_user = 0
1463
- OR (created_by IS NULL AND decision_id IS NULL)
1464
- `
1465
- )
1466
- .all();
1467
-
1468
- // Protected links (approved or has complete metadata)
1469
- const protectedLinks = db
1470
- .prepare(
1471
- `
1472
- SELECT COUNT(*) as count FROM decision_edges
1473
- WHERE approved_by_user = 1
1474
- OR (decision_id IS NOT NULL AND evidence IS NOT NULL)
1475
- `
1476
- )
1477
- .get().count;
1478
-
1479
- // Filter deletion targets (exclude protected links)
1480
- const deletionTargets = autoLinks.filter((link) => {
1481
- // Exclude protected links
1482
- return !(link.approved_by_user === 1 || (link.decision_id && link.evidence));
1483
- });
1484
-
1485
- return {
1486
- total_links: totalLinks,
1487
- auto_links: autoLinks.length,
1488
- protected_links: protectedLinks,
1489
- deletion_targets: deletionTargets.length,
1490
- deletion_target_list: deletionTargets,
1491
- };
1492
- }
1493
-
1494
- /**
1495
- * Create backup of links before cleanup (Epic 5 - Story 5.1)
1496
- *
1497
- * Backs up deletion target links with full metadata to JSON file.
1498
- * Generates SHA-256 checksum for data integrity verification.
1499
- * Creates backup manifest with timestamp and metadata.
1500
- *
1501
- * @param {Array} targetLinks - Links to back up
1502
- * @returns {Object} Backup result with file paths and checksum
1503
- */
1504
- function createLinkBackup(targetLinks) {
1505
- const fs = require('fs');
1506
- const crypto = require('crypto');
1507
- const path = require('path');
1508
-
1509
- const backupDir = path.join(os.homedir(), '.claude', 'mama-backups');
1510
-
1511
- // Create backup directory if not exists
1512
- if (!fs.existsSync(backupDir)) {
1513
- fs.mkdirSync(backupDir, { recursive: true });
1514
- }
1515
-
1516
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1517
- const backupFile = path.join(backupDir, `links-backup-${timestamp}.json`);
1518
-
1519
- // Serialize target links with full metadata
1520
- const backupData = {
1521
- timestamp: new Date().toISOString(),
1522
- link_count: targetLinks.length,
1523
- links: targetLinks,
1524
- };
1525
-
1526
- const backupJson = JSON.stringify(backupData, null, 2);
1527
-
1528
- // Calculate SHA-256 checksum
1529
- const checksum = crypto.createHash('sha256').update(backupJson).digest('hex');
1530
-
1531
- // Save backup file
1532
- fs.writeFileSync(backupFile, backupJson, 'utf8');
1533
-
1534
- // Save manifest
1535
- const manifest = {
1536
- timestamp: backupData.timestamp,
1537
- backup_file: backupFile,
1538
- checksum,
1539
- link_count: targetLinks.length,
1540
- };
1541
-
1542
- const manifestFile = path.join(backupDir, `backup-manifest-${timestamp}.json`);
1543
- fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2), 'utf8');
1544
-
1545
- return {
1546
- backup_file: backupFile,
1547
- manifest_file: manifestFile,
1548
- checksum,
1549
- link_count: targetLinks.length,
1550
- };
1551
- }
1552
-
1553
- /**
1554
- * Generate pre-cleanup report with risk assessment (Epic 5 - Story 5.1)
1555
- *
1556
- * Creates comprehensive report with statistics, risk level, and samples.
1557
- * Risk assessment based on deletion ratio:
1558
- * - HIGH: > 50% deletion
1559
- * - MEDIUM: 30-50% deletion
1560
- * - LOW: < 30% deletion
1561
- *
1562
- * @returns {Object} Report data with markdown output and file path
1563
- */
1564
- function generatePreCleanupReport() {
1565
- const scanResult = scanAutoLinks();
1566
-
1567
- // Calculate risk level (guard against division by zero)
1568
- const deletionRatio =
1569
- scanResult.total_links > 0 ? scanResult.deletion_targets / scanResult.total_links : 0;
1570
- let riskLevel;
1571
- if (deletionRatio > 0.5) {
1572
- riskLevel = 'HIGH';
1573
- } else if (deletionRatio > 0.3) {
1574
- riskLevel = 'MEDIUM';
1575
- } else {
1576
- riskLevel = 'LOW';
1577
- }
1578
-
1579
- // Sample deletion targets (max 10)
1580
- const samples = scanResult.deletion_target_list.slice(0, 10).map((link) => ({
1581
- from_id: link.from_id,
1582
- to_id: link.to_id,
1583
- relationship: link.relationship,
1584
- reason: link.reason,
1585
- created_by: link.created_by,
1586
- approved_by_user: link.approved_by_user,
1587
- }));
1588
-
1589
- const report = {
1590
- generated_at: new Date().toISOString(),
1591
- statistics: {
1592
- total_links: scanResult.total_links,
1593
- auto_links: scanResult.auto_links,
1594
- protected_links: scanResult.protected_links,
1595
- deletion_targets: scanResult.deletion_targets,
1596
- deletion_ratio: `${(deletionRatio * 100).toFixed(1)}%`,
1597
- },
1598
- risk_assessment: {
1599
- level: riskLevel,
1600
- message:
1601
- riskLevel === 'HIGH'
1602
- ? '⚠️ HIGH RISK: Deletion targets exceed 50%. Create backup before proceeding.'
1603
- : riskLevel === 'MEDIUM'
1604
- ? '⚡ MEDIUM RISK: Deletion targets 30-50%. Verify backup recommended.'
1605
- : '✅ LOW RISK: Deletion targets under 30%. Safe to proceed.',
1606
- },
1607
- deletion_target_samples: samples,
1608
- };
1609
-
1610
- // Generate markdown report
1611
- const markdown = `# Pre-Cleanup Report
1612
-
1613
- **Generated:** ${report.generated_at}
1614
-
1615
- ## Statistics
1616
-
1617
- - **Total Links:** ${report.statistics.total_links}
1618
- - **Auto Links:** ${report.statistics.auto_links}
1619
- - **Protected Links:** ${report.statistics.protected_links}
1620
- - **Deletion Targets:** ${report.statistics.deletion_targets} (${report.statistics.deletion_ratio})
1621
-
1622
- ## Risk Assessment
1623
-
1624
- **Level:** ${report.risk_assessment.level}
1625
-
1626
- ${report.risk_assessment.message}
1627
-
1628
- ## Sample Deletion Targets (First 10)
1629
-
1630
- ${samples
1631
- .map(
1632
- (link, idx) => `
1633
- ### ${idx + 1}. ${link.from_id} → ${link.to_id}
1634
-
1635
- - **Relationship:** ${link.relationship}
1636
- - **Reason:** ${link.reason || 'N/A'}
1637
- - **Created By:** ${link.created_by || 'N/A'}
1638
- - **Approved:** ${link.approved_by_user ? 'Yes' : 'No'}
1639
- `
1640
- )
1641
- .join('\n')}
1642
-
1643
- ---
1644
-
1645
- **Next Steps:**
1646
-
1647
- 1. Review the deletion targets above
1648
- 2. Run \`create_link_backup\` to create a backup
1649
- 3. Proceed with cleanup using Story 5.2 tools
1650
- 4. If needed, restore from backup using \`restore_link_backup\`
1651
- `;
1652
-
1653
- const fs = require('fs');
1654
- const path = require('path');
1655
- const backupDir = path.join(os.homedir(), '.claude', 'mama-backups');
1656
-
1657
- // Ensure backup directory exists
1658
- if (!fs.existsSync(backupDir)) {
1659
- fs.mkdirSync(backupDir, { recursive: true });
1660
- }
1661
-
1662
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1663
- const reportFile = path.join(backupDir, `pre-cleanup-report-${timestamp}.md`);
1664
-
1665
- fs.writeFileSync(reportFile, markdown, 'utf8');
1666
-
1667
- return {
1668
- report: report,
1669
- report_file: reportFile,
1670
- markdown,
1671
- };
1672
- }
1673
-
1674
- /**
1675
- * Restore links from backup file (Epic 5 - Story 5.1)
1676
- *
1677
- * Restores previously backed-up links to the database.
1678
- * Verifies checksum before restoration to ensure data integrity.
1679
- * Reports number of restored and failed links.
1680
- *
1681
- * @param {string} backupFile - Path to backup file
1682
- * @returns {Object} Restoration result with counts
1683
- */
1684
- function restoreLinkBackup(backupFile) {
1685
- const fs = require('fs');
1686
- const crypto = require('crypto');
1687
-
1688
- // Read backup file
1689
- const backupJson = fs.readFileSync(backupFile, 'utf8');
1690
- const backupData = JSON.parse(backupJson);
1691
-
1692
- // Read manifest for checksum verification
1693
- const manifestFile = backupFile.replace('links-backup', 'backup-manifest');
1694
- const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
1695
-
1696
- // Verify checksum
1697
- const calculatedChecksum = crypto.createHash('sha256').update(backupJson).digest('hex');
1698
- if (calculatedChecksum !== manifest.checksum) {
1699
- throw new Error('Backup file checksum mismatch. File may be corrupted.');
1700
- }
1701
-
1702
- // Restore links to database
1703
- const adapter = getAdapter();
1704
- const db = adapter.db;
1705
-
1706
- let restored = 0;
1707
- let failed = 0;
1708
-
1709
- const insertStmt = db.prepare(`
1710
- INSERT OR REPLACE INTO decision_edges
1711
- (from_id, to_id, relationship, reason, created_by, approved_by_user, decision_id, evidence, created_at)
1712
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1713
- `);
1714
-
1715
- for (const link of backupData.links) {
1716
- try {
1717
- insertStmt.run(
1718
- link.from_id,
1719
- link.to_id,
1720
- link.relationship,
1721
- link.reason,
1722
- link.created_by,
1723
- link.approved_by_user,
1724
- link.decision_id,
1725
- link.evidence,
1726
- link.created_at
1727
- );
1728
- restored++;
1729
- } catch (error) {
1730
- console.error(`Failed to restore link ${link.from_id} -> ${link.to_id}:`, error);
1731
- failed++;
1732
- }
1733
- }
1734
-
1735
- return {
1736
- total_links: backupData.link_count,
1737
- restored,
1738
- failed,
1739
- backup_file: backupFile,
1740
- };
1741
- }
1742
-
1743
- /**
1744
- * Verify backup file exists and is recent (Epic 5 - Story 5.2)
1745
- *
1746
- * Checks for backup files in backup directory and verifies they are recent enough.
1747
- * Required as safety check before executing link deletion.
1748
- *
1749
- * @param {number} maxAgeHours - Maximum age of backup in hours (default: 24)
1750
- * @returns {Object} Backup verification result with latest backup info
1751
- */
1752
- function verifyBackupExists(maxAgeHours = 24) {
1753
- const fs = require('fs');
1754
- const path = require('path');
1755
- const backupDir = path.join(os.homedir(), '.claude', 'mama-backups');
1756
-
1757
- if (!fs.existsSync(backupDir)) {
1758
- throw new Error(
1759
- 'Backup directory not found. Please create a backup first using create_link_backup.'
1760
- );
1761
- }
1762
-
1763
- const backupFiles = fs
1764
- .readdirSync(backupDir)
1765
- .filter((f) => f.startsWith('links-backup-'))
1766
- .map((f) => ({
1767
- name: f,
1768
- path: path.join(backupDir, f),
1769
- mtime: fs.statSync(path.join(backupDir, f)).mtime,
1770
- }))
1771
- .sort((a, b) => b.mtime - a.mtime);
1772
-
1773
- if (backupFiles.length === 0) {
1774
- throw new Error('No recent backup found. Please run create_link_backup first.');
1775
- }
1776
-
1777
- const latestBackup = backupFiles[0];
1778
- const backupAge = Date.now() - latestBackup.mtime.getTime();
1779
- const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
1780
-
1781
- if (backupAge > maxAgeMs) {
1782
- throw new Error(
1783
- `Most recent backup is too old (${(backupAge / (60 * 60 * 1000)).toFixed(1)} hours). Max age: ${maxAgeHours} hours.`
1784
- );
1785
- }
1786
-
1787
- // Read backup metadata
1788
- const backupJson = fs.readFileSync(latestBackup.path, 'utf8');
1789
- const backupData = JSON.parse(backupJson);
1790
-
1791
- return {
1792
- backup_file: latestBackup.path,
1793
- age_hours: backupAge / (60 * 60 * 1000),
1794
- link_count: backupData.link_count || 0,
1795
- };
1796
- }
1797
-
1798
- /**
1799
- * Delete auto-generated links with batch processing (Epic 5 - Story 5.2)
1800
- *
1801
- * Executes batch deletion of auto-generated links with transaction support.
1802
- * Requires recent backup (within 24 hours) before execution.
1803
- * Logs all deletions to audit trail.
1804
- *
1805
- * Safety features:
1806
- * - Backup verification before deletion
1807
- * - Batch processing with transaction support
1808
- * - Dry-run mode for simulation
1809
- * - Large deletion warning (> 1000 links)
1810
- *
1811
- * @param {number} batchSize - Number of links to delete per batch (default: 100)
1812
- * @param {boolean} dryRun - If true, simulate deletion without actual changes (default: true)
1813
- * @returns {Object} Deletion result with counts and backup info
1814
- */
1815
- function deleteAutoLinks(batchSize = 100, dryRun = true) {
1816
- const adapter = getAdapter();
1817
- const db = adapter.db;
1818
-
1819
- // Safety check: Verify recent backup exists
1820
- const backupInfo = verifyBackupExists(24);
1821
-
1822
- // Scan for deletion targets
1823
- const scanResult = scanAutoLinks();
1824
- const deletionTargets = scanResult.deletion_target_list;
1825
-
1826
- if (deletionTargets.length === 0) {
1827
- return {
1828
- dry_run: dryRun,
1829
- deleted: 0,
1830
- failed: 0,
1831
- total_targets: 0,
1832
- backup_file: backupInfo.backup_file,
1833
- message: 'No auto-generated links found. Nothing to delete.',
1834
- };
1835
- }
1836
-
1837
- // Large deletion warning
1838
- const largeDelection = deletionTargets.length > 1000;
1839
- if (largeDelection) {
1840
- console.warn(
1841
- `⚠️ LARGE DELETION: ${deletionTargets.length} links will be deleted. Consider running in dry-run mode first.`
1842
- );
1843
- }
1844
-
1845
- if (dryRun) {
1846
- return {
1847
- dry_run: true,
1848
- would_delete: deletionTargets.length,
1849
- deleted: 0,
1850
- backup_file: backupInfo.backup_file,
1851
- large_deletion_warning: largeDelection,
1852
- warning_message: largeDelection
1853
- ? `Warning: More than 1000 links (${deletionTargets.length}) will be deleted.`
1854
- : null,
1855
- sample_links: deletionTargets.slice(0, 5).map((l) => ({
1856
- from_id: l.from_id,
1857
- to_id: l.to_id,
1858
- relationship: l.relationship,
1859
- })),
1860
- message: 'Dry-run mode: No links were actually deleted.',
1861
- };
1862
- }
1863
-
1864
- // Execute batch deletion with transaction support
1865
- let deleted = 0;
1866
- let failed = 0;
1867
- let batchesProcessed = 0;
1868
- const errors = [];
1869
-
1870
- const deleteStmt = db.prepare(`
1871
- DELETE FROM decision_edges
1872
- WHERE from_id = ? AND to_id = ? AND relationship = ?
1873
- `);
1874
-
1875
- const auditStmt = db.prepare(`
1876
- INSERT INTO link_audit_log (from_id, to_id, relationship, action, actor, reason, created_at)
1877
- VALUES (?, ?, ?, 'deprecated', 'system', ?, ?)
1878
- `);
1879
-
1880
- // Process in batches
1881
- for (let i = 0; i < deletionTargets.length; i += batchSize) {
1882
- const batch = deletionTargets.slice(i, i + batchSize);
1883
-
1884
- try {
1885
- db.transaction(() => {
1886
- for (const link of batch) {
1887
- try {
1888
- deleteStmt.run(link.from_id, link.to_id, link.relationship);
1889
- auditStmt.run(
1890
- link.from_id,
1891
- link.to_id,
1892
- link.relationship,
1893
- 'Auto-link cleanup - v1.1 migration',
1894
- Date.now()
1895
- );
1896
- deleted++;
1897
- } catch (error) {
1898
- failed++;
1899
- errors.push({
1900
- link: `${link.from_id}->${link.to_id}`,
1901
- error: error.message,
1902
- });
1903
- }
1904
- }
1905
- })();
1906
- batchesProcessed++;
1907
- } catch (error) {
1908
- console.error(`Batch deletion failed at index ${i}:`, error);
1909
- failed += batch.length;
1910
- errors.push({
1911
- batch_index: i,
1912
- batch_size: batch.length,
1913
- error: error.message,
1914
- });
1915
- break; // Stop on batch failure
1916
- }
1917
- }
1918
-
1919
- const successRate = deletionTargets.length > 0 ? (deleted / deletionTargets.length) * 100 : 0;
1920
-
1921
- return {
1922
- dry_run: false,
1923
- deleted,
1924
- failed,
1925
- total_targets: deletionTargets.length,
1926
- backup_file: backupInfo.backup_file,
1927
- batches_processed: batchesProcessed,
1928
- errors: errors.slice(0, 10), // Return first 10 errors
1929
- success_rate: successRate,
1930
- };
1931
- }
1932
-
1933
- /**
1934
- * Validate cleanup result and generate post-cleanup report (Epic 5 - Story 5.2)
1935
- *
1936
- * Re-scans for remaining auto-generated links and evaluates cleanup success.
1937
- * Generates comprehensive report with statistics and recommendations.
1938
- *
1939
- * Success criteria:
1940
- * - SUCCESS: Remaining auto links < 5%
1941
- * - PARTIAL: Remaining auto links 5-10%
1942
- * - FAILED: Remaining auto links > 10%
1943
- *
1944
- * @returns {Object} Validation result with report and file path
1945
- */
1946
- function validateCleanupResult() {
1947
- // Re-scan for remaining auto links
1948
- const scanResult = scanAutoLinks();
1949
-
1950
- // Calculate remaining ratio
1951
- const totalLinks = scanResult.total_links;
1952
- const remainingAutoLinks = scanResult.auto_links;
1953
- const remainingRatio = totalLinks > 0 ? remainingAutoLinks / totalLinks : 0;
1954
-
1955
- // Evaluate cleanup success
1956
- let status;
1957
- let message;
1958
- let recommendation;
1959
-
1960
- if (remainingRatio < 0.05) {
1961
- status = 'SUCCESS';
1962
- message = '✅ SUCCESS: Remaining auto-links under 5%. Target achieved!';
1963
- recommendation = 'Cleanup completed successfully. You can proceed with migration.';
1964
- } else if (remainingRatio < 0.1) {
1965
- status = 'PARTIAL';
1966
- message = '⚡ PARTIAL: Remaining auto-links 5-10%. Additional cleanup recommended.';
1967
- recommendation = 'Run execute_link_cleanup again to clean up more auto-links.';
1968
- } else {
1969
- status = 'FAILED';
1970
- message = '⚠️ FAILED: Remaining auto-links exceed 10%. Rollback or re-run needed.';
1971
- recommendation = 'Significantly missed target. Consider restoring from backup and retry.';
1972
- }
1973
-
1974
- // Generate post-cleanup report
1975
- const report = {
1976
- validated_at: new Date().toISOString(),
1977
- status,
1978
- message,
1979
- statistics: {
1980
- total_links: totalLinks,
1981
- remaining_auto_links: remainingAutoLinks,
1982
- remaining_ratio: `${(remainingRatio * 100).toFixed(1)}%`,
1983
- protected_links: scanResult.protected_links,
1984
- deletion_targets: scanResult.deletion_targets,
1985
- },
1986
- recommendation,
1987
- };
1988
-
1989
- // Generate markdown report
1990
- const markdown = generatePostCleanupReportMarkdown(report);
1991
-
1992
- // Save report to file
1993
- const fs = require('fs');
1994
- const path = require('path');
1995
- const backupDir = path.join(os.homedir(), '.claude', 'mama-backups');
1996
-
1997
- if (!fs.existsSync(backupDir)) {
1998
- fs.mkdirSync(backupDir, { recursive: true });
1999
- }
2000
-
2001
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
2002
- const reportFile = path.join(backupDir, `post-cleanup-report-${timestamp}.md`);
2003
-
2004
- fs.writeFileSync(reportFile, markdown, 'utf8');
2005
-
2006
- return {
2007
- status,
2008
- total_links_before: totalLinks,
2009
- auto_links_remaining: remainingAutoLinks,
2010
- remaining_ratio: remainingRatio * 100,
2011
- protected_links: scanResult.protected_links,
2012
- report,
2013
- report_file: reportFile,
2014
- markdown,
2015
- };
2016
- }
2017
-
2018
- /**
2019
- * Generate post-cleanup report in Markdown format (Epic 5 - Story 5.2)
2020
- *
2021
- * @param {Object} report - Validation report data
2022
- * @returns {string} Markdown formatted report
2023
- */
2024
- function generatePostCleanupReportMarkdown(report) {
2025
- let markdown = `# Post-Cleanup Validation Report
2026
-
2027
- **Generated:** ${report.validated_at}
2028
- **Status:** ${report.status}
2029
-
2030
- ${report.message}
2031
-
2032
- ## Statistics
2033
-
2034
- - **Total Links:** ${report.statistics.total_links}
2035
- - **Remaining Auto Links:** ${report.statistics.remaining_auto_links}
2036
- - **Remaining Ratio:** ${report.statistics.remaining_ratio}
2037
- - **Protected Links:** ${report.statistics.protected_links}
2038
- - **Deletion Targets (if any):** ${report.statistics.deletion_targets}
2039
-
2040
- ## Recommendation
2041
-
2042
- ${report.recommendation}
2043
- `;
2044
-
2045
- // Add rollback instructions for non-SUCCESS statuses
2046
- if (report.status !== 'SUCCESS') {
2047
- markdown += `
2048
-
2049
- ---
2050
-
2051
- ## Rollback Instructions
2052
-
2053
- If you need to restore the deleted links:
2054
-
2055
- 1. Find the latest backup file in \`~/.claude/mama-backups/\`
2056
- 2. Run: \`restore_link_backup <backup_file_path>\`
2057
- 3. Re-run validation: \`validate_cleanup_result\`
2058
-
2059
- **Next Steps:**
2060
-
2061
- - For PARTIAL status: Review remaining auto links and run cleanup again if needed
2062
- - For FAILED status: Consider rollback and investigate why many links remain
2063
- `;
2064
- }
2065
-
2066
- return markdown;
2067
- }
2068
-
2069
- /**
2070
- * Calculate coverage metrics (Epic 4 - Story 4.1)
2071
- *
2072
- * Measures narrative coverage (% of decisions with narrative fields)
2073
- * and link coverage (% of decisions with at least one link).
2074
- *
2075
- * @returns {Object} Coverage metrics
2076
- */
2077
- function calculateCoverage() {
2078
- const adapter = getAdapter();
2079
- const db = adapter.db;
2080
-
2081
- // Total decisions
2082
- const totalDecisions = db.prepare(`SELECT COUNT(*) as count FROM decisions`).get().count;
2083
-
2084
- if (totalDecisions === 0) {
2085
- return {
2086
- narrativeCoverage: '0.0%',
2087
- linkCoverage: '0.0%',
2088
- totalDecisions: 0,
2089
- completeNarratives: 0,
2090
- decisionsWithLinks: 0,
2091
- };
2092
- }
2093
-
2094
- // Narrative coverage: Decisions with evidence, alternatives, and risks filled
2095
- // Note: Using existing schema fields (evidence, alternatives, risks) instead of
2096
- // 5-layer fields (specificity, evidence, reasoning, tension, continuity) mentioned in story
2097
- const completeNarratives = db
2098
- .prepare(
2099
- `
2100
- SELECT COUNT(*) as count FROM decisions
2101
- WHERE evidence IS NOT NULL AND evidence != ''
2102
- AND alternatives IS NOT NULL AND alternatives != ''
2103
- AND risks IS NOT NULL AND risks != ''
2104
- `
2105
- )
2106
- .get().count;
2107
-
2108
- const narrativeCoverage = (completeNarratives / totalDecisions) * 100;
2109
-
2110
- // Link coverage: Decisions with at least one link
2111
- const decisionsWithLinks = db
2112
- .prepare(
2113
- `
2114
- SELECT COUNT(DISTINCT d.id) as count FROM decisions d
2115
- WHERE EXISTS (
2116
- SELECT 1 FROM decision_edges e
2117
- WHERE e.from_id = d.id OR e.to_id = d.id
2118
- )
2119
- `
2120
- )
2121
- .get().count;
2122
-
2123
- const linkCoverage = (decisionsWithLinks / totalDecisions) * 100;
2124
-
2125
- return {
2126
- narrativeCoverage: `${narrativeCoverage.toFixed(1)}%`,
2127
- linkCoverage: `${linkCoverage.toFixed(1)}%`,
2128
- totalDecisions,
2129
- completeNarratives,
2130
- decisionsWithLinks,
2131
- };
2132
- }
2133
-
2134
- /**
2135
- * Log restart attempt (Epic 4 - Story 4.2)
2136
- *
2137
- * Records restart attempt with success/failure status, latency, and mode.
2138
- * Replaces in-memory restart-metrics.js with SQLite-backed storage.
2139
- *
2140
- * @param {string} sessionId - Session identifier
2141
- * @param {string} status - 'success' or 'failure'
2142
- * @param {string|null} failureReason - 'NO_CHECKPOINT', 'LOAD_ERROR', 'CONTEXT_INCOMPLETE', or null
2143
- * @param {number} latencyMs - Latency in milliseconds
2144
- * @param {string} mode - 'full' (narrative+links) or 'summary' (summary only)
2145
- * @returns {void}
2146
- */
2147
- function logRestartAttempt(sessionId, status, failureReason, latencyMs, mode = 'full') {
2148
- const adapter = getAdapter();
2149
- const db = adapter.db;
2150
-
2151
- const timestamp = new Date().toISOString();
2152
-
2153
- db.prepare(
2154
- `
2155
- INSERT INTO restart_metrics (timestamp, session_id, status, failure_reason, latency_ms, mode)
2156
- VALUES (?, ?, ?, ?, ?, ?)
2157
- `
2158
- ).run(timestamp, sessionId, status, failureReason, latencyMs, mode);
2159
-
2160
- // Performance warning if exceeds threshold
2161
- const threshold = mode === 'summary' ? 1000 : 2500;
2162
- if (latencyMs > threshold) {
2163
- console.warn(
2164
- JSON.stringify({
2165
- performance_warning: true,
2166
- message: `Restart latency exceeded threshold: ${latencyMs}ms > ${threshold}ms`,
2167
- session_id: sessionId,
2168
- mode,
2169
- latency_ms: latencyMs,
2170
- threshold_ms: threshold,
2171
- })
2172
- );
2173
- }
2174
- }
2175
-
2176
- /**
2177
- * Calculate restart success rate (Epic 4 - Story 4.2)
2178
- *
2179
- * Calculates success rate over a given period (24h, 7d, 30d).
2180
- *
2181
- * @param {string} period - '24h', '7d', or '30d'
2182
- * @returns {Object} Success rate metrics
2183
- */
2184
- function calculateRestartSuccessRate(period = '7d') {
2185
- const adapter = getAdapter();
2186
- const db = adapter.db;
2187
-
2188
- const periodMap = {
2189
- '24h': 1,
2190
- '7d': 7,
2191
- '30d': 30,
2192
- };
2193
-
2194
- const days = periodMap[period] || 7;
2195
- const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
2196
-
2197
- const stats = db
2198
- .prepare(
2199
- `
2200
- SELECT
2201
- COUNT(*) as total,
2202
- COUNT(CASE WHEN status = 'success' THEN 1 END) as success,
2203
- COUNT(CASE WHEN status = 'failure' THEN 1 END) as failure
2204
- FROM restart_metrics
2205
- WHERE timestamp >= ?
2206
- `
2207
- )
2208
- .get(since);
2209
-
2210
- const successRate = stats.total > 0 ? stats.success / stats.total : 0;
2211
-
2212
- return {
2213
- period,
2214
- total: stats.total,
2215
- success: stats.success,
2216
- failure: stats.failure,
2217
- successRate: `${(successRate * 100).toFixed(1)}%`,
2218
- meetsTarget: successRate >= 0.95,
2219
- };
2220
- }
2221
-
2222
- /**
2223
- * Calculate restart latency percentiles (Epic 4 - Story 4.2)
2224
- *
2225
- * Calculates p50, p95, p99 latencies for successful restarts.
2226
- * Optionally filters by mode (full/summary).
2227
- *
2228
- * @param {string} period - '24h', '7d', or '30d'
2229
- * @param {string|null} mode - 'full', 'summary', or null (all modes)
2230
- * @returns {Object} Latency percentile metrics
2231
- */
2232
- function calculateRestartLatency(period = '7d', mode = null) {
2233
- const adapter = getAdapter();
2234
- const db = adapter.db;
2235
-
2236
- const periodMap = { '24h': 1, '7d': 7, '30d': 30 };
2237
- const days = periodMap[period] || 7;
2238
- const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
2239
-
2240
- let query = `
2241
- SELECT latency_ms
2242
- FROM restart_metrics
2243
- WHERE timestamp >= ? AND status = 'success'
2244
- `;
2245
-
2246
- const params = [since];
2247
-
2248
- if (mode) {
2249
- query += ` AND mode = ?`;
2250
- params.push(mode);
2251
- }
2252
-
2253
- query += ` ORDER BY latency_ms ASC`;
2254
-
2255
- const rows = db.prepare(query).all(...params);
2256
- const latencies = rows.map((r) => r.latency_ms);
2257
-
2258
- if (latencies.length === 0) {
2259
- return { p50: 0, p95: 0, p99: 0, count: 0, mode: mode || 'all' };
2260
- }
2261
-
2262
- const percentile = (arr, p) => {
2263
- const index = Math.ceil((p / 100) * arr.length) - 1;
2264
- return arr[Math.max(0, index)];
2265
- };
2266
-
2267
- return {
2268
- p50: percentile(latencies, 50),
2269
- p95: percentile(latencies, 95),
2270
- p99: percentile(latencies, 99),
2271
- count: latencies.length,
2272
- mode: mode || 'all',
2273
- };
2274
- }
2275
-
2276
- /**
2277
- * Get restart metrics (Epic 4 - Story 4.2)
2278
- *
2279
- * Combines success rate and latency metrics for a given period.
2280
- *
2281
- * @param {string} period - '24h', '7d', or '30d'
2282
- * @param {boolean} includeLatency - Whether to include latency percentiles
2283
- * @returns {Object} Combined restart metrics
2284
- */
2285
- function getRestartMetrics(period = '7d', includeLatency = true) {
2286
- const successRate = calculateRestartSuccessRate(period);
2287
- const result = { successRate };
2288
-
2289
- if (includeLatency) {
2290
- result.latency = {
2291
- full: calculateRestartLatency(period, 'full'),
2292
- summary: calculateRestartLatency(period, 'summary'),
2293
- };
2294
- }
2295
-
2296
- return result;
2297
- }
2298
-
2299
- /**
2300
- * Calculate quality metrics (Epic 4 - Story 4.1)
2301
- *
2302
- * Measures narrative quality (field completeness per layer)
2303
- * and link quality (rich reason ratio, approved link ratio).
2304
- *
2305
- * @returns {Object} Quality metrics
2306
- */
2307
- function calculateQuality() {
2308
- const adapter = getAdapter();
2309
- const db = adapter.db;
2310
-
2311
- // Narrative quality: Average completeness for each narrative field
2312
- const narrativeQuality = db
2313
- .prepare(
2314
- `
2315
- SELECT
2316
- AVG(CASE WHEN evidence IS NOT NULL AND evidence != '' THEN 1 ELSE 0 END) as evidence,
2317
- AVG(CASE WHEN alternatives IS NOT NULL AND alternatives != '' THEN 1 ELSE 0 END) as alternatives,
2318
- AVG(CASE WHEN risks IS NOT NULL AND risks != '' THEN 1 ELSE 0 END) as risks
2319
- FROM decisions
2320
- `
2321
- )
2322
- .get();
2323
-
2324
- // Link quality: "Rich" reason ratio (reason > 50 chars) and approved link ratio
2325
- const linkStats = db
2326
- .prepare(
2327
- `
2328
- SELECT
2329
- COUNT(*) as total,
2330
- COUNT(CASE WHEN reason IS NOT NULL AND LENGTH(reason) > 50 THEN 1 END) as rich,
2331
- COUNT(CASE WHEN approved_by_user = 1 THEN 1 END) as approved
2332
- FROM decision_edges
2333
- `
2334
- )
2335
- .get();
2336
-
2337
- const linkQuality =
2338
- linkStats.total > 0 ? ((linkStats.rich / linkStats.total) * 100).toFixed(1) : '0.0';
2339
- const approvedRatio =
2340
- linkStats.total > 0 ? ((linkStats.approved / linkStats.total) * 100).toFixed(1) : '0.0';
2341
-
2342
- return {
2343
- narrativeQuality: {
2344
- evidence: `${(narrativeQuality.evidence * 100).toFixed(1)}%`,
2345
- alternatives: `${(narrativeQuality.alternatives * 100).toFixed(1)}%`,
2346
- risks: `${(narrativeQuality.risks * 100).toFixed(1)}%`,
2347
- },
2348
- linkQuality: {
2349
- richReasonRatio: `${linkQuality}%`,
2350
- approvedRatio: `${approvedRatio}%`,
2351
- totalLinks: linkStats.total,
2352
- richLinks: linkStats.rich,
2353
- approvedLinks: linkStats.approved,
2354
- },
2355
- };
2356
- }
2357
-
2358
- /**
2359
- * Generate quality report with recommendations (Epic 4 - Story 4.1 + 4.2)
2360
- *
2361
- * Generates a comprehensive quality report with coverage, quality metrics,
2362
- * restart metrics, and recommendations for improvement.
2363
- *
2364
- * @param {Object} options - Report options
2365
- * @param {string} [options.format='json'] - Output format: 'json' or 'markdown'
2366
- * @param {string} [options.period='7d'] - Period for restart metrics: '24h', '7d', or '30d'
2367
- * @param {Object} [options.thresholds] - Custom thresholds
2368
- * @param {number} [options.thresholds.narrativeCoverage=0.8] - Narrative coverage threshold (0-1)
2369
- * @param {number} [options.thresholds.linkCoverage=0.7] - Link coverage threshold (0-1)
2370
- * @param {number} [options.thresholds.richReasonRatio=0.7] - Rich reason ratio threshold (0-1)
2371
- * @param {number} [options.thresholds.restartSuccessRate=0.95] - Restart success rate threshold (0-1)
2372
- * @param {number} [options.thresholds.restartLatencyP95Full=2500] - Full mode p95 latency threshold (ms)
2373
- * @param {number} [options.thresholds.restartLatencyP95Summary=1000] - Summary mode p95 latency threshold (ms)
2374
- * @returns {Object|string} Quality report as JSON or Markdown
2375
- */
2376
- function generateQualityReport(options = {}) {
2377
- const { format = 'json', period = '7d', thresholds = {} } = options;
2378
-
2379
- const defaultThresholds = {
2380
- narrativeCoverage: 0.8,
2381
- linkCoverage: 0.7,
2382
- richReasonRatio: 0.7,
2383
- restartSuccessRate: 0.95,
2384
- restartLatencyP95Full: 2500,
2385
- restartLatencyP95Summary: 1000,
2386
- ...thresholds,
2387
- };
2388
-
2389
- const coverage = calculateCoverage();
2390
- const quality = calculateQuality();
2391
-
2392
- // Story 4.2: Add restart metrics
2393
- const restartMetrics = getRestartMetrics(period, true);
2394
-
2395
- const recommendations = [];
2396
-
2397
- // Check narrative coverage threshold
2398
- const narrativeCoveragePct = parseFloat(coverage.narrativeCoverage);
2399
- if (narrativeCoveragePct < defaultThresholds.narrativeCoverage * 100) {
2400
- recommendations.push({
2401
- type: 'narrative_coverage',
2402
- message: `Narrative coverage below target (${defaultThresholds.narrativeCoverage * 100}%). Add narrative to decisions missing evidence, alternatives, or risks fields.`,
2403
- target: `${defaultThresholds.narrativeCoverage * 100}%`,
2404
- current: coverage.narrativeCoverage,
2405
- });
2406
- }
2407
-
2408
- // Check link coverage threshold
2409
- const linkCoveragePct = parseFloat(coverage.linkCoverage);
2410
- if (linkCoveragePct < defaultThresholds.linkCoverage * 100) {
2411
- recommendations.push({
2412
- type: 'link_coverage',
2413
- message: `Link coverage below target (${defaultThresholds.linkCoverage * 100}%). Add links between related decisions.`,
2414
- target: `${defaultThresholds.linkCoverage * 100}%`,
2415
- current: coverage.linkCoverage,
2416
- });
2417
- }
2418
-
2419
- // Check link quality threshold
2420
- const richReasonRatioPct = parseFloat(quality.linkQuality.richReasonRatio);
2421
- if (richReasonRatioPct < defaultThresholds.richReasonRatio * 100) {
2422
- recommendations.push({
2423
- type: 'link_quality',
2424
- message: `Link quality below target (${defaultThresholds.richReasonRatio * 100}%). Add specific causality and evidence to link reason fields.`,
2425
- target: `${defaultThresholds.richReasonRatio * 100}%`,
2426
- current: quality.linkQuality.richReasonRatio,
2427
- });
2428
- }
2429
-
2430
- // Story 4.2: Check restart success rate threshold (only if there's data)
2431
- if (restartMetrics.successRate.total > 0 && !restartMetrics.successRate.meetsTarget) {
2432
- // eslint-disable-next-line no-unused-vars
2433
- const successRatePct = parseFloat(restartMetrics.successRate.successRate);
2434
- recommendations.push({
2435
- type: 'restart_success_rate',
2436
- message: `Restart success rate below target (95%). Analyze failure reasons and improve checkpoint quality.`,
2437
- target: '95%',
2438
- current: restartMetrics.successRate.successRate,
2439
- });
2440
- }
2441
-
2442
- // Story 4.2: Check restart latency thresholds (only if there's data)
2443
- if (restartMetrics.latency) {
2444
- const fullP95 = restartMetrics.latency.full.p95;
2445
- if (
2446
- restartMetrics.latency.full.count > 0 &&
2447
- fullP95 > defaultThresholds.restartLatencyP95Full
2448
- ) {
2449
- recommendations.push({
2450
- type: 'restart_latency_full',
2451
- message: `Narrative+link expansion p95 latency exceeds target (2.5s). Consider limiting link expansion depth or adding caching.`,
2452
- target: `${defaultThresholds.restartLatencyP95Full}ms`,
2453
- current: `${fullP95}ms`,
2454
- });
2455
- }
2456
-
2457
- const summaryP95 = restartMetrics.latency.summary.p95;
2458
- if (
2459
- restartMetrics.latency.summary.count > 0 &&
2460
- summaryP95 > defaultThresholds.restartLatencyP95Summary
2461
- ) {
2462
- recommendations.push({
2463
- type: 'restart_latency_summary',
2464
- message: `Summary mode p95 latency exceeds target (1.0s). Review query optimization or add indexes.`,
2465
- target: `${defaultThresholds.restartLatencyP95Summary}ms`,
2466
- current: `${summaryP95}ms`,
2467
- });
2468
- }
2469
- }
2470
-
2471
- const report = {
2472
- generated_at: new Date().toISOString(),
2473
- period,
2474
- coverage,
2475
- quality,
2476
- restart: restartMetrics,
2477
- thresholds: defaultThresholds,
2478
- recommendations,
2479
- format,
2480
- };
2481
-
2482
- if (format === 'markdown') {
2483
- return formatQualityReportMarkdown(report);
2484
- }
2485
-
2486
- return report;
2487
- }
2488
-
2489
- /**
2490
- * Format quality report as Markdown
2491
- *
2492
- * @param {Object} report - Quality report data
2493
- * @returns {string} Markdown-formatted report
2494
- */
2495
- function formatQualityReportMarkdown(report) {
2496
- const { generated_at, period, coverage, quality, restart, thresholds, recommendations } = report;
2497
-
2498
- let markdown = `# 📊 MAMA Quality Report\n\n`;
2499
- markdown += `Generated: ${generated_at}\n`;
2500
- markdown += `Period: ${period}\n\n`;
2501
-
2502
- markdown += `## Coverage Metrics\n\n`;
2503
- markdown += `- **Narrative Coverage**: ${coverage.narrativeCoverage} (${coverage.completeNarratives}/${coverage.totalDecisions} decisions)\n`;
2504
- markdown += `- **Link Coverage**: ${coverage.linkCoverage} (${coverage.decisionsWithLinks}/${coverage.totalDecisions} decisions)\n\n`;
2505
-
2506
- markdown += `## Quality Metrics\n\n`;
2507
- markdown += `### Narrative Quality\n`;
2508
- markdown += `- Evidence: ${quality.narrativeQuality.evidence}\n`;
2509
- markdown += `- Alternatives: ${quality.narrativeQuality.alternatives}\n`;
2510
- markdown += `- Risks: ${quality.narrativeQuality.risks}\n\n`;
2511
-
2512
- markdown += `### Link Quality\n`;
2513
- markdown += `- Rich Reason Ratio: ${quality.linkQuality.richReasonRatio} (${quality.linkQuality.richLinks}/${quality.linkQuality.totalLinks} links)\n`;
2514
- markdown += `- Approved Link Ratio: ${quality.linkQuality.approvedRatio} (${quality.linkQuality.approvedLinks}/${quality.linkQuality.totalLinks} links)\n\n`;
2515
-
2516
- // Story 4.2: Add restart metrics section
2517
- if (restart) {
2518
- markdown += `## Restart Metrics\n\n`;
2519
- markdown += `### Success Rate\n`;
2520
- markdown += `- **Success Rate**: ${restart.successRate.successRate} (${restart.successRate.success}/${restart.successRate.total} attempts)\n`;
2521
- markdown += `- **Meets Target**: ${restart.successRate.meetsTarget ? '✅ Yes' : '❌ No'}\n`;
2522
- markdown += `- Failures: ${restart.successRate.failure}\n\n`;
2523
-
2524
- if (restart.latency) {
2525
- markdown += `### Latency (Percentiles)\n\n`;
2526
- markdown += `**Full Mode (Narrative + Links)**\n`;
2527
- markdown += `- p50: ${restart.latency.full.p50}ms\n`;
2528
- markdown += `- p95: ${restart.latency.full.p95}ms\n`;
2529
- markdown += `- p99: ${restart.latency.full.p99}ms\n`;
2530
- markdown += `- Count: ${restart.latency.full.count}\n\n`;
2531
-
2532
- markdown += `**Summary Mode**\n`;
2533
- markdown += `- p50: ${restart.latency.summary.p50}ms\n`;
2534
- markdown += `- p95: ${restart.latency.summary.p95}ms\n`;
2535
- markdown += `- p99: ${restart.latency.summary.p99}ms\n`;
2536
- markdown += `- Count: ${restart.latency.summary.count}\n\n`;
2537
- }
2538
- }
2539
-
2540
- markdown += `## Thresholds\n\n`;
2541
- markdown += `- Narrative Coverage: ≥ ${thresholds.narrativeCoverage * 100}%\n`;
2542
- markdown += `- Link Coverage: ≥ ${thresholds.linkCoverage * 100}%\n`;
2543
- markdown += `- Rich Reason Ratio: ≥ ${thresholds.richReasonRatio * 100}%\n`;
2544
- markdown += `- Restart Success Rate: ≥ ${thresholds.restartSuccessRate * 100}%\n`;
2545
- markdown += `- Restart Latency p95 (Full): ≤ ${thresholds.restartLatencyP95Full}ms\n`;
2546
- markdown += `- Restart Latency p95 (Summary): ≤ ${thresholds.restartLatencyP95Summary}ms\n\n`;
2547
-
2548
- if (recommendations.length > 0) {
2549
- markdown += `## ⚠️ Recommendations\n\n`;
2550
- recommendations.forEach((rec, idx) => {
2551
- markdown += `${idx + 1}. **${rec.type}**: ${rec.message}\n`;
2552
- markdown += ` - Target: ${rec.target}, Current: ${rec.current}\n\n`;
2553
- });
2554
- } else {
2555
- markdown += `## ✅ All quality targets met!\n\n`;
2556
- }
2557
-
2558
- return markdown;
2559
- }
2560
-
2561
- /**
2562
- * MAMA Public API
2563
- *
2564
- * Simple, clean interface for Claude to interact with MAMA
2565
- * Hides complex implementation details (embeddings, vector search, graph queries)
2566
- *
2567
- * Key Principles:
2568
- * 1. Simple API First - No complex configuration
2569
- * 2. Transparent Process - Each step is visible
2570
- * 3. Claude-First Design - Claude decides what to save
2571
- * 4. Non-Intrusive - Silent failures for helpers (suggest)
2572
- */
2573
- // ════════════════════════════════════════════════════════════════════════════
2574
- // MAMA API - Simplified to 4 MCP tools (2025-11-25)
2575
- //
2576
- // Design: LLM can infer decision evolution from time-ordered search results
2577
- // More tools = more constraints = less LLM flexibility
2578
- //
2579
- // Retained internal functions for future use, but MCP exposes only:
2580
- // save, search, update, load_checkpoint
2581
- // ════════════════════════════════════════════════════════════════════════════
2582
- const mama = {
2583
- // Core functions (used by 4 MCP tools)
2584
- save,
2585
- suggest,
2586
- list: listDecisions,
2587
- listCheckpoints,
2588
- updateOutcome,
2589
- saveCheckpoint,
2590
- loadCheckpoint,
2591
- // Legacy functions (retained for internal use, not exposed via MCP)
2592
- recall,
2593
- proposeLink,
2594
- approveLink,
2595
- rejectLink,
2596
- getPendingLinks,
2597
- deprecateAutoLinks,
2598
- calculateCoverage,
2599
- calculateQuality,
2600
- generateQualityReport,
2601
- logRestartAttempt,
2602
- calculateRestartSuccessRate,
2603
- calculateRestartLatency,
2604
- getRestartMetrics,
2605
- scanAutoLinks,
2606
- createLinkBackup,
2607
- generatePreCleanupReport,
2608
- restoreLinkBackup,
2609
- verifyBackupExists,
2610
- deleteAutoLinks,
2611
- validateCleanupResult,
2612
- };
2613
-
2614
- module.exports = mama;