@jungjaehoon/mama-core 1.0.1

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