@memlab/mcp-server 2.1.0 → 2.3.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.
Files changed (162) hide show
  1. package/README.md +88 -3
  2. package/dist/analysis-budget.d.ts +71 -0
  3. package/dist/analysis-budget.d.ts.map +1 -0
  4. package/dist/analysis-budget.js +118 -0
  5. package/dist/analysis-budget.js.map +1 -0
  6. package/dist/guardrail.d.ts +24 -0
  7. package/dist/guardrail.d.ts.map +1 -0
  8. package/dist/guardrail.js +65 -0
  9. package/dist/guardrail.js.map +1 -0
  10. package/dist/heap-state.d.ts +46 -1
  11. package/dist/heap-state.d.ts.map +1 -1
  12. package/dist/heap-state.js +142 -10
  13. package/dist/heap-state.js.map +1 -1
  14. package/dist/index.js +36 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/tools/array-group-by.d.ts +12 -0
  17. package/dist/tools/array-group-by.d.ts.map +1 -0
  18. package/dist/tools/array-group-by.js +191 -0
  19. package/dist/tools/array-group-by.js.map +1 -0
  20. package/dist/tools/auto-investigate.d.ts.map +1 -1
  21. package/dist/tools/auto-investigate.js +999 -37
  22. package/dist/tools/auto-investigate.js.map +1 -1
  23. package/dist/tools/cache-analysis.d.ts.map +1 -1
  24. package/dist/tools/cache-analysis.js +438 -21
  25. package/dist/tools/cache-analysis.js.map +1 -1
  26. package/dist/tools/check-health.d.ts.map +1 -1
  27. package/dist/tools/check-health.js +261 -1
  28. package/dist/tools/check-health.js.map +1 -1
  29. package/dist/tools/class-histogram.d.ts.map +1 -1
  30. package/dist/tools/class-histogram.js +142 -33
  31. package/dist/tools/class-histogram.js.map +1 -1
  32. package/dist/tools/closure-inspection.d.ts.map +1 -1
  33. package/dist/tools/closure-inspection.js +238 -25
  34. package/dist/tools/closure-inspection.js.map +1 -1
  35. package/dist/tools/detached-dom.d.ts.map +1 -1
  36. package/dist/tools/detached-dom.js +25 -3
  37. package/dist/tools/detached-dom.js.map +1 -1
  38. package/dist/tools/dev-artifacts.d.ts +33 -0
  39. package/dist/tools/dev-artifacts.d.ts.map +1 -0
  40. package/dist/tools/dev-artifacts.js +165 -0
  41. package/dist/tools/dev-artifacts.js.map +1 -0
  42. package/dist/tools/diff-snapshots.d.ts.map +1 -1
  43. package/dist/tools/diff-snapshots.js +113 -18
  44. package/dist/tools/diff-snapshots.js.map +1 -1
  45. package/dist/tools/duplicated-strings.d.ts.map +1 -1
  46. package/dist/tools/duplicated-strings.js +74 -12
  47. package/dist/tools/duplicated-strings.js.map +1 -1
  48. package/dist/tools/eval.d.ts.map +1 -1
  49. package/dist/tools/eval.js +189 -10
  50. package/dist/tools/eval.js.map +1 -1
  51. package/dist/tools/event-listener-leaks.d.ts.map +1 -1
  52. package/dist/tools/event-listener-leaks.js +235 -13
  53. package/dist/tools/event-listener-leaks.js.map +1 -1
  54. package/dist/tools/event-registry.d.ts +12 -0
  55. package/dist/tools/event-registry.d.ts.map +1 -0
  56. package/dist/tools/event-registry.js +183 -0
  57. package/dist/tools/event-registry.js.map +1 -0
  58. package/dist/tools/find-by-property.d.ts.map +1 -1
  59. package/dist/tools/find-by-property.js +113 -31
  60. package/dist/tools/find-by-property.js.map +1 -1
  61. package/dist/tools/find-by-shape.d.ts +12 -0
  62. package/dist/tools/find-by-shape.d.ts.map +1 -0
  63. package/dist/tools/find-by-shape.js +178 -0
  64. package/dist/tools/find-by-shape.js.map +1 -0
  65. package/dist/tools/find-nodes-by-class.d.ts.map +1 -1
  66. package/dist/tools/find-nodes-by-class.js +50 -6
  67. package/dist/tools/find-nodes-by-class.js.map +1 -1
  68. package/dist/tools/for-each.d.ts.map +1 -1
  69. package/dist/tools/for-each.js +1 -1
  70. package/dist/tools/for-each.js.map +1 -1
  71. package/dist/tools/get-node.d.ts.map +1 -1
  72. package/dist/tools/get-node.js +6 -0
  73. package/dist/tools/get-node.js.map +1 -1
  74. package/dist/tools/get-string.d.ts +12 -0
  75. package/dist/tools/get-string.d.ts.map +1 -0
  76. package/dist/tools/get-string.js +223 -0
  77. package/dist/tools/get-string.js.map +1 -0
  78. package/dist/tools/get-value.d.ts +12 -0
  79. package/dist/tools/get-value.d.ts.map +1 -0
  80. package/dist/tools/get-value.js +152 -0
  81. package/dist/tools/get-value.js.map +1 -0
  82. package/dist/tools/global-variables.d.ts.map +1 -1
  83. package/dist/tools/global-variables.js +54 -34
  84. package/dist/tools/global-variables.js.map +1 -1
  85. package/dist/tools/growth-signals.d.ts +12 -0
  86. package/dist/tools/growth-signals.d.ts.map +1 -0
  87. package/dist/tools/growth-signals.js +211 -0
  88. package/dist/tools/growth-signals.js.map +1 -0
  89. package/dist/tools/intern-opportunities.d.ts +12 -0
  90. package/dist/tools/intern-opportunities.d.ts.map +1 -0
  91. package/dist/tools/intern-opportunities.js +289 -0
  92. package/dist/tools/intern-opportunities.js.map +1 -0
  93. package/dist/tools/load-snapshot.d.ts +10 -0
  94. package/dist/tools/load-snapshot.d.ts.map +1 -1
  95. package/dist/tools/load-snapshot.js +152 -15
  96. package/dist/tools/load-snapshot.js.map +1 -1
  97. package/dist/tools/object-cost-breakdown.d.ts +12 -0
  98. package/dist/tools/object-cost-breakdown.d.ts.map +1 -0
  99. package/dist/tools/object-cost-breakdown.js +439 -0
  100. package/dist/tools/object-cost-breakdown.js.map +1 -0
  101. package/dist/tools/object-shape.d.ts.map +1 -1
  102. package/dist/tools/object-shape.js +95 -2
  103. package/dist/tools/object-shape.js.map +1 -1
  104. package/dist/tools/pinch-points.d.ts.map +1 -1
  105. package/dist/tools/pinch-points.js +9 -9
  106. package/dist/tools/pinch-points.js.map +1 -1
  107. package/dist/tools/property-distribution.d.ts +12 -0
  108. package/dist/tools/property-distribution.d.ts.map +1 -0
  109. package/dist/tools/property-distribution.js +168 -0
  110. package/dist/tools/property-distribution.js.map +1 -0
  111. package/dist/tools/quick-diagnosis.d.ts.map +1 -1
  112. package/dist/tools/quick-diagnosis.js +56 -19
  113. package/dist/tools/quick-diagnosis.js.map +1 -1
  114. package/dist/tools/retainer-summary.d.ts.map +1 -1
  115. package/dist/tools/retainer-summary.js +90 -5
  116. package/dist/tools/retainer-summary.js.map +1 -1
  117. package/dist/tools/retainer-trace.d.ts.map +1 -1
  118. package/dist/tools/retainer-trace.js +74 -22
  119. package/dist/tools/retainer-trace.js.map +1 -1
  120. package/dist/tools/search-nodes.d.ts.map +1 -1
  121. package/dist/tools/search-nodes.js +10 -3
  122. package/dist/tools/search-nodes.js.map +1 -1
  123. package/dist/tools/search-strings.d.ts +12 -0
  124. package/dist/tools/search-strings.d.ts.map +1 -0
  125. package/dist/tools/search-strings.js +182 -0
  126. package/dist/tools/search-strings.js.map +1 -0
  127. package/dist/tools/sequence-analysis.d.ts +12 -0
  128. package/dist/tools/sequence-analysis.d.ts.map +1 -0
  129. package/dist/tools/sequence-analysis.js +234 -0
  130. package/dist/tools/sequence-analysis.js.map +1 -0
  131. package/dist/tools/server-status.d.ts +12 -0
  132. package/dist/tools/server-status.d.ts.map +1 -0
  133. package/dist/tools/server-status.js +41 -0
  134. package/dist/tools/server-status.js.map +1 -0
  135. package/dist/tools/shape-histogram.d.ts.map +1 -1
  136. package/dist/tools/shape-histogram.js +61 -16
  137. package/dist/tools/shape-histogram.js.map +1 -1
  138. package/dist/tools/sliced-strings.d.ts.map +1 -1
  139. package/dist/tools/sliced-strings.js +16 -11
  140. package/dist/tools/sliced-strings.js.map +1 -1
  141. package/dist/tools/snapshot-summary.d.ts.map +1 -1
  142. package/dist/tools/snapshot-summary.js +55 -13
  143. package/dist/tools/snapshot-summary.js.map +1 -1
  144. package/dist/tools/snapshots.d.ts +12 -0
  145. package/dist/tools/snapshots.d.ts.map +1 -0
  146. package/dist/tools/snapshots.js +93 -0
  147. package/dist/tools/snapshots.js.map +1 -0
  148. package/dist/tools/stale-collections.d.ts.map +1 -1
  149. package/dist/tools/stale-collections.js +248 -7
  150. package/dist/tools/stale-collections.js.map +1 -1
  151. package/dist/tools/trace-dominators.d.ts.map +1 -1
  152. package/dist/tools/trace-dominators.js +28 -1
  153. package/dist/tools/trace-dominators.js.map +1 -1
  154. package/dist/tools/weakmap-entries.d.ts +12 -0
  155. package/dist/tools/weakmap-entries.d.ts.map +1 -0
  156. package/dist/tools/weakmap-entries.js +156 -0
  157. package/dist/tools/weakmap-entries.js.map +1 -0
  158. package/dist/utils.d.ts +81 -2
  159. package/dist/utils.d.ts.map +1 -1
  160. package/dist/utils.js +303 -35
  161. package/dist/utils.js.map +1 -1
  162. package/package.json +3 -3
@@ -7,9 +7,11 @@
7
7
  * @format
8
8
  * @oncall memory_lab
9
9
  */
10
+ import memlabCore from '@memlab/core';
11
+ const { utils, NumericSet } = memlabCore;
10
12
  import { z } from 'zod';
11
13
  import { getSnapshot, getSnapshotMetadata } from '../heap-state.js';
12
- import { filterLargestObjects, isNodeWorthInspecting, formatBytes, formatNumber, truncateNodeName, errorResult, toolResult, } from '../utils.js';
14
+ import { filterLargestObjects, isNodeWorthInspecting, formatBytes, formatNumber, truncateNodeName, instrumentationRetainerNote, errorResult, toolResult, suggestionsSuppressed, } from '../utils.js';
13
15
  function classifySeverity(retainedSize, totalSize) {
14
16
  if (totalSize === 0)
15
17
  return 'LOW';
@@ -244,6 +246,47 @@ function scanTopShapes(snapshot, limit) {
244
246
  .sort((a, b) => b.totalSelfSize - a.totalSelfSize)
245
247
  .slice(0, limit);
246
248
  }
249
+ function scanDistributedAccumulation(snapshot, totalSize) {
250
+ const classMap = new Map();
251
+ snapshot.nodes.forEach(node => {
252
+ if (node.id <= 3)
253
+ return;
254
+ if (node.type === 'hidden' || node.type === 'array')
255
+ return;
256
+ const key = `${node.type}::${node.name}`;
257
+ const existing = classMap.get(key);
258
+ if (existing) {
259
+ existing.count++;
260
+ existing.totalSelfSize += node.self_size;
261
+ }
262
+ else {
263
+ classMap.set(key, {
264
+ className: node.name,
265
+ nodeType: node.type,
266
+ count: 1,
267
+ totalSelfSize: node.self_size,
268
+ exampleNodeId: node.id,
269
+ });
270
+ }
271
+ });
272
+ return [...classMap.values()]
273
+ .filter(c => {
274
+ if (c.count < 50_000)
275
+ return false;
276
+ const avgSize = c.totalSelfSize / c.count;
277
+ if (avgSize > 10_000)
278
+ return false;
279
+ if (totalSize > 0 && c.totalSelfSize < totalSize * 0.03)
280
+ return false;
281
+ return true;
282
+ })
283
+ .map(c => ({
284
+ ...c,
285
+ avgSize: c.totalSelfSize / c.count,
286
+ }))
287
+ .sort((a, b) => b.totalSelfSize - a.totalSelfSize)
288
+ .slice(0, 5);
289
+ }
247
290
  const LISTENER_SHAPE_KEYWORDS = new Set([
248
291
  'callback',
249
292
  'handler',
@@ -264,7 +307,15 @@ const LISTENER_CONTEXT_KEYWORDS = new Set([
264
307
  'scope',
265
308
  'self',
266
309
  ]);
267
- function scanSubscriptionAccumulation(snapshot, threshold) {
310
+ function scanSubscriptionAccumulation(snapshot, threshold, totalSize) {
311
+ const CALLBACK_PROPS = new Set(['callback', 'fn', 'handler', 'listener']);
312
+ const CONTEXT_PROPS_SET = new Set([
313
+ 'context',
314
+ 'ctx',
315
+ 'this',
316
+ 'target',
317
+ 'scope',
318
+ ]);
268
319
  const shapeMap = new Map();
269
320
  snapshot.nodes.forEach(node => {
270
321
  if (node.type !== 'object' || node.id <= 3)
@@ -272,9 +323,20 @@ function scanSubscriptionAccumulation(snapshot, threshold) {
272
323
  if (node.name !== 'Object')
273
324
  return;
274
325
  const names = [];
326
+ let callbackId = 0;
327
+ let contextId = 0;
328
+ let contextName = '';
275
329
  for (const edge of node.references) {
276
330
  if (edge.type === 'property') {
277
- names.push(String(edge.name_or_index));
331
+ const pName = String(edge.name_or_index);
332
+ names.push(pName);
333
+ if (CALLBACK_PROPS.has(pName)) {
334
+ callbackId = edge.toNode.id;
335
+ }
336
+ if (CONTEXT_PROPS_SET.has(pName)) {
337
+ contextId = edge.toNode.id;
338
+ contextName = edge.toNode.name;
339
+ }
278
340
  }
279
341
  }
280
342
  if (names.length === 0 || names.length > 10)
@@ -285,10 +347,29 @@ function scanSubscriptionAccumulation(snapshot, threshold) {
285
347
  if (existing) {
286
348
  existing.count++;
287
349
  existing.totalSelfSize += node.self_size;
350
+ if (callbackId > 0)
351
+ existing.callbackIds.add(callbackId);
352
+ if (contextId > 0) {
353
+ existing.contextIds.add(contextId);
354
+ existing.contextShapeMap.set(contextName, (existing.contextShapeMap.get(contextName) ?? 0) + 1);
355
+ const ctxNode = snapshot.getNodeById(contextId);
356
+ if (ctxNode && ctxNode.numOfReferrers <= 2) {
357
+ existing.orphanedContexts++;
358
+ }
359
+ }
288
360
  }
289
361
  else {
290
362
  const hasListener = names.some(n => LISTENER_SHAPE_KEYWORDS.has(n) || n.startsWith('on'));
291
363
  const hasContext = names.some(n => LISTENER_CONTEXT_KEYWORDS.has(n));
364
+ const contextShapeMap = new Map();
365
+ let orphanedContexts = 0;
366
+ if (contextId > 0) {
367
+ contextShapeMap.set(contextName, 1);
368
+ const ctxNode = snapshot.getNodeById(contextId);
369
+ if (ctxNode && ctxNode.numOfReferrers <= 2) {
370
+ orphanedContexts = 1;
371
+ }
372
+ }
292
373
  shapeMap.set(key, {
293
374
  properties: names,
294
375
  count: 1,
@@ -296,12 +377,47 @@ function scanSubscriptionAccumulation(snapshot, threshold) {
296
377
  exampleNodeId: node.id,
297
378
  hasListenerProp: hasListener,
298
379
  hasContextProp: hasContext,
380
+ callbackIds: new Set(callbackId > 0 ? [callbackId] : []),
381
+ contextIds: new Set(contextId > 0 ? [contextId] : []),
382
+ contextShapeMap,
383
+ orphanedContexts,
299
384
  });
300
385
  }
301
386
  });
302
- return [...shapeMap.values()]
387
+ const results = [...shapeMap.values()]
303
388
  .filter(s => s.count >= threshold && s.hasListenerProp && s.hasContextProp)
304
389
  .sort((a, b) => b.totalSelfSize - a.totalSelfSize);
390
+ return results.map(s => {
391
+ const hasHighFanOut = s.callbackIds.size <= 5 && s.contextIds.size > 10;
392
+ const hasOrphanedContexts = s.orphanedContexts > s.contextIds.size * 0.5;
393
+ let fanOut;
394
+ if (hasHighFanOut || hasOrphanedContexts) {
395
+ let escalatedSeverity = classifySeverity(s.totalSelfSize, totalSize);
396
+ if (hasHighFanOut &&
397
+ hasOrphanedContexts &&
398
+ (escalatedSeverity === 'LOW' || escalatedSeverity === 'MEDIUM')) {
399
+ escalatedSeverity = s.orphanedContexts > 100 ? 'CRITICAL' : 'HIGH';
400
+ }
401
+ const contextShapes = [...s.contextShapeMap.entries()]
402
+ .map(([name, count]) => ({ name, count }))
403
+ .sort((a, b) => b.count - a.count)
404
+ .slice(0, 5);
405
+ fanOut = {
406
+ uniqueCallbacks: s.callbackIds.size,
407
+ uniqueContexts: s.contextIds.size,
408
+ orphanedContexts: s.orphanedContexts,
409
+ contextShapes,
410
+ escalatedSeverity,
411
+ };
412
+ }
413
+ return {
414
+ properties: s.properties,
415
+ count: s.count,
416
+ totalSelfSize: s.totalSelfSize,
417
+ exampleNodeId: s.exampleNodeId,
418
+ fanOut,
419
+ };
420
+ });
305
421
  }
306
422
  const ERROR_CLASS_NAMES = new Set([
307
423
  'Error',
@@ -354,9 +470,46 @@ function scanErrorAccumulation(snapshot, minCount) {
354
470
  function tracePrefix(trace) {
355
471
  return trace.map(s => `${s.name}(${s.type})`).join('->');
356
472
  }
473
+ /**
474
+ * True if `ancestor` dominates `descendant` in the dominator tree (i.e. all
475
+ * paths from the GC root to `descendant` pass through `ancestor`, so
476
+ * `ancestor.retainedSize` already includes `descendant.retainedSize`). Walks
477
+ * the unique dominator chain upward, bounded to avoid pathological depth.
478
+ */
479
+ function isDominatorAncestor(ancestor, descendant, maxWalk = 500) {
480
+ if (ancestor.id === descendant.id)
481
+ return false;
482
+ let cur = descendant.dominatorNode ?? null;
483
+ let steps = 0;
484
+ while (cur && steps < maxWalk) {
485
+ if (cur.id === ancestor.id)
486
+ return true;
487
+ if (cur.dominatorNode?.id === cur.id)
488
+ break; // reached root self-loop
489
+ cur = cur.dominatorNode ?? null;
490
+ steps++;
491
+ }
492
+ return false;
493
+ }
494
+ /**
495
+ * Correct combined retained size for a set of nodes that may overlap on the
496
+ * dominator tree. In a dominator tree two nodes' retained subtrees are either
497
+ * nested or disjoint, so only dominator-maximal members are counted (any node
498
+ * dominated by another member has its bytes already included). This never
499
+ * exceeds the heap total — fixing the ">100% of heap" accounting.
500
+ *
501
+ * Delegates to memlab's `aggregateDominatorMetrics`, which dedups by walking
502
+ * each node's dominator chain once against a set — O(N·D) (N = nodes, D =
503
+ * dominator depth), versus the previous O(N²·D) pairwise `isDominatorAncestor`
504
+ * scan.
505
+ */
506
+ function combinedRetainedSize(nodes, snapshot) {
507
+ return utils.aggregateDominatorMetrics(new NumericSet(nodes.map(n => n.id)), snapshot, () => true, (node) => node.retainedSize);
508
+ }
357
509
  function deduplicateFindings(findings) {
358
510
  if (findings.length <= 1)
359
511
  return findings;
512
+ // Phase 1: Collapse findings that share the same retainer trace prefix
360
513
  const prefixMap = new Map();
361
514
  for (const f of findings) {
362
515
  const collapsed = f.trace.filter(s => s.type !== 'hidden' &&
@@ -384,12 +537,54 @@ function deduplicateFindings(findings) {
384
537
  prefixMap.set(prefix, { representative: f, others: [] });
385
538
  }
386
539
  }
387
- const result = [];
540
+ const prefixResult = [];
388
541
  for (const { representative, others } of prefixMap.values()) {
389
542
  representative.collapsed_siblings = others;
390
- result.push(representative);
543
+ prefixResult.push(representative);
391
544
  }
392
- return result;
545
+ // Phase 2: Merge findings that retain the *same dominated subtree* — whether
546
+ // because one node dominates the other in the dominator tree (so their
547
+ // retained sizes overlap) or because the same big subtree is reachable two
548
+ // ways and shows up under two different top retainers (Feedback §2a, §1a).
549
+ // The larger-retained node becomes the representative; the other is recorded
550
+ // as an alternate path so we report ONE finding instead of two ~equal ones.
551
+ if (prefixResult.length <= 1)
552
+ return prefixResult;
553
+ const traceNodeSets = prefixResult.map(f => {
554
+ const ids = new Set();
555
+ for (const step of f.trace) {
556
+ ids.add(step.nodeId);
557
+ }
558
+ return ids;
559
+ });
560
+ const absorbed = new Set();
561
+ for (let i = 0; i < prefixResult.length; i++) {
562
+ if (absorbed.has(i))
563
+ continue;
564
+ for (let j = i + 1; j < prefixResult.length; j++) {
565
+ if (absorbed.has(j))
566
+ continue;
567
+ const a = prefixResult[i].node;
568
+ const b = prefixResult[j].node;
569
+ const related = isDominatorAncestor(a, b) ||
570
+ isDominatorAncestor(b, a) ||
571
+ traceNodeSets[i].has(b.id) ||
572
+ traceNodeSets[j].has(a.id);
573
+ if (!related)
574
+ continue;
575
+ // Keep the larger-retained finding as representative.
576
+ const keepIdx = a.retainedSize >= b.retainedSize ? i : j;
577
+ const dropIdx = keepIdx === i ? j : i;
578
+ const siblings = prefixResult[keepIdx].collapsed_siblings ?? [];
579
+ siblings.push(prefixResult[dropIdx]);
580
+ siblings.push(...(prefixResult[dropIdx].collapsed_siblings ?? []));
581
+ prefixResult[keepIdx].collapsed_siblings = siblings;
582
+ absorbed.add(dropIdx);
583
+ if (dropIdx === i)
584
+ break; // i itself was absorbed; advance outer loop
585
+ }
586
+ }
587
+ return prefixResult.filter((_, idx) => !absorbed.has(idx));
393
588
  }
394
589
  function formatTrace(trace, maxSteps) {
395
590
  const collapsed = [];
@@ -420,6 +615,533 @@ function formatTrace(trace, maxSteps) {
420
615
  }
421
616
  return parts.join('\n → ');
422
617
  }
618
+ function computeTraceOverlap(a, b) {
619
+ let prefixLen = 0;
620
+ const minLen = Math.min(a.length, b.length);
621
+ for (let i = 0; i < minLen; i++) {
622
+ if (a[i].nodeId === b[i].nodeId) {
623
+ prefixLen++;
624
+ }
625
+ else {
626
+ break;
627
+ }
628
+ }
629
+ return { prefixLen, matchIndex: -1 };
630
+ }
631
+ function findBestTraceMatch(trace, previousTraces) {
632
+ let bestMatch = null;
633
+ for (let j = 0; j < previousTraces.length; j++) {
634
+ const { prefixLen } = computeTraceOverlap(trace, previousTraces[j]);
635
+ if (prefixLen > trace.length * 0.5) {
636
+ if (!bestMatch || prefixLen > bestMatch.prefixLen) {
637
+ bestMatch = { matchedIndex: j, prefixLen };
638
+ }
639
+ }
640
+ }
641
+ return bestMatch;
642
+ }
643
+ function detectAsyncContextLeaks(findings) {
644
+ const alerts = [];
645
+ const seen = new Set();
646
+ for (const f of findings) {
647
+ const traceNames = f.trace.map(s => s.name);
648
+ const traceEdges = f.trace.map(s => s.edgeName ?? '');
649
+ // Pattern 1: TCP/Socket → kResourceStore → afterContext (Next.js request context leak)
650
+ const hasTCP = traceNames.some(n => n === 'TCP' || n === 'Socket' || n.includes('TCP'));
651
+ const hasResourceStore = traceEdges.some(e => e === 'kResourceStore' || e === 'resource_symbol');
652
+ const hasAfterContext = traceEdges.some(e => e === 'afterContext');
653
+ const hasOnClose = traceEdges.some(e => e === 'onClose');
654
+ const hasContext = traceNames.some(n => n === 'system / Context' || n.startsWith('system /'));
655
+ if (hasTCP &&
656
+ hasResourceStore &&
657
+ (hasAfterContext || hasOnClose) &&
658
+ hasContext &&
659
+ !seen.has('tcp-als')) {
660
+ seen.add('tcp-als');
661
+ alerts.push(`🔴 **[CRITICAL] AsyncLocalStorage context retained by TCP connection**\n` +
662
+ `A TCP socket's async context chain retains ${formatBytes(f.node.retainedSize)} of request-scoped data. ` +
663
+ `The \`onClose\` closure captures the request's AsyncLocalStorage context, which chains to prior contexts via \`previous\` pointers.\n` +
664
+ `**Fix:** Ensure response bodies are fully consumed/canceled (check \`resp.body.cancel()\`), ` +
665
+ `or avoid capturing large data in request-scoped closures.`);
666
+ }
667
+ // Pattern 2: Generator.parameters_and_registers holding large arrays (Undici response body retention)
668
+ const hasGenerator = traceNames.some(n => n === 'Generator' || n.includes('Generator'));
669
+ const hasParamsAndRegisters = traceEdges.some(e => e === 'parameters_and_registers');
670
+ if (hasGenerator &&
671
+ hasParamsAndRegisters &&
672
+ f.node.retainedSize > 1024 * 1024 &&
673
+ !seen.has('generator-undici')) {
674
+ seen.add('generator-undici');
675
+ alerts.push(`🟠 **[HIGH] Generator retaining large data via \`parameters_and_registers\`**\n` +
676
+ `A Generator object holds ${formatBytes(f.node.retainedSize)} through its parameters/registers. ` +
677
+ `This is a common Undici pattern where an async generator (response body stream) retains the full response buffer.\n` +
678
+ `**Fix:** Ensure response bodies are fully read and the stream is closed/destroyed after use. ` +
679
+ `For fetch(), always call \`resp.text()\`, \`resp.json()\`, or \`resp.body.cancel()\`.`);
680
+ }
681
+ // Pattern 3: PromiseReaction chains with large retained sizes (unresolved/leaked promises)
682
+ const hasPromiseReaction = traceNames.some(n => n === 'PromiseReaction' || n === 'Promise');
683
+ const hasReactionEdge = traceEdges.some(e => e === 'reactions_or_result' ||
684
+ e === 'fulfill_handler' ||
685
+ e === 'reject_handler');
686
+ if (hasPromiseReaction &&
687
+ hasReactionEdge &&
688
+ f.node.retainedSize > 5 * 1024 * 1024 &&
689
+ !seen.has('promise-chain')) {
690
+ seen.add('promise-chain');
691
+ alerts.push(`🟠 **[HIGH] Unresolved Promise chain retaining ${formatBytes(f.node.retainedSize)}**\n` +
692
+ `A PromiseReaction chain is keeping large data alive. This often indicates a stuck async operation ` +
693
+ `(HTTP request, DB query, timer) whose promise was never resolved or rejected.\n` +
694
+ `**Fix:** Add timeouts to async operations. Check for \`await\` on promises that may never resolve. ` +
695
+ `Ensure \`.catch()\` handlers don't capture the entire response.`);
696
+ }
697
+ // Pattern 4: Undici Client/Pool response retention (Feedback #6)
698
+ const hasUndiciClient = traceNames.some(n => n === 'Client' || n === 'Pool' || n === 'Agent');
699
+ const hasRequestQueue = traceEdges.some(e => e === 'requests' || e === 'queue' || e === 'pending' || e === 'running');
700
+ if (hasUndiciClient &&
701
+ hasRequestQueue &&
702
+ f.node.retainedSize > 1024 * 1024 &&
703
+ !seen.has('undici-client')) {
704
+ seen.add('undici-client');
705
+ alerts.push(`🟠 **[HIGH] Undici HTTP Client/Pool retaining ${formatBytes(f.node.retainedSize)} via request queue**\n` +
706
+ `An Undici Client or Pool object is keeping response data alive through its request queue or closure chain. ` +
707
+ `This typically happens when HTTP response bodies are not fully consumed or canceled.\n` +
708
+ `**Fix:** Always consume or cancel response bodies: \`await resp.text()\`, \`resp.body.cancel()\`, ` +
709
+ `or \`resp.body.destroy()\`. For streaming responses, ensure the readable stream is fully drained.`);
710
+ }
711
+ // Pattern 5: mysql2 Connection retaining large data (Feedback #8)
712
+ const hasMysqlConn = traceNames.some(n => n === 'Connection' ||
713
+ n === 'PoolConnection' ||
714
+ n === 'PromiseConnection');
715
+ const hasMysqlEdge = traceEdges.some(e => e === '_protocol' || e === '_statements' || e === 'connectionConfig');
716
+ if (hasMysqlConn &&
717
+ hasMysqlEdge &&
718
+ f.node.retainedSize > 5 * 1024 * 1024 &&
719
+ !seen.has('mysql2-conn')) {
720
+ seen.add('mysql2-conn');
721
+ alerts.push(`🟠 **[HIGH] mysql2 Connection retaining ${formatBytes(f.node.retainedSize)}**\n` +
722
+ `A mysql2 Connection object is keeping query results or protocol buffers alive, often through ` +
723
+ `error event handler closure chains or unreleased prepared statements.\n` +
724
+ `**Fix:** Set pool \`idleTimeout\` (e.g., 60000ms) to free idle connections. ` +
725
+ `Ensure query result rows are not captured in long-lived closures. ` +
726
+ `Consider \`stream()\` instead of \`query()\` for large result sets.`);
727
+ }
728
+ }
729
+ return alerts;
730
+ }
731
+ // Framework class-name signatures. These are scanned heap-wide (not just along
732
+ // the top retainer traces) because framework internals frequently accumulate
733
+ // across many small objects that never surface as a single "top retained
734
+ // object" — yet are high-signal root causes (Feedback §2b).
735
+ const OTEL_METRIC_CLASSES = new Set([
736
+ 'SyncMetricStorage',
737
+ 'AsyncMetricStorage',
738
+ 'TemporalMetricProcessor',
739
+ 'DeltaMetricProcessor',
740
+ 'AttributeHashMap',
741
+ 'HashMap',
742
+ 'MetricStorageRegistry',
743
+ ]);
744
+ const RELAY_STORE_CLASSES = new Set([
745
+ 'RelayModernStore',
746
+ 'RelayRecordSource',
747
+ 'RecordSource',
748
+ 'RelayModernRecord',
749
+ 'RelayReader',
750
+ 'DataChecker',
751
+ ]);
752
+ function mapEntryCount(node) {
753
+ for (const edge of node.references) {
754
+ const n = String(edge.name_or_index);
755
+ if ((n === 'table' || n === 'backing_store') &&
756
+ (edge.toNode.type === 'array' || edge.toNode.type === 'hidden')) {
757
+ return edge.toNode.edge_count;
758
+ }
759
+ }
760
+ return node.edge_count;
761
+ }
762
+ // In-process source-map caches (@jridgewell/trace-mapping). These decode huge
763
+ // nested mapping arrays for stack-trace symbolication and are a recurring
764
+ // top-retainer across the Nest SSR fleet (Feedback round 3 §2a).
765
+ const SOURCEMAP_CLASSES = new Set(['TraceMap', 'AnyMap', 'SourceMapConsumer']);
766
+ function detectFrameworkLeaks(snapshot, totalSize) {
767
+ const alerts = [];
768
+ const otel = new Map();
769
+ const relay = new Map();
770
+ const sourceMaps = {
771
+ count: 0,
772
+ totalRetained: 0,
773
+ decodedRetained: 0,
774
+ encodedRetained: 0,
775
+ sourcesContentRetained: 0,
776
+ exampleId: 0,
777
+ exampleRetained: 0,
778
+ };
779
+ let topGenerator = null;
780
+ let generatorCount = 0;
781
+ const accumulate = (map, node, entries) => {
782
+ const e = map.get(node.name);
783
+ if (e) {
784
+ e.count++;
785
+ e.totalRetained += node.retainedSize;
786
+ e.maxEntries = Math.max(e.maxEntries, entries);
787
+ if (node.retainedSize >
788
+ (snapshot.getNodeById(e.exampleId)?.retainedSize ?? 0)) {
789
+ e.exampleId = node.id;
790
+ }
791
+ }
792
+ else {
793
+ map.set(node.name, {
794
+ count: 1,
795
+ totalRetained: node.retainedSize,
796
+ exampleId: node.id,
797
+ maxEntries: entries,
798
+ });
799
+ }
800
+ };
801
+ snapshot.nodes.forEach(node => {
802
+ if (node.id <= 3)
803
+ return;
804
+ if (OTEL_METRIC_CLASSES.has(node.name) && node.type === 'object') {
805
+ // Find the largest child Map/HashMap (the attribute->aggregation table).
806
+ let entries = 0;
807
+ for (const edge of node.references) {
808
+ const t = edge.toNode;
809
+ if (t.name === 'Map' || t.name === 'HashMap' || t.name === 'Set') {
810
+ entries = Math.max(entries, mapEntryCount(t));
811
+ }
812
+ }
813
+ accumulate(otel, node, entries);
814
+ }
815
+ if (RELAY_STORE_CLASSES.has(node.name) && node.type === 'object') {
816
+ accumulate(relay, node, mapEntryCount(node));
817
+ }
818
+ if (SOURCEMAP_CLASSES.has(node.name) && node.type === 'object') {
819
+ sourceMaps.count++;
820
+ sourceMaps.totalRetained += node.retainedSize;
821
+ if (node.retainedSize > sourceMaps.exampleRetained) {
822
+ sourceMaps.exampleRetained = node.retainedSize;
823
+ sourceMaps.exampleId = node.id;
824
+ }
825
+ for (const edge of node.references) {
826
+ if (edge.type !== 'property')
827
+ continue;
828
+ const p = String(edge.name_or_index);
829
+ if (p === '_decoded') {
830
+ sourceMaps.decodedRetained += edge.toNode.retainedSize;
831
+ }
832
+ else if (p === '_encoded') {
833
+ sourceMaps.encodedRetained += edge.toNode.retainedSize;
834
+ }
835
+ else if (p === 'sourcesContent') {
836
+ sourceMaps.sourcesContentRetained += edge.toNode.retainedSize;
837
+ }
838
+ }
839
+ }
840
+ if (node.name === 'Generator' &&
841
+ node.type === 'object' &&
842
+ node.retainedSize > 1024 * 1024) {
843
+ generatorCount++;
844
+ if (!topGenerator || node.retainedSize > topGenerator.retained) {
845
+ topGenerator = { id: node.id, retained: node.retainedSize };
846
+ }
847
+ }
848
+ });
849
+ // OpenTelemetry metric cardinality explosion.
850
+ const otelTotal = [...otel.values()].reduce((s, e) => s + e.totalRetained, 0);
851
+ const otelMaxEntries = Math.max(0, ...[...otel.values()].map(e => e.maxEntries));
852
+ if (otel.size > 0 &&
853
+ (otelTotal > 5 * 1024 * 1024 ||
854
+ (totalSize > 0 && otelTotal > totalSize * 0.03) ||
855
+ otelMaxEntries >= 1000)) {
856
+ const top = [...otel.entries()]
857
+ .sort((a, b) => b[1].totalRetained - a[1].totalRetained)
858
+ .slice(0, 3)
859
+ .map(([name, e]) => `${name} (${formatNumber(e.count)}×, ${formatBytes(e.totalRetained)}${e.maxEntries > 0 ? `, up to ${formatNumber(e.maxEntries)} attribute keys` : ''}, e.g. @${e.exampleId})`)
860
+ .join('; ');
861
+ alerts.push(`🔴 **[CRITICAL] OpenTelemetry metric cardinality explosion**\n` +
862
+ `OTel metric-storage internals retain ${formatBytes(otelTotal)} via per-attribute aggregation maps: ${top}.\n` +
863
+ `This is the classic Node OTel footgun: a high-cardinality attribute (request id, full URL with ids, user id) is used as a metric dimension, so each distinct value allocates a new aggregation in the \`AttributeHashMap\` / \`_valueMap\`.\n` +
864
+ `**Confirm:** \`memlab_property_distribution\` on the metric attribute shape, or \`memlab_search_strings\` for the serialized attribute keys (e.g. \`[["http.route","/x/<uuid>"]]\`).\n` +
865
+ `**Fix:** remove or bucket high-cardinality attributes (template the route: \`/artifact/:id\`), or add an allowed-attributes view so unbounded dimensions never enter the metric.`);
866
+ }
867
+ // Relay store growth.
868
+ const relayTotal = [...relay.values()].reduce((s, e) => s + e.totalRetained, 0);
869
+ if (relay.size > 0 &&
870
+ (relayTotal > 5 * 1024 * 1024 ||
871
+ (totalSize > 0 && relayTotal > totalSize * 0.05))) {
872
+ const top = [...relay.entries()]
873
+ .sort((a, b) => b[1].totalRetained - a[1].totalRetained)
874
+ .slice(0, 3)
875
+ .map(([name, e]) => `${name} (${formatNumber(e.count)}×, ${formatBytes(e.totalRetained)}, e.g. @${e.exampleId})`)
876
+ .join('; ');
877
+ alerts.push(`🟠 **[HIGH] Relay store retaining ${formatBytes(relayTotal)}**\n` +
878
+ `Relay record source/store internals are holding a large normalized cache: ${top}.\n` +
879
+ `**Fix:** ensure queries are disposed/GC'd (check \`retain()\` without matching \`dispose()\`), enable store garbage collection (\`gcReleaseBufferSize\`), or scope long-lived environments per request instead of a single global store.`);
880
+ }
881
+ // In-process source-map / symbolicator caches.
882
+ if (sourceMaps.count > 0 &&
883
+ (sourceMaps.totalRetained > 2 * 1024 * 1024 ||
884
+ (totalSize > 0 && sourceMaps.totalRetained > totalSize * 0.03))) {
885
+ const parts = [];
886
+ if (sourceMaps.decodedRetained > 0) {
887
+ parts.push(`_decoded ${formatBytes(sourceMaps.decodedRetained)}`);
888
+ }
889
+ if (sourceMaps.encodedRetained > 0) {
890
+ parts.push(`_encoded ${formatBytes(sourceMaps.encodedRetained)}`);
891
+ }
892
+ if (sourceMaps.sourcesContentRetained > 0) {
893
+ parts.push(`sourcesContent ${formatBytes(sourceMaps.sourcesContentRetained)}`);
894
+ }
895
+ const split = parts.length > 0 ? ` (${parts.join(', ')})` : '';
896
+ alerts.push(`🟠 **[HIGH] In-process source-map cache retaining ${formatBytes(sourceMaps.totalRetained)}**\n` +
897
+ `${formatNumber(sourceMaps.count)} \`TraceMap\`/source-map object(s) (\`@jridgewell/trace-mapping\`) are cached in-process for stack-trace symbolication${split}. Largest: @${sourceMaps.exampleId}. ` +
898
+ `The decoded mappings (\`_decoded\`) are huge nested arrays; on the Nest SSR fleet these are typically held by the OTel structured logger, the patched console, or a \`beforeExit\` closure.\n` +
899
+ `**Inspect:** \`memlab_dominator_subtree(${sourceMaps.exampleId})\` to see the decoded arrays; \`memlab_retainer_trace(${sourceMaps.exampleId})\` for who holds the cache.\n` +
900
+ `**Fix:** strip \`sourcesContent\` from the maps, cap/evict the symbolication cache, or move symbolication out-of-process. Every Nest server that symbolicates errors in-process hits this.`);
901
+ }
902
+ // Suspended async functions (generators) retaining large locals.
903
+ if (topGenerator) {
904
+ const g = topGenerator;
905
+ alerts.push(`🟠 **[HIGH] Suspended async function retaining ${formatBytes(g.retained)}**\n` +
906
+ `${formatNumber(generatorCount)} generator/async frame(s) >1 MB are parked mid-\`await\`, keeping their local variables (response buffers, accumulated rows) alive while the awaited resource never settles. Largest: @${g.id}.\n` +
907
+ `**Inspect:** \`memlab_closure_inspection(${g.id})\` resolves the frame's \`parameters_and_registers\` to source variable names.\n` +
908
+ `**Fix:** add timeouts/cancellation to the awaited operation; fully consume or cancel streamed response bodies (\`await resp.text()\` / \`resp.body.cancel()\`); avoid capturing whole datasets in the async frame.`);
909
+ }
910
+ return alerts;
911
+ }
912
+ /**
913
+ * Classify the long-lived GC root a closure hangs off of, by scanning its
914
+ * retainer path for the high-signal structures that pin request-scoped data for
915
+ * a connection/process lifetime (Feedback round 3 §2b/§2c).
916
+ */
917
+ function classifyLongLivedHolder(trace) {
918
+ for (const s of trace) {
919
+ const n = s.name;
920
+ const e = s.edgeName ?? '';
921
+ if (n === 'Timeout' ||
922
+ n === 'TimersList' ||
923
+ e === '_onTimeout' ||
924
+ e === '_idleNext' ||
925
+ e === '_repeat') {
926
+ return 'setInterval/setTimeout timer';
927
+ }
928
+ if (e === 'onClose')
929
+ return 'onClose handler';
930
+ if (n === 'TCP' || n === 'Socket' || n === 'ReusedHandle') {
931
+ return 'keep-alive socket';
932
+ }
933
+ if (e === 'kResourceStore' ||
934
+ e === 'resource_symbol' ||
935
+ /AsyncLocalStorage/.test(n)) {
936
+ return 'AsyncLocalStorage store';
937
+ }
938
+ if (e === '_events' || /EventEmitter/.test(n))
939
+ return 'EventEmitter listener';
940
+ if (e === 'signal' || n === 'AbortSignal')
941
+ return 'AbortSignal listener';
942
+ }
943
+ return null;
944
+ }
945
+ /**
946
+ * Find the single largest variable a closure/context captures — the dominant
947
+ * captured scope member, which is usually what makes an oversized handler
948
+ * closure expensive.
949
+ */
950
+ function dominantCapturedVar(closure) {
951
+ let ctx = null;
952
+ for (const edge of closure.references) {
953
+ if ((edge.type === 'internal' || edge.type === 'context') &&
954
+ String(edge.name_or_index) === 'context') {
955
+ ctx = edge.toNode;
956
+ break;
957
+ }
958
+ }
959
+ let best = null;
960
+ const consider = (node) => {
961
+ for (const edge of node.references) {
962
+ if (edge.type !== 'context' && edge.type !== 'property')
963
+ continue;
964
+ const name = String(edge.name_or_index);
965
+ if (name === 'previous' || name === 'native_context')
966
+ continue;
967
+ const t = edge.toNode;
968
+ if (t.id <= 3)
969
+ continue;
970
+ if (!best || t.retainedSize > best.retained) {
971
+ best = { name, retained: t.retainedSize, targetName: t.name };
972
+ }
973
+ }
974
+ };
975
+ consider(closure);
976
+ if (ctx)
977
+ consider(ctx);
978
+ return best;
979
+ }
980
+ /**
981
+ * Proactively surface the most common Node server leak shape this round: a
982
+ * long-lived closure (setInterval/onClose/listener) that captures a large
983
+ * variable it barely uses, pinning it for the connection/process lifetime.
984
+ * `closure_inspection` already computes the per-variable breakdown — this just
985
+ * flags the handler closures so the model doesn't have to suspect them first
986
+ * (Feedback round 3 §2b).
987
+ */
988
+ function scanOversizedClosures(snapshot, totalSize) {
989
+ const closureMin = Math.max(1024 * 1024, totalSize > 0 ? totalSize * 0.02 : 0);
990
+ const candidates = [];
991
+ snapshot.nodes.forEach(node => {
992
+ if (node.type !== 'closure' || node.id <= 3)
993
+ return;
994
+ if (node.retainedSize < closureMin)
995
+ return;
996
+ candidates.push(node);
997
+ });
998
+ candidates.sort((a, b) => b.retainedSize - a.retainedSize);
999
+ const alerts = [];
1000
+ const seen = new Set();
1001
+ for (const closure of candidates.slice(0, 30)) {
1002
+ const trace = getRetainerPath(closure);
1003
+ const holder = classifyLongLivedHolder(trace);
1004
+ if (!holder)
1005
+ continue;
1006
+ const dom = dominantCapturedVar(closure);
1007
+ const key = `${holder}|${dom?.name ?? ''}`;
1008
+ if (seen.has(key))
1009
+ continue;
1010
+ seen.add(key);
1011
+ const domStr = dom
1012
+ ? ` Its largest captured variable is \`${dom.name}\` → ${dom.targetName} (${formatBytes(dom.retained)})` +
1013
+ (dom.retained > closure.retainedSize * 0.6
1014
+ ? ' — the bulk of the retention, and quite possibly captured but barely used.'
1015
+ : '.')
1016
+ : '';
1017
+ alerts.push(`🟠 **[HIGH] Oversized closure on a long-lived ${holder} retaining ${formatBytes(closure.retainedSize)}**\n` +
1018
+ `Closure @${closure.id}${closure.name ? ` \`${truncateNodeName(closure.name, closure.type, closure.self_size, 40)}\`` : ''} is reachable from a ${holder} and keeps its captured scope alive for that root's lifetime.${domStr}\n` +
1019
+ `**Inspect:** \`memlab_closure_inspection(${closure.id}, {min_retained_size: 102400})\` for the full per-variable capture breakdown.\n` +
1020
+ `**Fix:** extract only the fields the callback needs before registering it (don't close over the whole request scope / serialized blob), or clear the timer / unregister the listener when the request or connection ends.`);
1021
+ if (alerts.length >= 5)
1022
+ break;
1023
+ }
1024
+ return alerts;
1025
+ }
1026
+ // Keys that mark a record as a log/conversation/message entry. An append-only
1027
+ // accumulation of these is a runaway-appender signature (Feedback round 3 §2d).
1028
+ const MESSAGE_RECORD_KEYS = new Set([
1029
+ 'role',
1030
+ 'content',
1031
+ 'message',
1032
+ 'msg',
1033
+ 'timestamp',
1034
+ 'ts',
1035
+ 'level',
1036
+ 'type',
1037
+ 'text',
1038
+ ]);
1039
+ // Fields whose value usually discriminates the record kind, used to test
1040
+ // whether a few values repeat across most records.
1041
+ const DISCRIMINATOR_KEYS = ['role', 'level', 'type', 'message', 'msg'];
1042
+ /**
1043
+ * A property whose value is expected to be unique per record. If such a field
1044
+ * has far fewer distinct values than instances, the SAME records are retained
1045
+ * multiple times (Feedback round 3 §1b).
1046
+ */
1047
+ function looksUniqueKey(name) {
1048
+ return (/^(id|_id|uuid|guid|key|hash|sku|pk|cursor|etag)$/i.test(name) ||
1049
+ /(_id|Id|Key|Uuid|Guid|Hash)$/.test(name));
1050
+ }
1051
+ /**
1052
+ * Detect append-only logs/conversations: a very high count of a uniform record
1053
+ * shape dominated by a few repeated field values (e.g. 696K `{role, content,
1054
+ * timestamp}` objects from a scheduler that re-appends the same system/error
1055
+ * message forever). `growth_signals` misses these because they aren't keyed the
1056
+ * way its heuristic expects (Feedback round 3 §2d).
1057
+ */
1058
+ function scanAppendOnlyRecords(snapshot, totalSize) {
1059
+ const shapeMap = new Map();
1060
+ snapshot.nodes.forEach(node => {
1061
+ if (node.type !== 'object' || node.id <= 3)
1062
+ return;
1063
+ if (node.name !== 'Object')
1064
+ return;
1065
+ const names = [];
1066
+ for (const edge of node.references) {
1067
+ if (edge.type === 'property')
1068
+ names.push(String(edge.name_or_index));
1069
+ }
1070
+ if (names.length === 0 || names.length > 12)
1071
+ return;
1072
+ if (!names.some(n => MESSAGE_RECORD_KEYS.has(n.toLowerCase())))
1073
+ return;
1074
+ names.sort();
1075
+ const key = names.join(',');
1076
+ const ex = shapeMap.get(key);
1077
+ if (ex) {
1078
+ ex.count++;
1079
+ ex.totalSelfSize += node.self_size;
1080
+ if (ex.exampleIds.length < 100)
1081
+ ex.exampleIds.push(node.id);
1082
+ }
1083
+ else {
1084
+ shapeMap.set(key, {
1085
+ properties: names,
1086
+ count: 1,
1087
+ totalSelfSize: node.self_size,
1088
+ exampleNodeId: node.id,
1089
+ exampleIds: [node.id],
1090
+ });
1091
+ }
1092
+ });
1093
+ const hits = [...shapeMap.values()]
1094
+ .filter(s => s.count >= 50_000)
1095
+ .sort((a, b) => b.count - a.count)
1096
+ .slice(0, 3);
1097
+ const alerts = [];
1098
+ for (const s of hits) {
1099
+ // Test a discriminator field for value concentration over the sample.
1100
+ const field = DISCRIMINATOR_KEYS.find(k => s.properties.includes(k)) ?? null;
1101
+ let concentration = '';
1102
+ if (field) {
1103
+ const freq = new Map();
1104
+ let sampled = 0;
1105
+ for (const id of s.exampleIds) {
1106
+ const node = snapshot.getNodeById(id);
1107
+ if (!node)
1108
+ continue;
1109
+ for (const edge of node.references) {
1110
+ if (edge.type === 'property' &&
1111
+ String(edge.name_or_index) === field) {
1112
+ let val = edge.toNode.name;
1113
+ if (edge.toNode.isString) {
1114
+ const sn = edge.toNode.toStringNode();
1115
+ if (sn)
1116
+ val = sn.stringValue;
1117
+ }
1118
+ freq.set(val, (freq.get(val) ?? 0) + 1);
1119
+ sampled++;
1120
+ break;
1121
+ }
1122
+ }
1123
+ }
1124
+ if (sampled > 0) {
1125
+ const top = [...freq.entries()].sort((a, b) => b[1] - a[1])[0];
1126
+ if (top) {
1127
+ const pct = ((top[1] / sampled) * 100).toFixed(0);
1128
+ const valDisp = top[0].length > 40 ? top[0].slice(0, 40) + '…' : top[0];
1129
+ concentration = ` The \`${field}\` field is dominated by one value ("${valDisp}", ${pct}% of sampled records) — a strong append-only signature.`;
1130
+ }
1131
+ }
1132
+ }
1133
+ const pctHeap = totalSize > 0
1134
+ ? ` (${((s.totalSelfSize / totalSize) * 100).toFixed(1)}% of heap self size)`
1135
+ : '';
1136
+ const propsDisplay = s.properties.length <= 6
1137
+ ? `{${s.properties.join(', ')}}`
1138
+ : `{${s.properties.slice(0, 5).join(', ')}, … +${s.properties.length - 5}}`;
1139
+ alerts.push(`🟠 **[HIGH] Append-only record accumulation — ${formatNumber(s.count)}× ${propsDisplay}**\n` +
1140
+ `${formatNumber(s.count)} uniform message/log records (${formatBytes(s.totalSelfSize)} self${pctHeap}, example @${s.exampleNodeId}).${concentration}\n` +
1141
+ `**Fix:** bound the conversation/log history (ring buffer, max length, or periodic flush). Check for a scheduler/retry loop that re-appends the same system or error message without ever trimming.`);
1142
+ }
1143
+ return alerts;
1144
+ }
423
1145
  export function registerAutoInvestigate(server) {
424
1146
  server.tool('memlab_auto_investigate', 'One-shot deep analysis: finds the top retained objects, traces each retainer chain to the GC root, identifies pinch points (small objects retaining large subtrees), and detects unbounded caches. Returns a structured report with root causes and suggested fixes. Use this as the first tool after loading a snapshot to get immediate actionable findings.', {
425
1147
  top_n: z
@@ -482,8 +1204,13 @@ export function registerAutoInvestigate(server) {
482
1204
  topPending: [],
483
1205
  };
484
1206
  const shapes = focus === 'all' ? scanTopShapes(snapshot, 5) : [];
485
- const subscriptions = focus === 'all' ? scanSubscriptionAccumulation(snapshot, 1000) : [];
1207
+ const subscriptions = focus === 'all'
1208
+ ? scanSubscriptionAccumulation(snapshot, 1000, totalSize)
1209
+ : [];
486
1210
  const errorAccum = focus === 'all' ? scanErrorAccumulation(snapshot, 10) : [];
1211
+ const distributed = focus === 'all'
1212
+ ? scanDistributedAccumulation(snapshot, totalSize)
1213
+ : [];
487
1214
  const lines = [`# Auto-Investigation Report`, ''];
488
1215
  const focusLabel = focus === 'all' ? '' : ` (focus: ${focus})`;
489
1216
  lines.push(`## Top ${findings.length} Retained Objects${focusLabel}`);
@@ -504,13 +1231,17 @@ export function registerAutoInvestigate(server) {
504
1231
  }
505
1232
  }
506
1233
  }
1234
+ const previousTraces = [];
507
1235
  for (let i = 0; i < findings.length; i++) {
508
1236
  const f = findings[i];
509
1237
  const siblings = f.collapsed_siblings ?? [];
510
- const totalRetained = f.node.retainedSize +
511
- siblings.reduce((s, sib) => s + sib.node.retainedSize, 0);
1238
+ // Dominator-aware combined size: members on the same chain overlap,
1239
+ // so we must not sum their retained sizes (that produced the old
1240
+ // ">100% of heap" bug). combinedRetainedSize() keeps only
1241
+ // dominator-maximal members.
1242
+ const totalRetained = combinedRetainedSize([f.node, ...siblings.map(s => s.node)], snapshot);
512
1243
  const pct = totalSize > 0
513
- ? ` (${((totalRetained / totalSize) * 100).toFixed(1)}% of heap)`
1244
+ ? ` (${Math.min(100, (totalRetained / totalSize) * 100).toFixed(1)}% of heap)`
514
1245
  : '';
515
1246
  const name = truncateNodeName(f.node.name, f.node.type, f.node.self_size, 60);
516
1247
  const sevIcon = f.severity === 'CRITICAL'
@@ -520,21 +1251,48 @@ export function registerAutoInvestigate(server) {
520
1251
  : f.severity === 'MEDIUM'
521
1252
  ? '🟡'
522
1253
  : '🔵';
1254
+ // Check if this finding's trace shares a common prefix with a previous one
1255
+ const traceMatch = i > 0 ? findBestTraceMatch(f.trace, previousTraces) : null;
523
1256
  if (siblings.length > 0) {
524
- lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] \`${name}\` chain — ${formatBytes(totalRetained)} total across ${1 + siblings.length} nodes${pct}`);
1257
+ lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] \`${name}\` — ${formatBytes(totalRetained)} retained, same subtree reached via ${1 + siblings.length} retainer(s)${pct}`);
525
1258
  lines.push('');
526
- lines.push(`**Retainer chain:** ${formatTrace(f.trace, 8)}`);
1259
+ if (traceMatch) {
1260
+ const divergeStep = f.trace[traceMatch.prefixLen];
1261
+ const divergeEdge = divergeStep?.edgeName ?? '…';
1262
+ const divergeName = divergeStep
1263
+ ? truncateNodeName(divergeStep.name, divergeStep.type, divergeStep.selfSize, 40)
1264
+ : '…';
1265
+ lines.push(`**Retainer chain:** (same chain as #${traceMatch.matchedIndex + 1}) → [${divergeEdge}] ${divergeName} — ${formatBytes(f.node.retainedSize)}`);
1266
+ }
1267
+ else {
1268
+ lines.push(`**Retainer chain:** ${formatTrace(f.trace, 8)}`);
1269
+ }
527
1270
  lines.push('');
528
- lines.push(`**Same chain contains:** @${f.node.id} (${formatBytes(f.node.retainedSize)})` +
1271
+ lines.push(`**Alternate retainers of the same subtree** (retained sizes overlap — not additive): @${f.node.id} (${formatBytes(f.node.retainedSize)})` +
529
1272
  siblings
530
1273
  .map(sib => `, @${sib.node.id} ${truncateNodeName(sib.node.name, sib.node.type, sib.node.self_size, 30)} (${formatBytes(sib.node.retainedSize)})`)
531
1274
  .join(''));
532
1275
  }
1276
+ else if (traceMatch) {
1277
+ const divergeStep = f.trace[traceMatch.prefixLen];
1278
+ const divergeEdge = divergeStep?.edgeName ?? '…';
1279
+ const divergeName = divergeStep
1280
+ ? truncateNodeName(divergeStep.name, divergeStep.type, divergeStep.selfSize, 40)
1281
+ : '…';
1282
+ lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] @${f.node.id} \`${name}\` (${f.node.type}) — ${formatBytes(f.node.retainedSize)}${pct}`);
1283
+ lines.push('');
1284
+ lines.push(`**Retainer chain:** (same chain as #${traceMatch.matchedIndex + 1}) → [${divergeEdge}] ${divergeName} — ${formatBytes(f.node.retainedSize)}`);
1285
+ }
533
1286
  else {
534
1287
  lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] @${f.node.id} \`${name}\` (${f.node.type}) — ${formatBytes(f.node.retainedSize)}${pct}`);
535
1288
  lines.push('');
536
1289
  lines.push(`**Retainer chain:** ${formatTrace(f.trace, 8)}`);
537
1290
  }
1291
+ previousTraces.push(f.trace);
1292
+ const instrNote = instrumentationRetainerNote(f.trace);
1293
+ if (instrNote) {
1294
+ lines.push(`> ⚠ ${instrNote}`);
1295
+ }
538
1296
  lines.push('');
539
1297
  if (f.pinchPoint) {
540
1298
  const pp = f.pinchPoint;
@@ -558,8 +1316,20 @@ export function registerAutoInvestigate(server) {
558
1316
  lines.push('## Shared Pinch Points');
559
1317
  lines.push('');
560
1318
  for (const { pp, findingIndices } of sharedPinchPoints) {
1319
+ const ppNode = snapshot.getNodeById(pp.nodeId);
561
1320
  const ppName = truncateNodeName(pp.name, pp.type, pp.selfSize, 50);
562
1321
  lines.push(`- @${pp.nodeId} \`${ppName}\` — self: ${formatBytes(pp.selfSize)}, retains: ${formatBytes(pp.retainedSize)} (${formatNumber(Math.round(pp.ratio))}:1 ratio). Shared by findings ${findingIndices.map(j => `#${j + 1}`).join(', ')}. Freeing this single object would reclaim ${formatBytes(pp.retainedSize)}.`);
1322
+ if (ppNode) {
1323
+ const topProps = [];
1324
+ for (const edge of ppNode.references) {
1325
+ if (edge.type === 'property' && topProps.length < 5) {
1326
+ topProps.push(String(edge.name_or_index));
1327
+ }
1328
+ }
1329
+ if (topProps.length > 0) {
1330
+ lines.push(` Properties: {${topProps.join(', ')}${ppNode.edge_count > 5 ? ', …' : ''}}`);
1331
+ }
1332
+ }
563
1333
  }
564
1334
  lines.push('');
565
1335
  }
@@ -607,6 +1377,154 @@ export function registerAutoInvestigate(server) {
607
1377
  }
608
1378
  lines.push('', '_Use `memlab_shape_histogram` for full shape analysis with retained sizes._');
609
1379
  lines.push('');
1380
+ // Feedback #4: Low-cardinality column detection
1381
+ // Feedback #8: High-cost unique property detection
1382
+ // Feedback round 3 §1b: same-object duplication detection
1383
+ // For large shapes (>10K instances), sample property value cardinality
1384
+ const columnAlerts = [];
1385
+ const duplicationAlerts = [];
1386
+ const dupShapesSeen = new Set();
1387
+ // Pre-bucket: collect qualifying shape keys, then single-pass scan
1388
+ const qualifyingShapes = shapes.filter(s => {
1389
+ if (s.count < 10_000)
1390
+ return false;
1391
+ return !!snapshot.getNodeById(s.exampleNodeId);
1392
+ });
1393
+ const shapeBuckets = new Map();
1394
+ for (const s of qualifyingShapes) {
1395
+ shapeBuckets.set(s.properties.join(','), []);
1396
+ }
1397
+ if (shapeBuckets.size > 0) {
1398
+ const maxSamples = 200;
1399
+ snapshot.nodes.forEach(node => {
1400
+ if (node.type !== 'object' || node.id <= 3)
1401
+ return;
1402
+ if (node.name !== 'Object')
1403
+ return;
1404
+ const props = [];
1405
+ for (const edge of node.references) {
1406
+ if (edge.type === 'property') {
1407
+ props.push(String(edge.name_or_index));
1408
+ }
1409
+ }
1410
+ props.sort();
1411
+ const key = props.join(',');
1412
+ const bucket = shapeBuckets.get(key);
1413
+ if (bucket && bucket.length < maxSamples) {
1414
+ bucket.push(node);
1415
+ }
1416
+ });
1417
+ }
1418
+ for (const s of qualifyingShapes) {
1419
+ const sampleNodes = shapeBuckets.get(s.properties.join(',')) ?? [];
1420
+ if (sampleNodes.length < 50)
1421
+ continue;
1422
+ // Analyze each property's cardinality and value characteristics
1423
+ for (const propName of s.properties) {
1424
+ const values = new Set();
1425
+ let totalValueSize = 0;
1426
+ let stringValueCount = 0;
1427
+ let allUnique = true;
1428
+ for (const node of sampleNodes) {
1429
+ for (const edge of node.references) {
1430
+ if (edge.type === 'property' &&
1431
+ String(edge.name_or_index) === propName) {
1432
+ const target = edge.toNode;
1433
+ let val = target.name;
1434
+ if (target.isString) {
1435
+ const strNode = target.toStringNode();
1436
+ if (strNode)
1437
+ val = strNode.stringValue;
1438
+ stringValueCount++;
1439
+ totalValueSize += target.self_size;
1440
+ }
1441
+ if (values.has(val))
1442
+ allUnique = false;
1443
+ values.add(val);
1444
+ break;
1445
+ }
1446
+ }
1447
+ }
1448
+ const cardinality = values.size;
1449
+ const sampleCount = sampleNodes.length;
1450
+ const uniqueKey = looksUniqueKey(propName);
1451
+ const shapeKey = s.properties.join(',');
1452
+ // Same-object duplication vs genuine low cardinality (§1b): a field
1453
+ // that *should* be unique per record but has far fewer distinct
1454
+ // values than sampled instances means the SAME records are retained
1455
+ // N times — a retention/concurrency fix, not interning.
1456
+ if (uniqueKey &&
1457
+ sampleCount >= 100 &&
1458
+ cardinality >= 1 &&
1459
+ cardinality < sampleCount * 0.6 &&
1460
+ !dupShapesSeen.has(shapeKey)) {
1461
+ const dupFactor = Math.round(sampleCount / Math.max(1, cardinality));
1462
+ if (dupFactor >= 2) {
1463
+ dupShapesSeen.add(shapeKey);
1464
+ duplicationAlerts.push(`- \`${propName}\` (a unique-looking key) has only **${formatNumber(cardinality)} distinct value(s)** across ${formatNumber(sampleCount)} sampled of ${formatNumber(s.count)} total \`{${s.properties.slice(0, 4).join(', ')}${s.properties.length > 4 ? ', …' : ''}}\` instances — looks like **~${dupFactor} retained copies of the same records**, not a low-cardinality column. Investigate **retention/concurrency** (e.g. many concurrent requests each holding a full copy of this dataset), NOT string interning.`);
1465
+ }
1466
+ }
1467
+ else if (!uniqueKey &&
1468
+ cardinality <= 20 &&
1469
+ sampleCount >= 100) {
1470
+ // Genuine low-cardinality column.
1471
+ columnAlerts.push(`- Property \`${propName}\` has only **${cardinality} unique value(s)** across ${formatNumber(s.count)} instances — low-cardinality column suitable for pre-filtering at the data source or string interning`);
1472
+ }
1473
+ // High-cost unique: all values unique, string, large average size
1474
+ if (allUnique &&
1475
+ stringValueCount > sampleCount * 0.8 &&
1476
+ cardinality > sampleCount * 0.9) {
1477
+ const avgSize = totalValueSize / stringValueCount;
1478
+ if (avgSize > 50 && s.count > 10_000) {
1479
+ const estimatedWaste = avgSize * s.count;
1480
+ columnAlerts.push(`- Property \`${propName}\` has **all unique string values** (avg ${Math.round(avgSize)}B each × ${formatNumber(s.count)} instances = ~${formatBytes(estimatedWaste)}) — verify this field is needed by consumers`);
1481
+ }
1482
+ }
1483
+ }
1484
+ }
1485
+ if (duplicationAlerts.length > 0) {
1486
+ lines.push('## Same-Object Duplication');
1487
+ lines.push('');
1488
+ lines.push('Unique-looking keys with far fewer distinct values than instances — the same records appear to be retained multiple times:');
1489
+ lines.push('');
1490
+ lines.push(...duplicationAlerts.slice(0, 8));
1491
+ lines.push('', '_This is a retention/concurrency issue, **not** an interning opportunity: interning a per-record copy cannot reclaim duplicated whole records. Trace one example with `memlab_retainer_summary` / `memlab_trace_dominators` to find why N copies are kept alive (e.g. unbounded request concurrency, a cache keyed per-request, or fan-out without back-pressure)._');
1492
+ lines.push('');
1493
+ }
1494
+ if (columnAlerts.length > 0) {
1495
+ // Cap to the most relevant lines — on wide shapes this section
1496
+ // used to emit ~100 near-identical lines (Feedback §4).
1497
+ const COLUMN_ALERT_CAP = 12;
1498
+ lines.push('## Data Column Analysis');
1499
+ lines.push('');
1500
+ lines.push('Property value analysis across large object shapes:');
1501
+ lines.push('');
1502
+ lines.push(...columnAlerts.slice(0, COLUMN_ALERT_CAP));
1503
+ if (columnAlerts.length > COLUMN_ALERT_CAP) {
1504
+ lines.push(`- … and ${columnAlerts.length - COLUMN_ALERT_CAP} more column(s). Use \`memlab_property_distribution\` to drill into a specific property.`);
1505
+ }
1506
+ lines.push('', '_Low-cardinality columns can often be filtered at the data source (SQL WHERE clause). High-cost unique string properties should be verified as needed by consumers._');
1507
+ lines.push('');
1508
+ }
1509
+ }
1510
+ if (distributed.length > 0) {
1511
+ lines.push(`## Distributed Accumulation`);
1512
+ lines.push('');
1513
+ lines.push('Classes with many small instances that collectively consume significant memory. No single instance stands out, but the total is substantial:');
1514
+ lines.push('');
1515
+ for (const d of distributed) {
1516
+ const pct = totalSize > 0
1517
+ ? ` (${((d.totalSelfSize / totalSize) * 100).toFixed(1)}% of heap)`
1518
+ : '';
1519
+ const sevIcon = d.totalSelfSize >= totalSize * 0.1
1520
+ ? '🔴'
1521
+ : d.totalSelfSize >= totalSize * 0.05
1522
+ ? '🟠'
1523
+ : '🟡';
1524
+ lines.push(`- ${sevIcon} \`${d.className}\` (${d.nodeType}) — **${formatNumber(d.count)}** instances × ${Math.round(d.avgSize)}B avg = **${formatBytes(d.totalSelfSize)}**${pct} (example: @${d.exampleNodeId})`);
1525
+ }
1526
+ lines.push('', '_These won\'t appear in "top retained objects" because no single instance retains enough. Reduce instance count at the source (batch, deduplicate, or filter before storing)._');
1527
+ lines.push('');
610
1528
  }
611
1529
  if (subscriptions.length > 0) {
612
1530
  lines.push(`## Subscription/Listener Accumulation`);
@@ -615,17 +1533,39 @@ export function registerAutoInvestigate(server) {
615
1533
  lines.push('');
616
1534
  for (const s of subscriptions.slice(0, 5)) {
617
1535
  const propsDisplay = `{${s.properties.join(', ')}}`;
618
- const subSev = classifySeverity(s.totalSelfSize, totalSize);
619
- const subSevIcon = subSev === 'CRITICAL'
1536
+ const baseSev = classifySeverity(s.totalSelfSize, totalSize);
1537
+ const effectiveSev = s.fanOut
1538
+ ? s.fanOut.escalatedSeverity
1539
+ : baseSev;
1540
+ const subSevIcon = effectiveSev === 'CRITICAL'
620
1541
  ? '🔴'
621
- : subSev === 'HIGH'
1542
+ : effectiveSev === 'HIGH'
622
1543
  ? '🟠'
623
- : subSev === 'MEDIUM'
1544
+ : effectiveSev === 'MEDIUM'
624
1545
  ? '🟡'
625
1546
  : '🔵';
626
- lines.push(`- ${subSevIcon} [${subSev}] ${propsDisplay} — **${formatNumber(s.count)}** instances, ${formatBytes(s.totalSelfSize)} self (example: @${s.exampleNodeId})`);
1547
+ lines.push(`- ${subSevIcon} [${effectiveSev}] ${propsDisplay} — **${formatNumber(s.count)}** instances, ${formatBytes(s.totalSelfSize)} self (example: @${s.exampleNodeId})`);
1548
+ if (s.fanOut) {
1549
+ const fo = s.fanOut;
1550
+ lines.push(` - **Listener fan-out detected:** ${formatNumber(fo.uniqueCallbacks)} unique callback(s), ${formatNumber(fo.uniqueContexts)} unique context(s)`);
1551
+ if (fo.orphanedContexts > 0) {
1552
+ lines.push(` - **${formatNumber(fo.orphanedContexts)} orphaned context(s)** — only retained by listener registrations`);
1553
+ }
1554
+ if (fo.contextShapes.length > 0) {
1555
+ const shapeStr = fo.contextShapes
1556
+ .map(cs => `${cs.count}× ${cs.name}`)
1557
+ .join(', ');
1558
+ lines.push(` - Context shapes: ${shapeStr}`);
1559
+ }
1560
+ if (effectiveSev !== baseSev) {
1561
+ lines.push(` - _Severity escalated from ${baseSev} → ${effectiveSev} due to listener fan-out pattern (few callbacks, many orphaned contexts)_`);
1562
+ }
1563
+ }
627
1564
  }
628
- lines.push('', '_These may be event listeners, signal subscriptions, or observer registrations that are not being cleaned up. Use `memlab_retainer_summary` with node_ids from the examples to trace the retention pattern._');
1565
+ lines.push('');
1566
+ lines.push('_These may be event listeners, signal subscriptions, or observer registrations that are not being cleaned up. ' +
1567
+ 'Use `memlab_event_listener_leaks` for detailed listener analysis or ' +
1568
+ '`memlab_retainer_summary` with node_ids from the examples to trace the retention pattern._');
629
1569
  lines.push('');
630
1570
  }
631
1571
  if (errorAccum.length > 0) {
@@ -642,6 +1582,26 @@ export function registerAutoInvestigate(server) {
642
1582
  lines.push('', '_Repeated identical errors are almost always a bug — a module failing to load, a misconfigured import, or a retry loop that never succeeds._');
643
1583
  lines.push('');
644
1584
  }
1585
+ const asyncAlerts = detectAsyncContextLeaks(findings);
1586
+ const frameworkAlerts = focus === 'all' ? detectFrameworkLeaks(snapshot, totalSize) : [];
1587
+ const closureAlerts = focus === 'all' || focus === 'closures'
1588
+ ? scanOversizedClosures(snapshot, totalSize)
1589
+ : [];
1590
+ const appendOnlyAlerts = focus === 'all' ? scanAppendOnlyRecords(snapshot, totalSize) : [];
1591
+ const allPatternAlerts = [
1592
+ ...frameworkAlerts,
1593
+ ...closureAlerts,
1594
+ ...appendOnlyAlerts,
1595
+ ...asyncAlerts,
1596
+ ];
1597
+ if (allPatternAlerts.length > 0) {
1598
+ lines.push('## Known Leak Patterns');
1599
+ lines.push('');
1600
+ for (const alert of allPatternAlerts) {
1601
+ lines.push(alert);
1602
+ lines.push('');
1603
+ }
1604
+ }
645
1605
  const sourceHints = extractSourceHints(findings.map(f => f.trace));
646
1606
  if (sourceHints.length > 0) {
647
1607
  lines.push('## Source Hints');
@@ -655,23 +1615,25 @@ export function registerAutoInvestigate(server) {
655
1615
  }
656
1616
  lines.push('');
657
1617
  }
658
- lines.push('## Suggested Next Steps');
659
- lines.push('');
660
- let stepNum = 1;
661
- if (findings[0]?.pinchPoint) {
662
- const pp = findings[0].pinchPoint;
663
- lines.push(`${stepNum++}. Inspect pinch point: \`memlab_get_node(${pp.nodeId})\` then \`memlab_dominator_subtree(${pp.nodeId})\``);
664
- }
665
- lines.push(`${stepNum++}. Trace top retainer: \`memlab_trace_dominators(${findings[0].node.id})\` for full dominator chain in one call`);
666
- lines.push(`${stepNum++}. Check for patterns: \`memlab_retainer_summary\` with class name of top objects`);
667
- if (promises.pendingCount > 0 && promises.topPending.length > 0) {
668
- lines.push(`${stepNum++}. Investigate pending promises: \`memlab_trace_dominators(${promises.topPending[0].nodeId})\``);
669
- }
670
- if (caches.length > 0) {
671
- lines.push(`${stepNum++}. Analyze caches: \`memlab_cache_analysis\` for detailed cache inspection`);
672
- }
673
- if (shapes.length > 0) {
674
- lines.push(`${stepNum++}. Explore data shapes: \`memlab_shape_histogram\` for detailed shape clustering`);
1618
+ if (!suggestionsSuppressed()) {
1619
+ lines.push('## Suggested Next Steps');
1620
+ lines.push('');
1621
+ let stepNum = 1;
1622
+ if (findings[0]?.pinchPoint) {
1623
+ const pp = findings[0].pinchPoint;
1624
+ lines.push(`${stepNum++}. Inspect pinch point: \`memlab_get_node(${pp.nodeId})\` then \`memlab_dominator_subtree(${pp.nodeId})\``);
1625
+ }
1626
+ lines.push(`${stepNum++}. Trace top retainer: \`memlab_trace_dominators(${findings[0].node.id})\` for full dominator chain in one call`);
1627
+ lines.push(`${stepNum++}. Check for patterns: \`memlab_retainer_summary\` with class name of top objects`);
1628
+ if (promises.pendingCount > 0 && promises.topPending.length > 0) {
1629
+ lines.push(`${stepNum++}. Investigate pending promises: \`memlab_trace_dominators(${promises.topPending[0].nodeId})\``);
1630
+ }
1631
+ if (caches.length > 0) {
1632
+ lines.push(`${stepNum++}. Analyze caches: \`memlab_cache_analysis\` for detailed cache inspection`);
1633
+ }
1634
+ if (shapes.length > 0) {
1635
+ lines.push(`${stepNum++}. Explore data shapes: \`memlab_shape_histogram\` for detailed shape clustering`);
1636
+ }
675
1637
  }
676
1638
  return toolResult(lines.join('\n'));
677
1639
  }