@jungjaehoon/mama-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1180 @@
1
+ /**
2
+ * MAMA (Memory-Augmented MCP Architecture) - Decision Context Formatter
3
+ *
4
+ * Formats decision history with token budget enforcement and top-N selection
5
+ * Tasks: 6.1-6.6, 8.1-8.5 (Context formatting with top-N selection)
6
+ * AC #1: Context under 500 tokens
7
+ * AC #4: Rolling summary for large histories
8
+ * AC #5: Top-N selection with summary
9
+ *
10
+ * @module decision-formatter
11
+ * @version 2.0
12
+ * @date 2025-11-14
13
+ */
14
+
15
+ const { info, error: logError } = require('./debug-logger');
16
+ const { formatTopNContext } = require('./relevance-scorer');
17
+
18
+ /**
19
+ * Format decision context for Claude injection with top-N selection
20
+ *
21
+ * Task 6.1-6.2, 8.1-8.5: Build context format template with top-N selection
22
+ * AC #1: Format decision history
23
+ * AC #4: Handle large histories with rolling summary
24
+ * AC #5: Top-N selection with summary (top 3 full detail, rest summarized)
25
+ *
26
+ * Story 014.7.10 - Task 5: Fallback Formatting
27
+ * Tries Instant Answer format first (if trust_context available), falls back to legacy
28
+ *
29
+ * @param {Array<Object>} decisions - Decision chain (sorted by relevance)
30
+ * @param {Object} options - Formatting options
31
+ * @param {number} options.maxTokens - Token budget (default: 500)
32
+ * @param {boolean} options.useTopN - Use top-N selection (default: true for 4+ decisions)
33
+ * @param {number} options.topN - Number of decisions for full detail (default: 3)
34
+ * @returns {string} Formatted context for injection
35
+ */
36
+ function formatContext(decisions, options = {}) {
37
+ const {
38
+ maxTokens = 500,
39
+ useTopN = decisions.length >= 4, // Auto-enable for 4+ decisions
40
+ topN = 3,
41
+ useTeaser = true, // New: Use Teaser format to encourage interaction
42
+ } = options;
43
+
44
+ if (!decisions || decisions.length === 0) {
45
+ return null;
46
+ }
47
+
48
+ // New approach: Teaser format (curiosity-driven)
49
+ // MAMA = Librarian: Shows book previews, Claude decides to read
50
+ if (useTeaser) {
51
+ // Show top 3 results (Google-style)
52
+ const teaserList = formatTeaserList(decisions, topN);
53
+
54
+ if (teaserList) {
55
+ return teaserList;
56
+ }
57
+ }
58
+
59
+ // Fallback: Legacy format
60
+ return formatLegacyContext(decisions, { maxTokens, useTopN, topN });
61
+ }
62
+
63
+ /**
64
+ * Format decisions using legacy format (no trust context)
65
+ *
66
+ * Story 014.7.10 - Task 5.1: Fallback formatting
67
+ * AC #3: Graceful degradation for decisions without trust_context
68
+ *
69
+ * @param {Array<Object>} decisions - Decision chain (sorted by relevance)
70
+ * @param {Object} options - Formatting options
71
+ * @param {number} options.maxTokens - Token budget (default: 500)
72
+ * @param {boolean} options.useTopN - Use top-N selection (default: true for 4+ decisions)
73
+ * @param {number} options.topN - Number of decisions for full detail (default: 3)
74
+ * @returns {string} Formatted context (legacy format)
75
+ */
76
+ function formatLegacyContext(decisions, options = {}) {
77
+ if (!decisions || decisions.length === 0) {
78
+ return null;
79
+ }
80
+
81
+ const { maxTokens = 500, useTopN = decisions.length >= 4, topN = 3 } = options;
82
+
83
+ // Task 8.1: Use top-N selection for 4+ decisions (AC #5)
84
+ let context;
85
+
86
+ if (useTopN && decisions.length > topN) {
87
+ // Task 8.1: Modify to use top-N selection
88
+ context = formatWithTopN(decisions, topN);
89
+ } else {
90
+ // Find current decision (superseded_by = NULL or missing)
91
+ const current = decisions.find((d) => !d.superseded_by) || decisions[0];
92
+ const history = decisions.filter((d) => d.id !== current.id);
93
+
94
+ // Task 6.2: Build context format template (legacy)
95
+ if (decisions.length <= 3) {
96
+ // Small history: Full details
97
+ context = formatSmallHistory(current, history);
98
+ } else {
99
+ // Large history: Rolling summary
100
+ context = formatLargeHistory(current, history);
101
+ }
102
+ }
103
+
104
+ // Task 6.3, 8.4: Ensure token budget stays under 500 tokens
105
+ return ensureTokenBudget(context, maxTokens);
106
+ }
107
+
108
+ /**
109
+ * Format with top-N selection
110
+ *
111
+ * Task 8.2-8.3: Full detail for top 3, summary for rest
112
+ * AC #5: Top-N selection with summary
113
+ *
114
+ * @param {Array<Object>} decisions - All decisions (sorted by relevance)
115
+ * @param {number} topN - Number of decisions for full detail
116
+ * @returns {string} Formatted context
117
+ */
118
+ function formatWithTopN(decisions, topN) {
119
+ // Use formatTopNContext from relevance-scorer.js
120
+ const { full, summary } = formatTopNContext(decisions, topN);
121
+
122
+ const current = full[0]; // Highest relevance
123
+ const topic = current.topic;
124
+
125
+ // Task 8.2: Full detail for top 3 decisions
126
+ let context = `
127
+ 🧠 DECISION HISTORY: ${topic}
128
+
129
+ Top ${full.length} Most Relevant Decisions:
130
+ `.trim();
131
+
132
+ for (let i = 0; i < full.length; i++) {
133
+ const d = full[i];
134
+ const duration = calculateDuration(d.created_at);
135
+ const outcomeEmoji = getOutcomeEmoji(d.outcome);
136
+ const relevancePercent = Math.round((d.relevanceScore || 0) * 100);
137
+
138
+ context += `\n\n${i + 1}. ${d.decision} (${duration}, relevance: ${relevancePercent}%) ${outcomeEmoji}`;
139
+ context += `\n Reasoning: ${d.reasoning || 'N/A'}`;
140
+
141
+ if (d.outcome === 'FAILED') {
142
+ context += `\n āš ļø Failure: ${d.failure_reason || 'Unknown reason'}`;
143
+ }
144
+ }
145
+
146
+ // Task 8.3: Summary for rest (count, duration, key failures only)
147
+ if (summary && summary.count > 0) {
148
+ context += `\n\n━━━━━━━━━━━━━━━━━━━━━━━`;
149
+ context += `\nHistory: ${summary.count} additional decisions over ${summary.duration_days} days`;
150
+
151
+ if (summary.failures && summary.failures.length > 0) {
152
+ context += `\n\nāš ļø Other Failures:`;
153
+ for (const failure of summary.failures) {
154
+ context += `\n- ${failure.decision}: ${failure.reason || 'Unknown'}`;
155
+ }
156
+ }
157
+ }
158
+
159
+ return context;
160
+ }
161
+
162
+ /**
163
+ * Format small decision history (3 or fewer)
164
+ *
165
+ * @param {Object} current - Current decision
166
+ * @param {Array<Object>} history - Previous decisions
167
+ * @returns {string} Formatted context
168
+ */
169
+ function formatSmallHistory(current, history) {
170
+ const duration = calculateDuration(current.created_at);
171
+
172
+ let context = `
173
+ 🧠 DECISION HISTORY: ${current.topic}
174
+
175
+ Current: ${current.decision} (${duration}, confidence: ${current.confidence})
176
+ Reasoning: ${current.reasoning || 'N/A'}
177
+ `.trim();
178
+
179
+ // Add history details
180
+ if (history.length > 0) {
181
+ context += '\n\nPrevious Decisions:\n';
182
+
183
+ for (const decision of history) {
184
+ const durationDays = calculateDurationDays(
185
+ decision.created_at,
186
+ decision.updated_at || Date.now()
187
+ );
188
+ const outcomeEmoji = getOutcomeEmoji(decision.outcome);
189
+
190
+ context += `- ${decision.decision} (${durationDays} days) ${outcomeEmoji}\n`;
191
+
192
+ if (decision.outcome === 'FAILED') {
193
+ context += ` Reason: ${decision.failure_reason || 'Unknown'}\n`;
194
+ }
195
+ }
196
+ }
197
+
198
+ return context;
199
+ }
200
+
201
+ /**
202
+ * Format large decision history (4+ decisions)
203
+ *
204
+ * Task 6.2: Rolling summary for large histories
205
+ * AC #4: Highlight top 3 failures
206
+ *
207
+ * @param {Object} current - Current decision
208
+ * @param {Array<Object>} history - Previous decisions
209
+ * @returns {string} Formatted context with rolling summary
210
+ */
211
+ function formatLargeHistory(current, history) {
212
+ const duration = calculateDuration(current.created_at);
213
+ // Include current decision in total duration calculation
214
+ const allDecisions = [current, ...history];
215
+ const totalDuration = calculateTotalDuration(allDecisions);
216
+
217
+ // Extract failures
218
+ const failures = history.filter((d) => d.outcome === 'FAILED');
219
+ const topFailures = failures.slice(0, 3);
220
+
221
+ // Get last evolution
222
+ const lastEvolution = history.length > 0 ? history[0] : null;
223
+
224
+ let context = `
225
+ 🧠 DECISION HISTORY: ${current.topic}
226
+
227
+ Current: ${current.decision} (confidence: ${current.confidence})
228
+ Reasoning: ${current.reasoning || 'N/A'}
229
+
230
+ History: ${history.length + 1} decisions over ${totalDuration}
231
+ `.trim();
232
+
233
+ // Add key failures
234
+ if (topFailures.length > 0) {
235
+ context += '\n\nāš ļø Key Failures (avoid these):\n';
236
+
237
+ for (const failure of topFailures) {
238
+ context += `- ${failure.decision}: ${failure.failure_reason || 'Unknown reason'}\n`;
239
+ }
240
+ }
241
+
242
+ // Add last evolution
243
+ if (lastEvolution) {
244
+ context += `\nLast evolution: ${lastEvolution.decision} → ${current.decision}`;
245
+
246
+ if (current.reasoning) {
247
+ const reasonSummary = current.reasoning.substring(0, 100);
248
+ context += ` (${reasonSummary}${current.reasoning.length > 100 ? '...' : ''})`;
249
+ }
250
+ }
251
+
252
+ return context;
253
+ }
254
+
255
+ /**
256
+ * Ensure token budget is enforced
257
+ *
258
+ * Task 6.3-6.5: Token budget enforcement
259
+ * AC #1: Context stays under 500 tokens
260
+ *
261
+ * @param {string} text - Context text
262
+ * @param {number} maxTokens - Maximum tokens allowed
263
+ * @returns {string} Truncated text if needed
264
+ */
265
+ function ensureTokenBudget(text, maxTokens) {
266
+ // Task 6.4: Token estimation (~1 token per 4 characters)
267
+ const estimatedTokens = estimateTokens(text);
268
+
269
+ if (estimatedTokens <= maxTokens) {
270
+ return text;
271
+ }
272
+
273
+ // Task 6.5: Truncate to fit budget
274
+ const ratio = maxTokens / estimatedTokens;
275
+ const truncated = text.substring(0, Math.floor(text.length * ratio));
276
+
277
+ return truncated + '\n\n... (truncated to fit token budget)';
278
+ }
279
+
280
+ /**
281
+ * Estimate token count from text
282
+ *
283
+ * Task 6.4: Simple token estimation
284
+ * Heuristic: ~1 token per 4 characters
285
+ *
286
+ * @param {string} text - Text to estimate
287
+ * @returns {number} Estimated token count
288
+ */
289
+ function estimateTokens(text) {
290
+ // Task 6.4: ~1 token per 4 characters
291
+ return Math.ceil(text.length / 4);
292
+ }
293
+
294
+ /**
295
+ * Calculate human-readable duration
296
+ *
297
+ * @param {number|string} timestamp - Unix timestamp (ms) or ISO 8601 string
298
+ * @returns {string} Human-readable duration (e.g., "3 days ago")
299
+ */
300
+ function calculateDuration(timestamp) {
301
+ // Handle Unix timestamp (number or numeric string) and ISO 8601 string
302
+ let ts;
303
+ if (typeof timestamp === 'string') {
304
+ // Try parsing as number first (e.g., "1763971277689")
305
+ const num = Number(timestamp);
306
+ ts = isNaN(num) ? Date.parse(timestamp) : num;
307
+ } else {
308
+ ts = timestamp;
309
+ }
310
+
311
+ if (isNaN(ts) || ts === null || ts === undefined) {
312
+ return 'unknown';
313
+ }
314
+
315
+ const now = Date.now();
316
+ const diffMs = now - ts;
317
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
318
+
319
+ if (diffDays === 0) {
320
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
321
+ if (diffHours === 0) {
322
+ const diffMins = Math.floor(diffMs / (1000 * 60));
323
+ return `${diffMins} min${diffMins !== 1 ? 's' : ''} ago`;
324
+ }
325
+ return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
326
+ }
327
+
328
+ return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
329
+ }
330
+
331
+ /**
332
+ * Calculate duration between two timestamps
333
+ *
334
+ * @param {number|string} start - Start timestamp (ms) or ISO 8601 string
335
+ * @param {number|string} end - End timestamp (ms) or ISO 8601 string
336
+ * @returns {number} Duration in days
337
+ */
338
+ function calculateDurationDays(start, end) {
339
+ // Handle Unix timestamp (number or numeric string) and ISO 8601 string
340
+ let startTs, endTs;
341
+
342
+ if (typeof start === 'string') {
343
+ const num = Number(start);
344
+ startTs = isNaN(num) ? Date.parse(start) : num;
345
+ } else {
346
+ startTs = start;
347
+ }
348
+
349
+ if (typeof end === 'string') {
350
+ const num = Number(end);
351
+ endTs = isNaN(num) ? Date.parse(end) : num;
352
+ } else {
353
+ endTs = end;
354
+ }
355
+
356
+ if (isNaN(startTs) || isNaN(endTs)) {
357
+ return 0;
358
+ }
359
+
360
+ const diffMs = endTs - startTs;
361
+ return Math.floor(diffMs / (1000 * 60 * 60 * 24));
362
+ }
363
+
364
+ /**
365
+ * Calculate total duration across decision history
366
+ *
367
+ * @param {Array<Object>} history - Decision history
368
+ * @returns {string} Human-readable total duration
369
+ */
370
+ function calculateTotalDuration(history) {
371
+ if (history.length === 0) {
372
+ return 'N/A';
373
+ }
374
+
375
+ // Convert all timestamps to numbers for comparison
376
+ const timestamps = history
377
+ .map((d) => {
378
+ const created = typeof d.created_at === 'string' ? Date.parse(d.created_at) : d.created_at;
379
+ const updated = d.updated_at
380
+ ? typeof d.updated_at === 'string'
381
+ ? Date.parse(d.updated_at)
382
+ : d.updated_at
383
+ : created;
384
+ return { created, updated };
385
+ })
386
+ .filter((t) => !isNaN(t.created) && !isNaN(t.updated));
387
+
388
+ if (timestamps.length === 0) {
389
+ return 'N/A';
390
+ }
391
+
392
+ const earliest = Math.min(...timestamps.map((t) => t.created));
393
+ const latest = Math.max(...timestamps.map((t) => t.updated));
394
+
395
+ const durationDays = calculateDurationDays(earliest, latest);
396
+
397
+ if (durationDays < 7) {
398
+ return `${durationDays} days`;
399
+ } else if (durationDays < 30) {
400
+ const weeks = Math.floor(durationDays / 7);
401
+ return `${weeks} week${weeks !== 1 ? 's' : ''}`;
402
+ } else {
403
+ const months = Math.floor(durationDays / 30);
404
+ return `${months} month${months !== 1 ? 's' : ''}`;
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Get emoji for outcome
410
+ *
411
+ * @param {string} outcome - Decision outcome
412
+ * @returns {string} Emoji representation
413
+ */
414
+ function getOutcomeEmoji(outcome) {
415
+ const emojiMap = {
416
+ SUCCESS: 'āœ…',
417
+ FAILED: 'āŒ',
418
+ PARTIAL: 'āš ļø',
419
+ ONGOING: 'ā³',
420
+ };
421
+
422
+ return emojiMap[outcome] || '';
423
+ }
424
+
425
+ /**
426
+ * Format context in Claude-friendly Instant Answer format
427
+ *
428
+ * Story 014.7.10: Claude-Friendly Context Formatting
429
+ * AC #1: Instant Answer format with trust components
430
+ *
431
+ * Prioritizes:
432
+ * 1. Quick answer (one line)
433
+ * 2. Code example (if available)
434
+ * 3. Trust evidence (5 components)
435
+ * 4. Minimal reasoning (< 150 chars)
436
+ *
437
+ * @param {Object} decision - Decision object
438
+ * @param {Object} options - Formatting options
439
+ * @param {number} options.maxTokens - Token budget (default: 500)
440
+ * @returns {string|null} Formatted instant answer or null
441
+ */
442
+ function formatInstantAnswer(decision, options = {}) {
443
+ const { maxTokens = 500 } = options;
444
+
445
+ if (!decision) {
446
+ return null;
447
+ }
448
+
449
+ // Extract quick answer (first line of decision)
450
+ const quickAnswer = extractQuickAnswer(decision);
451
+
452
+ if (!quickAnswer) {
453
+ return null;
454
+ }
455
+
456
+ // Extract code example (from reasoning)
457
+ const codeExample = extractCodeExample(decision);
458
+
459
+ // Format trust context
460
+ const trustSection = formatTrustContext(decision.trust_context);
461
+
462
+ // Build output
463
+ let output = `⚔ INSTANT ANSWER\n\n${quickAnswer}`;
464
+
465
+ if (codeExample) {
466
+ output += `\n\n${codeExample}`;
467
+ }
468
+
469
+ if (trustSection) {
470
+ output += `\n\n${trustSection}`;
471
+ }
472
+
473
+ // Token budget check
474
+ if (estimateTokens(output) > maxTokens) {
475
+ output = truncateToFit(output, maxTokens);
476
+ }
477
+
478
+ return output;
479
+ }
480
+
481
+ /**
482
+ * Extract quick answer from decision
483
+ *
484
+ * Returns first line or sentence from decision field
485
+ *
486
+ * @param {Object} decision - Decision object
487
+ * @returns {string|null} Quick answer or null
488
+ */
489
+ function extractQuickAnswer(decision) {
490
+ if (!decision.decision || typeof decision.decision !== 'string') {
491
+ return null;
492
+ }
493
+
494
+ const text = decision.decision.trim();
495
+
496
+ if (text.length === 0) {
497
+ return null;
498
+ }
499
+
500
+ // Extract first line
501
+ const lines = text.split('\n');
502
+ const firstLine = lines[0].trim();
503
+
504
+ // Check if first line contains multiple real sentences
505
+ // Match period/exclamation/question mark followed by space and capital letter
506
+ const sentenceMatch = firstLine.match(/^.+?[.!?](?=\s+[A-Z])/);
507
+ if (sentenceMatch) {
508
+ // Multiple sentences detected - return first sentence
509
+ return sentenceMatch[0].trim();
510
+ }
511
+
512
+ // Single sentence or no sentence boundary - use full first line if reasonable
513
+ if (firstLine.length <= 150) {
514
+ return firstLine;
515
+ }
516
+
517
+ // First line too long - truncate to 100 chars
518
+ return firstLine.substring(0, 100) + '...';
519
+ }
520
+
521
+ /**
522
+ * Extract code example from reasoning
523
+ *
524
+ * Looks for markdown code blocks (```...```)
525
+ *
526
+ * @param {Object} decision - Decision object
527
+ * @returns {string|null} Code example or null
528
+ */
529
+ function extractCodeExample(decision) {
530
+ if (!decision.reasoning || typeof decision.reasoning !== 'string') {
531
+ return null;
532
+ }
533
+
534
+ // Match markdown code blocks
535
+ const codeBlockRegex = /```[\s\S]*?```/;
536
+ const match = decision.reasoning.match(codeBlockRegex);
537
+
538
+ if (match) {
539
+ return match[0];
540
+ }
541
+
542
+ // Check if decision field contains code patterns
543
+ if (decision.decision && typeof decision.decision === 'string') {
544
+ const hasCode =
545
+ decision.decision.includes('mama.save(') ||
546
+ decision.decision.includes('await ') ||
547
+ decision.decision.includes('=>');
548
+
549
+ if (hasCode) {
550
+ // Wrap in code block
551
+ return `\`\`\`javascript\n${decision.decision}\n\`\`\``;
552
+ }
553
+ }
554
+
555
+ return null;
556
+ }
557
+
558
+ /**
559
+ * Format trust context section
560
+ *
561
+ * Story 014.7.10 AC #2: Trust Context display
562
+ *
563
+ * Shows 5 trust components:
564
+ * 1. Source transparency
565
+ * 2. Causality
566
+ * 3. Verifiability
567
+ * 4. Context relevance
568
+ * 5. Track record
569
+ *
570
+ * @param {Object} trustCtx - Trust context object
571
+ * @returns {string|null} Formatted trust section or null
572
+ */
573
+ function formatTrustContext(trustCtx) {
574
+ if (!trustCtx) {
575
+ return null;
576
+ }
577
+
578
+ const lines = ['━'.repeat(40), 'šŸ” WHY TRUST THIS?', ''];
579
+
580
+ let hasContent = false;
581
+
582
+ // 1. Source transparency
583
+ if (trustCtx.source) {
584
+ const { file, line, author, timestamp } = trustCtx.source;
585
+ const timeAgo = calculateDuration(timestamp);
586
+ lines.push(`šŸ“ Source: ${file}:${line} (${timeAgo}, by ${author})`);
587
+ hasContent = true;
588
+ }
589
+
590
+ // 2. Causality
591
+ if (trustCtx.causality && trustCtx.causality.impact) {
592
+ lines.push(`šŸ”— Reason: ${trustCtx.causality.impact}`);
593
+ hasContent = true;
594
+ }
595
+
596
+ // 3. Verifiability
597
+ if (trustCtx.verification) {
598
+ const { test_file, result } = trustCtx.verification;
599
+ const status = result === 'success' ? 'passed' : result;
600
+ lines.push(`āœ… Verified: ${test_file} ${status}`);
601
+ hasContent = true;
602
+ }
603
+
604
+ // 4. Context relevance
605
+ if (trustCtx.context_match && trustCtx.context_match.user_intent) {
606
+ lines.push(`šŸŽÆ Applies to: ${trustCtx.context_match.user_intent}`);
607
+ hasContent = true;
608
+ }
609
+
610
+ // 5. Track record
611
+ if (trustCtx.track_record) {
612
+ const { recent_successes, recent_failures } = trustCtx.track_record;
613
+ const successCount = recent_successes?.length || 0;
614
+ const failureCount = recent_failures?.length || 0;
615
+ const total = successCount + failureCount;
616
+
617
+ if (total > 0) {
618
+ lines.push(`šŸ“Š Track record: ${successCount}/${total} recent successes`);
619
+ hasContent = true;
620
+ }
621
+ }
622
+
623
+ if (!hasContent) {
624
+ return null;
625
+ }
626
+
627
+ lines.push('━'.repeat(40));
628
+
629
+ return lines.join('\n');
630
+ }
631
+
632
+ /**
633
+ * Truncate output to fit token budget
634
+ *
635
+ * Prioritizes:
636
+ * 1. Keep quick answer (always)
637
+ * 2. Keep code example (if fits)
638
+ * 3. Trim trust section (if needed)
639
+ *
640
+ * @param {string} output - Full output
641
+ * @param {number} maxTokens - Maximum tokens
642
+ * @returns {string} Truncated output
643
+ */
644
+ function truncateToFit(output, maxTokens) {
645
+ // Split sections
646
+ const sections = output.split('\n\n');
647
+ const quickAnswer = sections[0]; // "⚔ INSTANT ANSWER\n\n[answer]"
648
+
649
+ // Always keep quick answer
650
+ let result = quickAnswer;
651
+ let remainingTokens = maxTokens - estimateTokens(result);
652
+
653
+ // Try to add code example
654
+ const codeIndex = sections.findIndex((s) => s.startsWith('```'));
655
+ if (codeIndex > 0) {
656
+ const codeSection = sections[codeIndex];
657
+ const codeTokens = estimateTokens(codeSection);
658
+
659
+ if (codeTokens <= remainingTokens) {
660
+ result += '\n\n' + codeSection;
661
+ remainingTokens -= codeTokens;
662
+ }
663
+ }
664
+
665
+ // Try to add trust section (trimmed if needed)
666
+ const trustIndex = sections.findIndex((s) => s.startsWith('━'));
667
+ if (trustIndex > 0 && remainingTokens > 50) {
668
+ const trustSection = sections[trustIndex];
669
+ const trustTokens = estimateTokens(trustSection);
670
+
671
+ if (trustTokens <= remainingTokens) {
672
+ result += '\n\n' + trustSection;
673
+ } else {
674
+ // Trim trust section to fit
675
+ const trustLines = trustSection.split('\n');
676
+ let trimmed = trustLines[0] + '\n' + trustLines[1] + '\n'; // Header
677
+
678
+ for (let i = 2; i < trustLines.length - 1; i++) {
679
+ const line = trustLines[i] + '\n';
680
+ if (estimateTokens(trimmed + line) <= remainingTokens - 10) {
681
+ trimmed += line;
682
+ } else {
683
+ break;
684
+ }
685
+ }
686
+
687
+ trimmed += trustLines[trustLines.length - 1]; // Footer
688
+ result += '\n\n' + trimmed;
689
+ }
690
+ }
691
+
692
+ return result;
693
+ }
694
+
695
+ /**
696
+ * Format multiple decisions as Google-style search results
697
+ *
698
+ * Shows top N results with relevance scores, allowing user to choose
699
+ * Story: Google-style teaser list for better UX
700
+ *
701
+ * @param {Array<Object>} decisions - Decision objects (sorted by relevance)
702
+ * @param {number} topN - Number of results to show (default: 3)
703
+ * @returns {string|null} Formatted teaser list or null
704
+ */
705
+ function formatTeaserList(decisions, topN = 3) {
706
+ if (!decisions || decisions.length === 0) {
707
+ return null;
708
+ }
709
+
710
+ const topDecisions = decisions.slice(0, topN);
711
+ const count = topDecisions.length;
712
+
713
+ let output = `šŸ’” MAMA found ${count} related topic${count > 1 ? 's' : ''}:\n`;
714
+
715
+ for (let i = 0; i < topDecisions.length; i++) {
716
+ const d = topDecisions[i];
717
+ const relevance = Math.round((d.similarity || d.confidence || 0) * 100);
718
+
719
+ // Preview (max 60 chars)
720
+ const preview = d.decision.length > 60 ? d.decision.substring(0, 60) + '...' : d.decision;
721
+
722
+ output += `\n${i + 1}. ${d.topic} (${relevance}% match)`;
723
+ output += `\n "${preview}"`;
724
+
725
+ // Recency metadata (NEW - Gaussian Decay)
726
+ // Shows age and recency impact to help Claude adjust parameters
727
+ if (d.recency_age_days !== undefined && d.created_at) {
728
+ const timeAgo = calculateDuration(d.created_at); // Use human-readable time (mins/hours/days)
729
+ const recencyScore = d.recency_score ? Math.round(d.recency_score * 100) : null;
730
+ const finalScore = d.final_score ? Math.round(d.final_score * 100) : null;
731
+
732
+ output += `\n ā° ${timeAgo}`;
733
+ if (recencyScore !== null && finalScore !== null) {
734
+ output += ` | Recency: ${recencyScore}% | Final: ${finalScore}%`;
735
+ }
736
+ }
737
+
738
+ output += `\n šŸ” mama.recall('${d.topic}')`;
739
+
740
+ if (i < topDecisions.length - 1) {
741
+ output += '\n';
742
+ }
743
+ }
744
+
745
+ return output;
746
+ }
747
+
748
+ /**
749
+ * Format decision as curiosity-inducing teaser
750
+ *
751
+ * MAMA = Librarian: Shows book preview, Claude decides to read
752
+ * "Just enough context to spark curiosity" - makes Claude want to learn more
753
+ *
754
+ * @param {Object} decision - Decision object
755
+ * @returns {string|null} Formatted teaser or null
756
+ */
757
+ function formatTeaser(decision) {
758
+ if (!decision) {
759
+ return null;
760
+ }
761
+
762
+ const timeAgo = calculateDuration(decision.created_at);
763
+
764
+ // Extract preview (first 60 chars)
765
+ const preview =
766
+ decision.decision.length > 60 ? decision.decision.substring(0, 60) + '...' : decision.decision;
767
+
768
+ // Extract files from trust_context or show generic
769
+ let files = 'Multiple files';
770
+ if (decision.trust_context?.source?.file) {
771
+ const fileStr = decision.trust_context.source.file;
772
+ const fileList = fileStr.split(',').map((f) => f.trim());
773
+
774
+ if (fileList.length === 1) {
775
+ files = fileList[0];
776
+ } else if (fileList.length === 2) {
777
+ files = fileList.join(', ');
778
+ } else {
779
+ files = `${fileList[0]}, ${fileList[1]} (+${fileList.length - 2})`;
780
+ }
781
+ }
782
+
783
+ // Build teaser
784
+ const teaser = `
785
+ šŸ’” MAMA has related info
786
+
787
+ šŸ“š Topic: ${decision.topic}
788
+ šŸ“– Preview: "${preview}"
789
+ šŸ“ Files: ${files}
790
+ ā° Updated: ${timeAgo}
791
+
792
+ šŸ” Read more: mama.recall('${decision.topic}')
793
+ `.trim();
794
+
795
+ return teaser;
796
+ }
797
+
798
+ /**
799
+ * Extract files from source string
800
+ * Helper for formatTeaser
801
+ *
802
+ * @param {string} source - Source file string (may be comma-separated)
803
+ * @returns {string} Formatted file list
804
+ */
805
+ function extractFiles(source) {
806
+ if (!source) {
807
+ return 'Multiple files';
808
+ }
809
+
810
+ const files = source.split(',').map((f) => f.trim());
811
+
812
+ if (files.length === 1) {
813
+ return files[0];
814
+ } else if (files.length === 2) {
815
+ return files.join(', ');
816
+ } else {
817
+ return `${files[0]}, ${files[1]} (+${files.length - 2})`;
818
+ }
819
+ }
820
+
821
+ /**
822
+ * Format mama.recall() results in readable format
823
+ *
824
+ * Transforms raw JSON into readable markdown with:
825
+ * - Properly formatted reasoning (markdown preserved)
826
+ * - Parsed trust_context (not JSON string)
827
+ * - Clean metadata display
828
+ *
829
+ * @param {Array<Object>} decisions - Decision history from recall()
830
+ * @returns {string} Formatted output for human reading
831
+ */
832
+ function formatRecall(decisions, semanticEdges = null) {
833
+ if (!decisions || decisions.length === 0) {
834
+ return 'āŒ No decisions found';
835
+ }
836
+
837
+ // Single decision: full detail
838
+ if (decisions.length === 1) {
839
+ return formatSingleDecision(decisions[0], semanticEdges);
840
+ }
841
+
842
+ // Multiple decisions: history view
843
+ return formatDecisionHistory(decisions, semanticEdges);
844
+ }
845
+
846
+ /**
847
+ * Format single decision with full detail
848
+ *
849
+ * @param {Object} decision - Single decision object
850
+ * @returns {string} Formatted decision
851
+ */
852
+ function formatSingleDecision(decision) {
853
+ const timeAgo = calculateDuration(decision.created_at);
854
+ const confidencePercent = Math.round((decision.confidence || 0) * 100);
855
+ const outcomeEmoji = getOutcomeEmoji(decision.outcome);
856
+ const outcomeText = decision.outcome || 'Not yet tracked';
857
+
858
+ let output = `
859
+ šŸ“‹ Decision: ${decision.topic}
860
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
861
+
862
+ ${decision.reasoning || decision.decision}
863
+ `.trim();
864
+
865
+ // Metadata section
866
+ output += `\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`;
867
+ output += `\nšŸ“Š Confidence: ${confidencePercent}%`;
868
+ output += `\nā° Created: ${timeAgo}`;
869
+ output += `\n${outcomeEmoji} Outcome: ${outcomeText}`;
870
+
871
+ if (decision.outcome === 'FAILED' && decision.failure_reason) {
872
+ output += `\nāš ļø Failure reason: ${decision.failure_reason}`;
873
+ }
874
+
875
+ // Trust context section (if available)
876
+ const trustCtx = parseTrustContext(decision.trust_context);
877
+ if (trustCtx) {
878
+ output += '\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
879
+ output += '\nšŸ” Trust Context\n';
880
+
881
+ if (trustCtx.source) {
882
+ const { file, line, author } = trustCtx.source;
883
+ output += `\nšŸ“ Source: ${file}${line ? ':' + line : ''} (by ${author || 'unknown'})`;
884
+ }
885
+
886
+ if (trustCtx.causality?.impact) {
887
+ output += `\nšŸ”— Impact: ${trustCtx.causality.impact}`;
888
+ }
889
+
890
+ if (trustCtx.verification) {
891
+ const { test_file, result } = trustCtx.verification;
892
+ const status = result === 'success' ? 'āœ… passed' : `āš ļø ${result}`;
893
+ output += `\n${status}: ${test_file || 'Verified'}`;
894
+ }
895
+
896
+ if (trustCtx.track_record) {
897
+ const { success_rate, sample_size } = trustCtx.track_record;
898
+ if (sample_size > 0) {
899
+ const rate = Math.round(success_rate * 100);
900
+ output += `\nšŸ“Š Track record: ${rate}% success (${sample_size} samples)`;
901
+ }
902
+ }
903
+ }
904
+
905
+ return output;
906
+ }
907
+
908
+ /**
909
+ * Format decision history (multiple decisions)
910
+ *
911
+ * @param {Array<Object>} decisions - Decision array
912
+ * @param {Object} [semanticEdges] - Semantic edges { refines, refined_by, contradicts, contradicted_by }
913
+ * @returns {string} Formatted history
914
+ */
915
+ function formatDecisionHistory(decisions, semanticEdges = null) {
916
+ const topic = decisions[0].topic;
917
+ const latest = decisions[0];
918
+ const older = decisions.slice(1);
919
+
920
+ let output = `
921
+ šŸ“‹ Decision History: ${topic}
922
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
923
+
924
+ Latest Decision (${calculateDuration(latest.created_at)}):
925
+ ${latest.decision}
926
+ `.trim();
927
+
928
+ // Show brief reasoning if available
929
+ if (latest.reasoning) {
930
+ const briefReasoning = latest.reasoning.split('\n')[0].substring(0, 150);
931
+ output += `\n\nReasoning: ${briefReasoning}${latest.reasoning.length > 150 ? '...' : ''}`;
932
+ }
933
+
934
+ output += `\n\nConfidence: ${Math.round(latest.confidence * 100)}%`;
935
+
936
+ // Show older decisions (supersedes chain)
937
+ if (older.length > 0) {
938
+ output += '\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
939
+ output += `\nPrevious Decisions (${older.length}):\n`;
940
+
941
+ for (let i = 0; i < Math.min(older.length, 5); i++) {
942
+ const d = older[i];
943
+ const timeAgo = calculateDuration(d.created_at);
944
+ const emoji = getOutcomeEmoji(d.outcome);
945
+ output += `\n${i + 2}. ${d.decision} (${timeAgo}) ${emoji}`;
946
+
947
+ if (d.outcome === 'FAILED' && d.failure_reason) {
948
+ output += `\n āš ļø ${d.failure_reason}`;
949
+ }
950
+ }
951
+
952
+ if (older.length > 5) {
953
+ output += `\n\n... and ${older.length - 5} more`;
954
+ }
955
+ }
956
+
957
+ // Show semantic edges (related decisions)
958
+ if (semanticEdges) {
959
+ const totalEdges =
960
+ (semanticEdges.refines?.length || 0) +
961
+ (semanticEdges.refined_by?.length || 0) +
962
+ (semanticEdges.contradicts?.length || 0) +
963
+ (semanticEdges.contradicted_by?.length || 0);
964
+
965
+ if (totalEdges > 0) {
966
+ output += '\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
967
+ output += `\nšŸ”— Related Decisions (${totalEdges}):\n`;
968
+
969
+ // Refines (builds upon)
970
+ if (semanticEdges.refines && semanticEdges.refines.length > 0) {
971
+ output += '\n✨ Refines (builds upon):';
972
+ semanticEdges.refines.slice(0, 3).forEach((e) => {
973
+ const preview = e.decision.substring(0, 60);
974
+ output += `\n • ${e.topic}: ${preview}${e.decision.length > 60 ? '...' : ''}`;
975
+ });
976
+ if (semanticEdges.refines.length > 3) {
977
+ output += `\n ... and ${semanticEdges.refines.length - 3} more`;
978
+ }
979
+ }
980
+
981
+ // Refined by (later improvements)
982
+ if (semanticEdges.refined_by && semanticEdges.refined_by.length > 0) {
983
+ output += '\n\nšŸ”„ Refined by (later improvements):';
984
+ semanticEdges.refined_by.slice(0, 3).forEach((e) => {
985
+ const preview = e.decision.substring(0, 60);
986
+ output += `\n • ${e.topic}: ${preview}${e.decision.length > 60 ? '...' : ''}`;
987
+ });
988
+ if (semanticEdges.refined_by.length > 3) {
989
+ output += `\n ... and ${semanticEdges.refined_by.length - 3} more`;
990
+ }
991
+ }
992
+
993
+ // Contradicts
994
+ if (semanticEdges.contradicts && semanticEdges.contradicts.length > 0) {
995
+ output += '\n\n⚔ Contradicts:';
996
+ semanticEdges.contradicts.forEach((e) => {
997
+ const preview = e.decision.substring(0, 60);
998
+ output += `\n • ${e.topic}: ${preview}${e.decision.length > 60 ? '...' : ''}`;
999
+ });
1000
+ }
1001
+
1002
+ // Contradicted by
1003
+ if (semanticEdges.contradicted_by && semanticEdges.contradicted_by.length > 0) {
1004
+ output += '\n\nāŒ Contradicted by:';
1005
+ semanticEdges.contradicted_by.forEach((e) => {
1006
+ const preview = e.decision.substring(0, 60);
1007
+ output += `\n • ${e.topic}: ${preview}${e.decision.length > 60 ? '...' : ''}`;
1008
+ });
1009
+ }
1010
+ }
1011
+ }
1012
+
1013
+ output += '\n\nšŸ’” Tip: Review individual decisions for full context';
1014
+
1015
+ return output;
1016
+ }
1017
+
1018
+ /**
1019
+ * Parse trust_context (might be JSON string)
1020
+ *
1021
+ * @param {Object|string} trustContext - Trust context (object or JSON string)
1022
+ * @returns {Object|null} Parsed trust context
1023
+ */
1024
+ function parseTrustContext(trustContext) {
1025
+ if (!trustContext) {
1026
+ return null;
1027
+ }
1028
+
1029
+ // Already parsed
1030
+ if (typeof trustContext === 'object') {
1031
+ return trustContext;
1032
+ }
1033
+
1034
+ // Parse JSON string
1035
+ if (typeof trustContext === 'string') {
1036
+ try {
1037
+ return JSON.parse(trustContext);
1038
+ } catch (e) {
1039
+ return null;
1040
+ }
1041
+ }
1042
+
1043
+ return null;
1044
+ }
1045
+
1046
+ /**
1047
+ * Format recent decisions list (all topics, chronological)
1048
+ *
1049
+ * Readable format for Claude - no raw JSON
1050
+ * Shows: time, type (user/assistant), topic, preview, confidence, status
1051
+ *
1052
+ * @param {Array<Object>} decisions - Recent decisions (sorted by created_at DESC)
1053
+ * @param {Object} options - Formatting options
1054
+ * @param {number} options.limit - Max decisions to show (default: 20)
1055
+ * @returns {string} Formatted list
1056
+ */
1057
+ function formatList(decisions, options = {}) {
1058
+ const { limit = 20 } = options;
1059
+
1060
+ if (!decisions || decisions.length === 0) {
1061
+ return 'āŒ No decisions found';
1062
+ }
1063
+
1064
+ // Limit results
1065
+ const items = decisions.slice(0, limit);
1066
+
1067
+ let output = `šŸ“‹ Recent Decisions (Last ${items.length})\n`;
1068
+ output += '━'.repeat(60) + '\n';
1069
+
1070
+ for (let i = 0; i < items.length; i++) {
1071
+ const d = items[i];
1072
+ const timeAgo = calculateDuration(d.created_at);
1073
+ const type = d.user_involvement === 'approved' ? 'šŸ‘¤ User' : 'šŸ¤– Assistant';
1074
+ const status = d.outcome ? getOutcomeEmoji(d.outcome) + ' ' + d.outcome : 'ā³ Pending';
1075
+ const confidence = Math.round((d.confidence || 0) * 100);
1076
+
1077
+ // Preview (max 60 chars)
1078
+ const preview = d.decision.length > 60 ? d.decision.substring(0, 60) + '...' : d.decision;
1079
+
1080
+ output += `\n${i + 1}. [${timeAgo}] ${type}\n`;
1081
+ output += ` šŸ“š ${d.topic}\n`;
1082
+ output += ` šŸ’” ${preview}\n`;
1083
+ output += ` šŸ“Š ${confidence}% confidence | ${status}\n`;
1084
+ }
1085
+
1086
+ output += '\n' + '━'.repeat(60);
1087
+ output += `\nšŸ’” Tip: Use mama.recall('topic') for full details\n`;
1088
+
1089
+ return output;
1090
+ }
1091
+
1092
+ // Export API
1093
+ module.exports = {
1094
+ formatContext,
1095
+ formatInstantAnswer,
1096
+ formatLegacyContext,
1097
+ formatTeaser,
1098
+ formatRecall,
1099
+ formatList,
1100
+ ensureTokenBudget,
1101
+ estimateTokens,
1102
+ extractQuickAnswer,
1103
+ extractCodeExample,
1104
+ formatTrustContext,
1105
+ };
1106
+
1107
+ // CLI execution for testing
1108
+ if (require.main === module) {
1109
+ info('🧠 MAMA Decision Formatter - Test\n');
1110
+
1111
+ // Task 6.6: Test token budget enforcement
1112
+ const mockDecisions = [
1113
+ {
1114
+ id: 'decision_mesh_structure_003',
1115
+ topic: 'mesh_structure',
1116
+ decision: 'MODERATE',
1117
+ reasoning: 'Balance between performance and completeness',
1118
+ confidence: 0.8,
1119
+ outcome: null,
1120
+ created_at: Date.now() - 5 * 24 * 60 * 60 * 1000, // 5 days ago
1121
+ },
1122
+ {
1123
+ id: 'decision_mesh_structure_002',
1124
+ topic: 'mesh_structure',
1125
+ decision: 'SIMPLE',
1126
+ reasoning: 'Learned from 001 performance failure',
1127
+ confidence: 0.6,
1128
+ outcome: 'PARTIAL',
1129
+ limitation: 'Missing layer information',
1130
+ created_at: Date.now() - 10 * 24 * 60 * 60 * 1000, // 10 days ago
1131
+ updated_at: Date.now() - 5 * 24 * 60 * 60 * 1000,
1132
+ },
1133
+ {
1134
+ id: 'decision_mesh_structure_001',
1135
+ topic: 'mesh_structure',
1136
+ decision: 'COMPLEX',
1137
+ reasoning: 'Initial choice for flexibility',
1138
+ confidence: 0.5,
1139
+ outcome: 'FAILED',
1140
+ failure_reason: 'Performance bottleneck at 10K+ meshes',
1141
+ created_at: Date.now() - 15 * 24 * 60 * 60 * 1000, // 15 days ago
1142
+ updated_at: Date.now() - 10 * 24 * 60 * 60 * 1000,
1143
+ },
1144
+ ];
1145
+
1146
+ info('šŸ“‹ Test 1: Format small history (3 decisions)...');
1147
+ const context1 = formatContext(mockDecisions.slice(0, 3), { maxTokens: 500 });
1148
+ info(context1);
1149
+ info(`\nTokens: ${estimateTokens(context1)}/500\n`);
1150
+
1151
+ info('═══════════════════════════');
1152
+ info('šŸ“‹ Test 2: Format large history (10+ decisions)...');
1153
+
1154
+ // Generate large history
1155
+ const largeHistory = [mockDecisions[0]];
1156
+ for (let i = 1; i <= 10; i++) {
1157
+ largeHistory.push({
1158
+ ...mockDecisions[1],
1159
+ id: `decision_mesh_structure_${String(i).padStart(3, '0')}`,
1160
+ created_at: Date.now() - i * 5 * 24 * 60 * 60 * 1000,
1161
+ });
1162
+ }
1163
+
1164
+ const context2 = formatContext(largeHistory, { maxTokens: 500 });
1165
+ info(context2);
1166
+ info(`\nTokens: ${estimateTokens(context2)}/500\n`);
1167
+
1168
+ info('═══════════════════════════');
1169
+ info('šŸ“‹ Test 3: Token budget enforcement (truncation)...');
1170
+
1171
+ // Create very long context
1172
+ const longDecisions = largeHistory.concat(largeHistory);
1173
+ const context3 = formatContext(longDecisions, { maxTokens: 300 });
1174
+ info(context3);
1175
+ info(`\nTokens: ${estimateTokens(context3)}/300 (enforced)\n`);
1176
+
1177
+ info('═══════════════════════════');
1178
+ info('āœ… Decision formatter tests complete');
1179
+ info('═══════════════════════════');
1180
+ }