@memlab/mcp-server 2.18.1 → 2.52.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 (186) hide show
  1. package/README.md +8 -14
  2. package/bin/.eslintrc.js +26 -0
  3. package/bin/memlab-cli.js +313 -0
  4. package/dist/artifact-classes.d.ts +86 -0
  5. package/dist/artifact-classes.d.ts.map +1 -0
  6. package/dist/artifact-classes.js +261 -0
  7. package/dist/artifact-classes.js.map +1 -0
  8. package/dist/capture-mode.d.ts +60 -0
  9. package/dist/capture-mode.d.ts.map +1 -0
  10. package/dist/capture-mode.js +39 -0
  11. package/dist/capture-mode.js.map +1 -0
  12. package/dist/guardrail.d.ts.map +1 -1
  13. package/dist/guardrail.js +13 -0
  14. package/dist/guardrail.js.map +1 -1
  15. package/dist/heap-state.d.ts +89 -2
  16. package/dist/heap-state.d.ts.map +1 -1
  17. package/dist/heap-state.js +186 -2
  18. package/dist/heap-state.js.map +1 -1
  19. package/dist/index.js +39 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/progress.d.ts +25 -0
  22. package/dist/progress.d.ts.map +1 -0
  23. package/dist/progress.js +46 -0
  24. package/dist/progress.js.map +1 -0
  25. package/dist/result-handles.d.ts +11 -0
  26. package/dist/result-handles.d.ts.map +1 -0
  27. package/dist/result-handles.js +77 -0
  28. package/dist/result-handles.js.map +1 -0
  29. package/dist/string-index.d.ts +55 -0
  30. package/dist/string-index.d.ts.map +1 -0
  31. package/dist/string-index.js +91 -0
  32. package/dist/string-index.js.map +1 -0
  33. package/dist/tool-registry.d.ts +42 -0
  34. package/dist/tool-registry.d.ts.map +1 -0
  35. package/dist/tool-registry.js +20 -0
  36. package/dist/tool-registry.js.map +1 -0
  37. package/dist/tools/analyze-run.d.ts +12 -0
  38. package/dist/tools/analyze-run.d.ts.map +1 -0
  39. package/dist/tools/analyze-run.js +343 -0
  40. package/dist/tools/analyze-run.js.map +1 -0
  41. package/dist/tools/app-heap.d.ts +12 -0
  42. package/dist/tools/app-heap.d.ts.map +1 -0
  43. package/dist/tools/app-heap.js +140 -0
  44. package/dist/tools/app-heap.js.map +1 -0
  45. package/dist/tools/batch.d.ts +12 -0
  46. package/dist/tools/batch.d.ts.map +1 -0
  47. package/dist/tools/batch.js +168 -0
  48. package/dist/tools/batch.js.map +1 -0
  49. package/dist/tools/cache-analysis.d.ts.map +1 -1
  50. package/dist/tools/cache-analysis.js +156 -5
  51. package/dist/tools/cache-analysis.js.map +1 -1
  52. package/dist/tools/class-histogram.d.ts.map +1 -1
  53. package/dist/tools/class-histogram.js +14 -3
  54. package/dist/tools/class-histogram.js.map +1 -1
  55. package/dist/tools/collection-trend.d.ts +55 -0
  56. package/dist/tools/collection-trend.d.ts.map +1 -0
  57. package/dist/tools/collection-trend.js +315 -0
  58. package/dist/tools/collection-trend.js.map +1 -0
  59. package/dist/tools/detached-dom.d.ts +2 -0
  60. package/dist/tools/detached-dom.d.ts.map +1 -1
  61. package/dist/tools/detached-dom.js +291 -20
  62. package/dist/tools/detached-dom.js.map +1 -1
  63. package/dist/tools/dev-artifacts.d.ts +62 -6
  64. package/dist/tools/dev-artifacts.d.ts.map +1 -1
  65. package/dist/tools/dev-artifacts.js +466 -23
  66. package/dist/tools/dev-artifacts.js.map +1 -1
  67. package/dist/tools/dominator-attribution.d.ts +12 -0
  68. package/dist/tools/dominator-attribution.d.ts.map +1 -0
  69. package/dist/tools/dominator-attribution.js +189 -0
  70. package/dist/tools/dominator-attribution.js.map +1 -0
  71. package/dist/tools/dominator-chain.d.ts +12 -0
  72. package/dist/tools/dominator-chain.d.ts.map +1 -0
  73. package/dist/tools/dominator-chain.js +92 -0
  74. package/dist/tools/dominator-chain.js.map +1 -0
  75. package/dist/tools/duplicate-objects.d.ts +12 -0
  76. package/dist/tools/duplicate-objects.d.ts.map +1 -0
  77. package/dist/tools/duplicate-objects.js +172 -0
  78. package/dist/tools/duplicate-objects.js.map +1 -0
  79. package/dist/tools/duplicated-strings.d.ts.map +1 -1
  80. package/dist/tools/duplicated-strings.js +46 -38
  81. package/dist/tools/duplicated-strings.js.map +1 -1
  82. package/dist/tools/eval.d.ts +1 -0
  83. package/dist/tools/eval.d.ts.map +1 -1
  84. package/dist/tools/eval.js +785 -28
  85. package/dist/tools/eval.js.map +1 -1
  86. package/dist/tools/explain-delta.d.ts +12 -0
  87. package/dist/tools/explain-delta.d.ts.map +1 -0
  88. package/dist/tools/explain-delta.js +170 -0
  89. package/dist/tools/explain-delta.js.map +1 -0
  90. package/dist/tools/find-nodes-by-class.d.ts.map +1 -1
  91. package/dist/tools/find-nodes-by-class.js +112 -13
  92. package/dist/tools/find-nodes-by-class.js.map +1 -1
  93. package/dist/tools/finding-index.d.ts +22 -0
  94. package/dist/tools/finding-index.d.ts.map +1 -0
  95. package/dist/tools/finding-index.js +247 -0
  96. package/dist/tools/finding-index.js.map +1 -0
  97. package/dist/tools/for-each.d.ts.map +1 -1
  98. package/dist/tools/for-each.js +15 -3
  99. package/dist/tools/for-each.js.map +1 -1
  100. package/dist/tools/get-referrers.d.ts.map +1 -1
  101. package/dist/tools/get-referrers.js +17 -0
  102. package/dist/tools/get-referrers.js.map +1 -1
  103. package/dist/tools/hunt-report.d.ts +12 -0
  104. package/dist/tools/hunt-report.d.ts.map +1 -0
  105. package/dist/tools/hunt-report.js +186 -0
  106. package/dist/tools/hunt-report.js.map +1 -0
  107. package/dist/tools/hypothesis.d.ts +12 -0
  108. package/dist/tools/hypothesis.d.ts.map +1 -0
  109. package/dist/tools/hypothesis.js +221 -0
  110. package/dist/tools/hypothesis.js.map +1 -0
  111. package/dist/tools/intern-opportunities.d.ts.map +1 -1
  112. package/dist/tools/intern-opportunities.js +112 -47
  113. package/dist/tools/intern-opportunities.js.map +1 -1
  114. package/dist/tools/ladder.d.ts +33 -0
  115. package/dist/tools/ladder.d.ts.map +1 -0
  116. package/dist/tools/ladder.js +266 -0
  117. package/dist/tools/ladder.js.map +1 -0
  118. package/dist/tools/largest-objects.d.ts.map +1 -1
  119. package/dist/tools/largest-objects.js +6 -2
  120. package/dist/tools/largest-objects.js.map +1 -1
  121. package/dist/tools/leak-report.d.ts +12 -0
  122. package/dist/tools/leak-report.d.ts.map +1 -0
  123. package/dist/tools/leak-report.js +256 -0
  124. package/dist/tools/leak-report.js.map +1 -0
  125. package/dist/tools/load-snapshot.d.ts +18 -0
  126. package/dist/tools/load-snapshot.d.ts.map +1 -1
  127. package/dist/tools/load-snapshot.js +211 -13
  128. package/dist/tools/load-snapshot.js.map +1 -1
  129. package/dist/tools/map-entries.d.ts +12 -0
  130. package/dist/tools/map-entries.d.ts.map +1 -0
  131. package/dist/tools/map-entries.js +139 -0
  132. package/dist/tools/map-entries.js.map +1 -0
  133. package/dist/tools/match-object.d.ts +12 -0
  134. package/dist/tools/match-object.d.ts.map +1 -0
  135. package/dist/tools/match-object.js +165 -0
  136. package/dist/tools/match-object.js.map +1 -0
  137. package/dist/tools/object-shape.d.ts.map +1 -1
  138. package/dist/tools/object-shape.js +53 -5
  139. package/dist/tools/object-shape.js.map +1 -1
  140. package/dist/tools/population-diff.d.ts +57 -0
  141. package/dist/tools/population-diff.d.ts.map +1 -0
  142. package/dist/tools/population-diff.js +338 -0
  143. package/dist/tools/population-diff.js.map +1 -0
  144. package/dist/tools/retainer-layers.d.ts +23 -0
  145. package/dist/tools/retainer-layers.d.ts.map +1 -0
  146. package/dist/tools/retainer-layers.js +179 -0
  147. package/dist/tools/retainer-layers.js.map +1 -0
  148. package/dist/tools/retainer-summary.d.ts +1 -0
  149. package/dist/tools/retainer-summary.d.ts.map +1 -1
  150. package/dist/tools/retainer-summary.js +128 -32
  151. package/dist/tools/retainer-summary.js.map +1 -1
  152. package/dist/tools/retainer-trace.d.ts.map +1 -1
  153. package/dist/tools/retainer-trace.js +4 -2
  154. package/dist/tools/retainer-trace.js.map +1 -1
  155. package/dist/tools/search-nodes.d.ts.map +1 -1
  156. package/dist/tools/search-nodes.js +5 -1
  157. package/dist/tools/search-nodes.js.map +1 -1
  158. package/dist/tools/search-strings.d.ts.map +1 -1
  159. package/dist/tools/search-strings.js +53 -7
  160. package/dist/tools/search-strings.js.map +1 -1
  161. package/dist/tools/sequence-analysis.d.ts +49 -0
  162. package/dist/tools/sequence-analysis.d.ts.map +1 -1
  163. package/dist/tools/sequence-analysis.js +266 -86
  164. package/dist/tools/sequence-analysis.js.map +1 -1
  165. package/dist/tools/shape-histogram.d.ts.map +1 -1
  166. package/dist/tools/shape-histogram.js +9 -2
  167. package/dist/tools/shape-histogram.js.map +1 -1
  168. package/dist/tools/string-patterns.d.ts.map +1 -1
  169. package/dist/tools/string-patterns.js +5 -4
  170. package/dist/tools/string-patterns.js.map +1 -1
  171. package/dist/tools/tools-index.d.ts +12 -0
  172. package/dist/tools/tools-index.d.ts.map +1 -0
  173. package/dist/tools/tools-index.js +342 -0
  174. package/dist/tools/tools-index.js.map +1 -0
  175. package/dist/tools/verify-fix.d.ts +23 -0
  176. package/dist/tools/verify-fix.d.ts.map +1 -0
  177. package/dist/tools/verify-fix.js +211 -0
  178. package/dist/tools/verify-fix.js.map +1 -0
  179. package/dist/tools/weakmap-entries.d.ts.map +1 -1
  180. package/dist/tools/weakmap-entries.js +6 -1
  181. package/dist/tools/weakmap-entries.js.map +1 -1
  182. package/dist/utils.d.ts +146 -1
  183. package/dist/utils.d.ts.map +1 -1
  184. package/dist/utils.js +335 -10
  185. package/dist/utils.js.map +1 -1
  186. package/package.json +5 -4
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @format
8
+ * @oncall memory_lab
9
+ */
10
+ import { z } from 'zod';
11
+ import memlabHeapAnalysis from '@memlab/heap-analysis';
12
+ const { getFullHeapFromFile } = memlabHeapAnalysis;
13
+ import { formatBytes, formatNumber, markdownTable, errorResult, toolResult, } from '../utils.js';
14
+ import { artifactLabel } from '../artifact-classes.js';
15
+ import { computeSequenceTrends, normalizeClassName, } from './sequence-analysis.js';
16
+ import { collectDevRoots, computeReachableWithoutDevRoots, } from './dev-artifacts.js';
17
+ import { getFirstNonFrameworkRetainer } from './detached-dom.js';
18
+ // Nodes kept per candidate class for the retainer sample. Small on purpose: the
19
+ // question is "what shape of thing holds these", and a handful of instances
20
+ // answers it — walking thousands would cost more than the trend pass itself.
21
+ const MAX_SAMPLES_PER_CLASS = 8;
22
+ const RETAINER_SAMPLES = 3;
23
+ // A class whose instances are overwhelmingly dev-root-retained is a measurement
24
+ // artifact in production terms, whatever its growth curve looks like. Matches
25
+ // the majority-not-any threshold intern_opportunities uses, for the same reason:
26
+ // a class can legitimately mix a few console-held instances with real data.
27
+ const DEV_ONLY_SHARE = 0.8;
28
+ // Deliberately NOT a sum of retainedSize across the class: retained sizes
29
+ // overlap wherever instances nest, so summing them reports figures larger than
30
+ // the heap (measured: 5.9 GB of `Object` in a 200 MB heap). Net self-size delta
31
+ // across the ladder is additive and is the growth the report is about anyway.
32
+ function displayName(row) {
33
+ return row.name.length > 0 ? row.name : `(unnamed ${row.type})`;
34
+ }
35
+ function topRetainerLabel(ev) {
36
+ const counts = new Map();
37
+ for (const node of ev.samples.slice(0, RETAINER_SAMPLES)) {
38
+ const label = getFirstNonFrameworkRetainer(node);
39
+ counts.set(label, (counts.get(label) ?? 0) + 1);
40
+ }
41
+ let best = '(unknown)';
42
+ let bestCount = 0;
43
+ for (const [label, c] of counts) {
44
+ if (c > bestCount) {
45
+ best = label;
46
+ bestCount = c;
47
+ }
48
+ }
49
+ return best;
50
+ }
51
+ export function registerLeakReport(server) {
52
+ server.tool('memlab_leak_report', 'One-call leak triage across an ORDERED ladder of >=2 heap snapshots: runs the growth-trend pass, then gathers per-class EVIDENCE from the final snapshot and returns a single table — class, per-rung counts, Δ and Δ/cycle, how much of it is dev/automation-retained, the dominant retainer, and a verdict hint. ' +
53
+ 'Exists because the trend pass alone cannot tell a leak from an artifact: every hunt then ran memlab_dev_artifacts and a retainer trace by hand on each grower and joined the three outputs mentally, which is the step that gets skipped right before something is reported as a production leak. Composes memlab_sequence_analysis with memlab_dev_artifacts and a retainer sample; costs one extra snapshot load (the last rung) on top of the ladder pass. ' +
54
+ 'The verdict column is a HINT, not a conclusion — confirm a candidate with memlab_retainer_trace on the example node before calling it a leak. Paths may be local, manifold:// URLs, or bare filenames.', {
55
+ paths: z
56
+ .array(z.string())
57
+ .min(1)
58
+ .describe('Ordered list of >=2 snapshot paths (oldest first): local absolute paths, manifold:// URLs, or bare snapshot filenames. A single ["ladder:<name>"] entry expands to a ladder saved with memlab_ladder.'),
59
+ cycles: z
60
+ .number()
61
+ .optional()
62
+ .describe('Number of interaction cycles driven between the FIRST and LAST snapshot. When provided, a "Δ/cycle" column is reported — a per-cycle rate is what says whether growth scales with interaction, which a total cannot.'),
63
+ limit: z
64
+ .number()
65
+ .optional()
66
+ .default(10)
67
+ .describe('Maximum number of growing classes to gather evidence for (default 10).'),
68
+ min_growth_count: z
69
+ .number()
70
+ .optional()
71
+ .default(50)
72
+ .describe('Only consider classes whose net instance-count growth is at least this (default 50).'),
73
+ monotonic_only: z
74
+ .boolean()
75
+ .optional()
76
+ .default(false)
77
+ .describe('Only consider classes that grew at EVERY step (default false: grew-net classes are included and flagged as noisy).'),
78
+ include_artifacts: z
79
+ .boolean()
80
+ .optional()
81
+ .default(false)
82
+ .describe('Include classes that are known measurement artifacts (CDP inspector retention, V8 JIT warmup, Blink a11y caches, captured Error stacks). Default false: they are counted in a one-line summary instead of consuming evidence slots, since none of them is an app leak.'),
83
+ max_file_size_mb: z
84
+ .number()
85
+ .optional()
86
+ .describe('Per-file size ceiling in MB, matching memlab_load_snapshot / memlab_sequence_analysis defaults. Snapshots are loaded one at a time and dropped before the next.'),
87
+ }, async ({ paths, cycles, limit, min_growth_count, monotonic_only, include_artifacts, max_file_size_mb, }) => {
88
+ try {
89
+ const { steps, rows } = await computeSequenceTrends(paths, {
90
+ minGrowthCount: min_growth_count,
91
+ monotonicOnly: monotonic_only,
92
+ maxFileSizeMB: max_file_size_mb,
93
+ toolName: 'memlab_leak_report',
94
+ });
95
+ const n = steps.length;
96
+ const artifactRows = rows.filter(r => r.artifact != null);
97
+ const candidates = (include_artifacts ? rows : rows.filter(r => r.artifact == null)).slice(0, limit);
98
+ const heapNet = steps[n - 1].totalSize - steps[0].totalSize;
99
+ const lines = [
100
+ `## Leak report (${n} snapshots)`,
101
+ '',
102
+ `Heap self-size ${heapNet >= 0 ? 'grew' : 'shrank'} by ${formatBytes(Math.abs(heapNet))} across the ladder (${formatNumber(steps[0].nodeCount)} → ${formatNumber(steps[n - 1].nodeCount)} nodes).`,
103
+ '',
104
+ ];
105
+ if (n === 2) {
106
+ lines.push('> ⚠️ **Only 2 snapshots: "↑ every step" is monotonic by construction** and cannot separate a real trend from a single GC-band sample. Capture at least a third rung before treating anything here as a leak.', '');
107
+ }
108
+ if (candidates.length === 0) {
109
+ lines.push(`No non-artifact class grew by >= ${formatNumber(min_growth_count)} instances across the ladder — no unbounded-growth signal to report.`);
110
+ if (artifactRows.length > 0) {
111
+ lines.push('', `> 🧹 ${formatNumber(artifactRows.length)} growing class(es) were known measurement artifacts (pass \`include_artifacts: true\` to see them).`);
112
+ }
113
+ return toolResult(lines.join('\n'));
114
+ }
115
+ // Evidence pass. The trend loop drops each graph before loading the
116
+ // next, so the final rung is re-opened here — one extra load, and the
117
+ // only way to ask retention questions about the classes the trend pass
118
+ // just identified.
119
+ const snapshot = await getFullHeapFromFile(steps[n - 1].localPath);
120
+ const devRoots = collectDevRoots(snapshot);
121
+ const reached = devRoots.byId.size > 0
122
+ ? computeReachableWithoutDevRoots(snapshot, devRoots)
123
+ : null;
124
+ const evidence = new Map();
125
+ for (const c of candidates) {
126
+ evidence.set(c.key, {
127
+ total: 0,
128
+ devOnly: 0,
129
+ samples: [],
130
+ example: null,
131
+ exampleRetained: -1,
132
+ anyExample: null,
133
+ });
134
+ }
135
+ snapshot.nodes.forEach(node => {
136
+ if (node.id <= 3)
137
+ return;
138
+ // Must key exactly as buildHistogram does, normalization included, or
139
+ // per-instance Context/scope classes never match their trend row.
140
+ const ev = evidence.get(`${node.type}::${normalizeClassName(node.name)}`);
141
+ if (!ev)
142
+ return;
143
+ ev.total++;
144
+ if (reached != null && reached[node.nodeIndex] === 0)
145
+ ev.devOnly++;
146
+ if (ev.anyExample == null)
147
+ ev.anyExample = node;
148
+ if (!node.hasPathEdge)
149
+ return;
150
+ if (ev.samples.length < MAX_SAMPLES_PER_CLASS)
151
+ ev.samples.push(node);
152
+ if (node.retainedSize > ev.exampleRetained) {
153
+ ev.exampleRetained = node.retainedSize;
154
+ ev.example = node;
155
+ }
156
+ });
157
+ // The biggest traceable instance is the most informative one to walk, so
158
+ // put it at the head of every retainer sample.
159
+ for (const ev of evidence.values()) {
160
+ if (ev.example != null) {
161
+ ev.samples = [
162
+ ev.example,
163
+ ...ev.samples.filter(s => s.id !== ev.example.id),
164
+ ];
165
+ }
166
+ }
167
+ const perCycle = cycles != null && cycles > 0;
168
+ const showDevOnly = reached != null;
169
+ const headers = [
170
+ 'Class',
171
+ 'Type',
172
+ ...steps.map((_, i) => `#${i + 1}`),
173
+ 'Δ count',
174
+ ...(perCycle ? ['Δ/cycle'] : []),
175
+ 'Δ size',
176
+ ...(showDevOnly ? ['Dev-only'] : []),
177
+ 'Top retainer',
178
+ 'Verdict hint',
179
+ ];
180
+ const rightCols = new Set();
181
+ for (let i = 2; i < headers.length - 2; i++)
182
+ rightCols.add(i);
183
+ let leakCandidates = 0;
184
+ let devOnlyClasses = 0;
185
+ const tableRows = candidates.map(r => {
186
+ const ev = evidence.get(r.key);
187
+ const devShare = ev.total > 0 ? ev.devOnly / ev.total : 0;
188
+ const isDevOnly = showDevOnly && devShare >= DEV_ONLY_SHARE;
189
+ if (isDevOnly)
190
+ devOnlyClasses++;
191
+ let verdict;
192
+ if (r.artifact != null) {
193
+ verdict = artifactLabel(r.artifact);
194
+ }
195
+ else if (isDevOnly) {
196
+ verdict = '🛠 dev/automation-retained (not production)';
197
+ }
198
+ else if (r.trend === 'monotonic-up') {
199
+ verdict = '↑ every step — LEAK candidate';
200
+ leakCandidates++;
201
+ }
202
+ else {
203
+ verdict = 'grew net (noisy)';
204
+ }
205
+ const label = displayName(r);
206
+ return [
207
+ label.length > 34 ? label.slice(0, 31) + '…' : label,
208
+ r.type,
209
+ ...r.counts.map(c => formatNumber(c)),
210
+ `+${formatNumber(r.netCount)}`,
211
+ ...(perCycle ? [(r.netCount / cycles).toFixed(2)] : []),
212
+ `${r.netSize >= 0 ? '+' : ''}${formatBytes(r.netSize)}`,
213
+ ...(showDevOnly
214
+ ? [
215
+ ev.total === 0
216
+ ? '—'
217
+ : ev.devOnly === 0
218
+ ? '—'
219
+ : `${(devShare * 100).toFixed(0)}%`,
220
+ ]
221
+ : []),
222
+ (label => (label.length > 44 ? label.slice(0, 41) + '…' : label))(topRetainerLabel(ev)),
223
+ verdict,
224
+ ];
225
+ });
226
+ lines.push(markdownTable(headers, tableRows, rightCols));
227
+ lines.push('', `**${formatNumber(leakCandidates)} leak candidate(s)** of ${formatNumber(candidates.length)} growing class(es) examined` +
228
+ (devOnlyClasses > 0
229
+ ? `; ${formatNumber(devOnlyClasses)} ruled out as dev/automation-retained`
230
+ : '') +
231
+ (artifactRows.length > 0 && !include_artifacts
232
+ ? `; ${formatNumber(artifactRows.length)} known measurement artifact(s) not shown (\`include_artifacts: true\`)`
233
+ : '') +
234
+ '.');
235
+ if (!showDevOnly) {
236
+ lines.push('', '_No dev/automation roots were found in the final snapshot, so the dev-only column is omitted — nothing here is being attributed to the inspector._');
237
+ }
238
+ // Point at the single next call for the strongest candidate rather than
239
+ // a menu: the failure mode this tool exists to fix is a plausible
240
+ // grower being reported without anyone tracing it.
241
+ const strongest = candidates.find(r => r.artifact == null &&
242
+ r.trend === 'monotonic-up' &&
243
+ evidence.get(r.key)?.example != null);
244
+ if (strongest) {
245
+ const ev = evidence.get(strongest.key);
246
+ const example = ev.example;
247
+ lines.push('', `**Next:** confirm the top candidate before reporting it — \`memlab_retainer_trace({node_id: ${example.id}})\` on the largest traceable \`${displayName(strongest)}\` instance in the final rung, then \`memlab_dominator_chain\` on whatever owns it. Counts alone cannot distinguish a leak from a cache that grew and will be evicted.`);
248
+ }
249
+ return toolResult(lines.join('\n'));
250
+ }
251
+ catch (err) {
252
+ return errorResult(err);
253
+ }
254
+ });
255
+ }
256
+ //# sourceMappingURL=leak-report.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"leak-report.js","sourceRoot":"","sources":["../../src/tools/leak-report.ts"],"names":[],"mappings":"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"}
@@ -16,6 +16,24 @@ export declare const DEFAULT_MAX_EDGES = 24000000;
16
16
  export declare const WARN_NODE_COUNT = 2000000;
17
17
  export declare const NODES_PER_HEAP_MB = 1700;
18
18
  export declare const EDGES_PER_HEAP_MB = 6800;
19
+ export declare const LOAD_BYTES_PER_NODE = 200;
20
+ export declare const LOAD_BYTES_PER_EDGE = 40;
21
+ export declare const HEADROOM_SAFETY_FRACTION = 0.8;
22
+ /**
23
+ * Projected additional heap (MB) a full load of this graph will occupy.
24
+ * Light loads skip the dominator/retained-size arrays; the parsed graph still
25
+ * dominates, so the discount is modest and intentionally conservative.
26
+ */
27
+ export declare function estimateLoadHeapMB(counts: {
28
+ nodeCount: number;
29
+ edgeCount: number;
30
+ }, light: boolean): number;
31
+ /**
32
+ * Old-space headroom (MB) left for a new load: the configured limit minus what
33
+ * is already committed. Returns null when the limit can't be read, in which case
34
+ * the caller skips the guard rather than guessing.
35
+ */
36
+ export declare function getHeapHeadroomMB(): number | null;
19
37
  /**
20
38
  * Configured V8 old-space limit in MB (reflects --max-old-space-size). Returns 0
21
39
  * when it can't be read, so callers fall back to the fixed floors.
@@ -1 +1 @@
1
- {"version":3,"file":"load-snapshot.d.ts","sourceRoot":"","sources":["../../src/tools/load-snapshot.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,yCAAyC,CAAC;AA4BvE,eAAO,MAAM,uBAAuB,sCAAsC,CAAC;AAU3E,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAC5C,eAAO,MAAM,4BAA4B,OAAO,CAAC;AAmBjD,eAAO,MAAM,iBAAiB,WAAa,CAAC;AAC5C,eAAO,MAAM,iBAAiB,WAAa,CAAC;AAC5C,eAAO,MAAM,eAAe,UAAY,CAAC;AAazC,eAAO,MAAM,iBAAiB,OAAO,CAAC;AACtC,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAM3C;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,IAAI;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB,CAoBA;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,GAChB;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAC,GAAG,IAAI,CAgB/C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAC,EAC9C,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAC,GAC7C,MAAM,GAAG,IAAI,CASf;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,mBAAmB,EAAE,OAAO,GAC3B,MAAM,CAKR;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG;IACrD,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B,CAsEA;AAmCD,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAWhE;AAsGD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAuV5D"}
1
+ {"version":3,"file":"load-snapshot.d.ts","sourceRoot":"","sources":["../../src/tools/load-snapshot.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,yCAAyC,CAAC;AAuCvE,eAAO,MAAM,uBAAuB,sCAAsC,CAAC;AAU3E,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAC5C,eAAO,MAAM,4BAA4B,OAAO,CAAC;AAmBjD,eAAO,MAAM,iBAAiB,WAAa,CAAC;AAC5C,eAAO,MAAM,iBAAiB,WAAa,CAAC;AAC5C,eAAO,MAAM,eAAe,UAAY,CAAC;AAazC,eAAO,MAAM,iBAAiB,OAAO,CAAC;AACtC,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAStC,eAAO,MAAM,mBAAmB,MAAM,CAAC;AACvC,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAMtC,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAE5C;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAC,EAC9C,KAAK,EAAE,OAAO,GACb,MAAM,CAKR;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,GAAG,IAAI,CAUjD;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAM3C;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,IAAI;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB,CAoBA;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,GAChB;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAC,GAAG,IAAI,CAgB/C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAC,EAC9C,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAC,GAC7C,MAAM,GAAG,IAAI,CASf;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,mBAAmB,EAAE,OAAO,GAC3B,MAAM,CAKR;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG;IACrD,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B,CAsEA;AAmCD,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAWhE;AA4HD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAmgB5D"}
@@ -13,10 +13,16 @@ import path from 'path';
13
13
  import v8 from 'v8';
14
14
  import { execFileSync } from 'child_process';
15
15
  import { z } from 'zod';
16
+ import memlabCore from '@memlab/core';
16
17
  import memlabHeapAnalysis from '@memlab/heap-analysis';
17
18
  const { getFullHeapFromFile } = memlabHeapAnalysis;
18
- import { setSnapshot, getSnapshotMetadata, setSessionConfig, listSnapshots, clearAllSnapshots, } from '../heap-state.js';
19
- import { formatBytes, formatNumber, truncateNodeName, errorResult, textResult, } from '../utils.js';
19
+ // `getFullHeapFromFile` = parse + `analysis.preparePathFinder`. Light mode calls
20
+ // only the parse half, so the two pieces are reached directly here. Measured on
21
+ // a 380 MB / 4.19M-node browser snapshot: parse 7.9s, preparePathFinder 9.9s.
22
+ const { utils: memlabUtils } = memlabCore;
23
+ import { setSnapshot, getSnapshotMetadata, setSessionConfig, listSnapshots, clearAllSnapshots, evictLeastRecentlyUsed, findResidentByPath, retainOnlySnapshot, setCurrentSnapshot, } from '../heap-state.js';
24
+ import { makeProgressReporter } from '../progress.js';
25
+ import { formatBytes, formatNumber, truncateNodeName, errorResult, textResult, suggestionsSuppressed, } from '../utils.js';
20
26
  // The bucket the Nest auto-capture + the Manifold links in diffs use, so a
21
27
  // bare snapshot filename resolves predictably (Feedback §7).
22
28
  export const DEFAULT_MANIFOLD_BUCKET = 'nest_server_nodejs_heap_snapshots';
@@ -63,6 +69,48 @@ export const WARN_NODE_COUNT = 2_000_000;
63
69
  // 45–49M-edge band, still rejects the 89M-edge / 2 GB band).
64
70
  export const NODES_PER_HEAP_MB = 1700;
65
71
  export const EDGES_PER_HEAP_MB = 6800;
72
+ // Per-node / per-edge working-set estimate for a full load, fitted to a
73
+ // measured browser capture: 4,185,734 nodes + 18,755,739 edges peaked at
74
+ // ~1,500 MB RSS (200 B/node + 40 B/edge predicts ~1,588 MB). Deliberately a
75
+ // slight OVER-estimate — the failure this guards against is an OOM that kills
76
+ // every resident snapshot, so erring toward "not enough headroom" is the cheap
77
+ // direction to be wrong in. File size is not used: it correlates poorly with
78
+ // graph cost, which is why the node/edge ceilings exist at all.
79
+ export const LOAD_BYTES_PER_NODE = 200;
80
+ export const LOAD_BYTES_PER_EDGE = 40;
81
+ // Fraction of remaining old-space a single load may be projected to consume
82
+ // before the guard fires. Well below 1.0 because the estimate is approximate,
83
+ // the parse allocates in bursts the GC cannot always keep up with, and analysis
84
+ // after the load needs room of its own.
85
+ export const HEADROOM_SAFETY_FRACTION = 0.8;
86
+ /**
87
+ * Projected additional heap (MB) a full load of this graph will occupy.
88
+ * Light loads skip the dominator/retained-size arrays; the parsed graph still
89
+ * dominates, so the discount is modest and intentionally conservative.
90
+ */
91
+ export function estimateLoadHeapMB(counts, light) {
92
+ const bytes = counts.nodeCount * LOAD_BYTES_PER_NODE +
93
+ counts.edgeCount * LOAD_BYTES_PER_EDGE;
94
+ return Math.round((bytes / (1024 * 1024)) * (light ? 0.85 : 1));
95
+ }
96
+ /**
97
+ * Old-space headroom (MB) left for a new load: the configured limit minus what
98
+ * is already committed. Returns null when the limit can't be read, in which case
99
+ * the caller skips the guard rather than guessing.
100
+ */
101
+ export function getHeapHeadroomMB() {
102
+ try {
103
+ const s = v8.getHeapStatistics();
104
+ const limit = s.heap_size_limit / (1024 * 1024);
105
+ const used = s.used_heap_size / (1024 * 1024);
106
+ if (!Number.isFinite(limit) || limit <= 0)
107
+ return null;
108
+ return Math.max(0, Math.round(limit - used));
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
66
114
  /**
67
115
  * Configured V8 old-space limit in MB (reflects --max-old-space-size). Returns 0
68
116
  * when it can't be read, so callers fall back to the fixed floors.
@@ -285,7 +333,7 @@ function detectEnv(snapshot) {
285
333
  return 'node';
286
334
  return 'unknown';
287
335
  }
288
- function quickDiagnosis(snapshot, totalSelfSize) {
336
+ function quickDiagnosis(snapshot, totalSelfSize, light = false) {
289
337
  const warnings = [];
290
338
  const largeStrings = [];
291
339
  const stringCounts = new Map();
@@ -322,7 +370,10 @@ function quickDiagnosis(snapshot, totalSelfSize) {
322
370
  const display = val.length > 40 ? val.slice(0, 40) + '…' : val;
323
371
  warnings.push(`⚠ "${display}" duplicated ${formatNumber(count)} times`);
324
372
  }
325
- const largest = findLargestObject(snapshot);
373
+ // Skipped on a light load: `findLargestObject` ranks by retainedSize, which
374
+ // is uncomputed there — every candidate would read 0 and the "retains N% of
375
+ // heap" line would be fiction.
376
+ const largest = light ? null : findLargestObject(snapshot);
326
377
  if (largest && totalSelfSize > 0) {
327
378
  const pct = ((largest.size / totalSelfSize) * 100).toFixed(0);
328
379
  if (largest.size >= totalSelfSize * 0.3) {
@@ -336,11 +387,32 @@ function quickDiagnosis(snapshot, totalSelfSize) {
336
387
  for (const [name, count] of anomalous) {
337
388
  warnings.push(`⚠ ${formatNumber(count)}× \`${name}\` instances`);
338
389
  }
339
- if (warnings.length > 0) {
340
- warnings.push('', 'Run `memlab_quick_diagnosis` for actionable triage or `memlab_auto_investigate` for deep analysis.');
341
- }
342
390
  return warnings;
343
391
  }
392
+ /**
393
+ * What to call next, indexed by the QUESTION rather than by tool.
394
+ *
395
+ * The old trailer always pointed at `quick_diagnosis` / `auto_investigate`
396
+ * regardless of why the snapshot was loaded, which is right for "I don't know
397
+ * what I'm looking at" and wrong for every other case. Measured effect of the
398
+ * generic version: over one long investigation 28 of the server's tools were
399
+ * used and the rest never were, including several that answer a stated question
400
+ * in a single call. Naming the question is what makes the right tool findable
401
+ * at the only moment the caller is guaranteed to be reading.
402
+ */
403
+ function nextStepMenu() {
404
+ return [
405
+ '**What do you want to know?**',
406
+ '- _What is this heap made of?_ → `memlab_app_heap` (application data vs bundle source vs V8 machinery — run it before trusting any percentage)',
407
+ '- _Anything obviously wrong?_ → `memlab_quick_diagnosis`, then `memlab_auto_investigate` for the deep dive',
408
+ '- _Is X a leak?_ → `memlab_hypothesis`',
409
+ '- _Who owns the growth?_ → `memlab_explain_delta` (needs a baseline loaded with `keep_previous:true`)',
410
+ '- _What grew across the ladder?_ → `memlab_leak_report` / `memlab_sequence_analysis`',
411
+ '- _What does this object pin?_ → `memlab_dominator_chain`, then `memlab_dominator_attribution`',
412
+ '- _Is it real or an artifact?_ → `memlab_dev_artifacts` (and `memlab_explain_delta` for JIT warmup, which it does not count)',
413
+ '- _Something else_ → `memlab_tools` lists every tool grouped by the question it answers',
414
+ ];
415
+ }
344
416
  export function registerLoadSnapshot(server) {
345
417
  server.tool('memlab_load_snapshot', 'Load and parse a .heapsnapshot file. This builds indexes, computes the dominator tree, and calculates retained sizes. Returns a quick diagnosis highlighting potential issues. Accepts a local absolute path, a manifold:// URL, or a bare snapshot filename (resolved against the nest_server_nodejs_heap_snapshots bucket and fetched automatically). Multiple snapshots can be kept resident — pass keep_previous:true to load several for diffing/comparison; switch between them with memlab_snapshots. Cost note: the load working set is several× the file size and the dominator pass runs uninterruptibly, so large/deep snapshots are memory- and time-heavy — run the server with NODE_OPTIONS="--max-old-space-size=8192", load one snapshot at a time (omit keep_previous) on large heaps, and prefer memlab_sequence_analysis (transient per-snapshot loads) when you only need trend/growth across a ladder. A node/edge-count ceiling (max_nodes/max_edges) refuses pathologically large loads before they wedge the server; the ceiling auto-scales from the server\'s configured --max-old-space-size (an 8 GB server admits the ~1.1 GB / ~14M-node / ~55M-edge Nest tier by default), and force:true self-sizes from the header counts so a one-off oversized load needs no manual max_nodes/max_edges.', {
346
418
  file_path: z
@@ -378,8 +450,24 @@ export function registerLoadSnapshot(server) {
378
450
  force: z
379
451
  .boolean()
380
452
  .optional()
381
- .describe('Bypass the node/edge-count ceiling and attempt the load anyway (default false). force:true is self-sizing — it uses the header-peeked counts, so you do NOT also need to pass max_nodes/max_edges. The load cannot be interrupted once started, so only use this when you accept a potentially very long load or possible OOM.'),
382
- }, async ({ file_path, alias, keep_previous, quiet, suppress_suggestions, max_file_size_mb, max_nodes, max_edges, force, }) => {
453
+ .describe('Bypass the node/edge-count ceiling AND the memory-headroom guard, and attempt the load anyway (default false). force:true is self-sizing — it uses the header-peeked counts, so you do NOT also need to pass max_nodes/max_edges. The load cannot be interrupted once started, so only use this when you accept a potentially very long load or possible OOM.'),
454
+ force_reload: z
455
+ .boolean()
456
+ .optional()
457
+ .default(false)
458
+ .describe('Re-parse even if this exact file is already resident in this server process (default false: an already-loaded file is switched to instead, reported as `cache: hit`). Only needed if the file changed on disk since it was loaded.'),
459
+ light: z
460
+ .boolean()
461
+ .optional()
462
+ .default(false)
463
+ .describe('Count-only load: parse the graph but SKIP the dominator / retained-size / shortest-path pass (default false). Roughly halves load time (measured on a 380 MB / 4.19M-node capture: 18s → 8s) for questions that only need counts, names, types, self sizes or string values. The saving is time, not memory — the parsed graph is the bulk of the working set. A light snapshot REFUSES any tool that needs retained sizes, dominators or retainer paths (memlab_retainer_trace, memlab_detached_dom, memlab_largest_objects, memlab_dominator_*, …) instead of silently reporting zeros; reload without light to run those. Light-safe: memlab_class_histogram (include_retained_size:false), memlab_search_nodes, memlab_search_strings, memlab_string_patterns, memlab_snapshot_summary, memlab_snapshot_header, memlab_snapshots.'),
464
+ }, async ({ file_path, alias, keep_previous, quiet, suppress_suggestions, max_file_size_mb, max_nodes, max_edges, force, force_reload, light, }, extra) => {
465
+ // 6 phases: resolve → guards → parse → (dominators | skipped) → index →
466
+ // diagnose. Reported at boundaries only; see progress.ts for why there is
467
+ // nothing finer-grained available.
468
+ const progress = makeProgressReporter(extra, 'load_snapshot');
469
+ const PHASES = 6;
470
+ const startedAt = Date.now();
383
471
  try {
384
472
  if (quiet != null || suppress_suggestions != null) {
385
473
  setSessionConfig({
@@ -390,6 +478,7 @@ export function registerLoadSnapshot(server) {
390
478
  });
391
479
  }
392
480
  const previousMeta = getSnapshotMetadata();
481
+ progress.phase(1, PHASES, `resolving ${file_path} (a manifold:// or bare filename is fetched here)`);
393
482
  let resolved;
394
483
  let fetchedFrom = null;
395
484
  try {
@@ -403,6 +492,43 @@ export function registerLoadSnapshot(server) {
403
492
  `Ensure the 'manifold' CLI is installed and you have access to the bucket, ` +
404
493
  `or pass a local absolute path instead.`));
405
494
  }
495
+ // In-process snapshot cache. Re-parsing a file this process already
496
+ // holds is the single most expensive avoidable thing the server does —
497
+ // a measured 18.8s for a 380 MB capture — and a long investigation
498
+ // re-loads the same file repeatedly. A hit switches to the resident
499
+ // graph instead. It must be indistinguishable from a real load apart
500
+ // from speed, so replace-mode (`keep_previous:false`) still unloads the
501
+ // others, exactly as a re-parse would have.
502
+ //
503
+ // A resident LIGHT snapshot does NOT satisfy a full request: it has no
504
+ // dominator tree, so serving it would silently answer a retention
505
+ // question with zeros. The reverse is fine — a full graph satisfies a
506
+ // light request.
507
+ if (!force_reload) {
508
+ const resident = findResidentByPath(resolved);
509
+ if (resident != null && (!resident.light || light)) {
510
+ if (keep_previous) {
511
+ setCurrentSnapshot(resident.handle);
512
+ }
513
+ else {
514
+ retainOnlySnapshot(resident.handle);
515
+ }
516
+ const headroomHitMB = getHeapHeadroomMB();
517
+ return textResult([
518
+ `cache: **hit** — ${resident.fileName} was already parsed in this server process; switched to it in ${Date.now() - startedAt} ms instead of re-parsing [handle: ${resident.handle}]`,
519
+ `${formatNumber(resident.nodeCount)} nodes, ${formatNumber(resident.edgeCount)} edges, ${formatBytes(resident.totalSize)} heap size` +
520
+ (resident.light
521
+ ? ' (LIGHT — no dominator tree / retained sizes)'
522
+ : ''),
523
+ headroomHitMB != null
524
+ ? `Memory: ~${formatNumber(headroomHitMB)} MB of the ~${formatNumber(getOldSpaceLimitMB())} MB old-space limit free (unchanged — nothing was parsed).`
525
+ : '',
526
+ '_Pass `force_reload: true` if the file changed on disk since it was loaded._',
527
+ ]
528
+ .filter(Boolean)
529
+ .join('\n'));
530
+ }
531
+ }
406
532
  if (!fs.existsSync(resolved)) {
407
533
  return errorResult(new Error(`File not found: ${resolved}` +
408
534
  (file_path.includes('/')
@@ -491,8 +617,8 @@ export function registerLoadSnapshot(server) {
491
617
  const countStr = counts
492
618
  ? `${formatNumber(counts.nodeCount)} nodes / ${formatNumber(counts.edgeCount)} edges, `
493
619
  : '';
494
- process.stderr.write(`Loading ${formatBytes(fileStat.size)} snapshot (${countStr}parsing → dominators → retained sizes). ` +
495
- `This runs uninterruptibly and can take minutes on a large/deep heap; if it appears to hang it is the dominator pass, not a crash.\n`);
620
+ process.stderr.write(`Loading ${formatBytes(fileStat.size)} snapshot (${countStr}parsing${light ? '' : ' → dominators → retained sizes'}). ` +
621
+ `This runs uninterruptibly and can take minutes on a large/deep heap; if it appears to hang it is the ${light ? 'parse' : 'dominator pass'}, not a crash.\n`);
496
622
  }
497
623
  // Free any resident snapshot BEFORE parsing the next so peak memory is
498
624
  // one heap, not two — the double-residency that tips a large load into
@@ -501,9 +627,61 @@ export function registerLoadSnapshot(server) {
501
627
  if (!keep_previous) {
502
628
  clearAllSnapshots();
503
629
  }
630
+ // Memory-headroom guard. The count ceiling above bounds the graph in the
631
+ // ABSTRACT; this bounds it against what this process actually has left,
632
+ // which is a different question once other snapshots are resident. When
633
+ // the projection does not fit, evict cold snapshots (never the current
634
+ // one) before refusing — an OOM here kills every resident snapshot and
635
+ // the whole session's state, so shedding the coldest is strictly better.
636
+ const evicted = [];
637
+ let headroomMB = getHeapHeadroomMB();
638
+ let estimateMB = null;
639
+ if (counts != null && headroomMB != null) {
640
+ estimateMB = estimateLoadHeapMB(counts, light);
641
+ while (estimateMB > (headroomMB ?? 0) * HEADROOM_SAFETY_FRACTION &&
642
+ !force) {
643
+ const victim = evictLeastRecentlyUsed();
644
+ if (victim == null)
645
+ break;
646
+ evicted.push(victim.handle);
647
+ headroomMB = getHeapHeadroomMB();
648
+ }
649
+ if (!force &&
650
+ estimateMB > (headroomMB ?? 0) * HEADROOM_SAFETY_FRACTION) {
651
+ const heapMB = getOldSpaceLimitMB();
652
+ return errorResult(new Error(`Not enough memory headroom to load this snapshot: it projects to ~${formatNumber(estimateMB)} MB ` +
653
+ `(${formatNumber(counts.nodeCount)} nodes × ${LOAD_BYTES_PER_NODE} B + ${formatNumber(counts.edgeCount)} edges × ${LOAD_BYTES_PER_EDGE} B), ` +
654
+ `over the ~${formatNumber(Math.round((headroomMB ?? 0) * HEADROOM_SAFETY_FRACTION))} MB budget for one load ` +
655
+ `(${Math.round(HEADROOM_SAFETY_FRACTION * 100)}% of the ~${formatNumber(headroomMB ?? 0)} MB free of this server's ~${formatNumber(heapMB)} MB old-space limit; the rest is reserved because the estimate is approximate and the parse allocates in bursts)` +
656
+ (evicted.length > 0
657
+ ? ` (after unloading ${evicted.length} cold snapshot(s): ${evicted.join(', ')})`
658
+ : '') +
659
+ `.\n\nAn out-of-memory crash here would take every resident snapshot and all session state with it, so the load is refused instead.\n\nOptions:\n` +
660
+ `1. Unload snapshots you no longer need: memlab_snapshots({action: "unload", handle: "…"}).\n` +
661
+ `2. Load light — skips the dominator/retained-size pass: memlab_load_snapshot({file_path: "${file_path}", light: true}).\n` +
662
+ `3. Use a smaller/earlier snapshot, or memlab_sequence_analysis for trend questions (it holds one graph at a time).\n` +
663
+ `4. Restart the server with more memory: NODE_OPTIONS="--max-old-space-size=8192".\n` +
664
+ `5. Override this projection with force:true (accepts the OOM risk).`));
665
+ }
666
+ }
667
+ progress.phase(2, PHASES, `guards passed${estimateMB != null ? ` (projected ~${formatNumber(estimateMB)} MB, ~${formatNumber(headroomMB ?? 0)} MB free)` : ''}${evicted.length > 0 ? `, evicted ${evicted.join(', ')}` : ''}`);
668
+ progress.phase(3, PHASES, `parsing ${formatBytes(fileStat.size)} — this phase is synchronous and emits no further updates until it completes`);
504
669
  let snapshot;
505
670
  try {
506
- snapshot = await getFullHeapFromFile(resolved);
671
+ if (light) {
672
+ // Parse only. `preparePathFinder` (shortest paths + dominators +
673
+ // retained sizes + Fiber marking) is the half being skipped, and it
674
+ // is the expensive half.
675
+ snapshot = await memlabUtils.getSnapshotFromFile(resolved, {
676
+ buildNodeIdIndex: true,
677
+ verbose: false,
678
+ });
679
+ progress.phase(4, PHASES, 'parsed; dominator/retained-size pass SKIPPED (light mode)');
680
+ }
681
+ else {
682
+ snapshot = await getFullHeapFromFile(resolved);
683
+ progress.phase(4, PHASES, 'parsed; dominator tree and retained sizes computed');
684
+ }
507
685
  }
508
686
  catch (loadErr) {
509
687
  const msg = loadErr instanceof Error ? loadErr.message : String(loadErr);
@@ -525,6 +703,7 @@ export function registerLoadSnapshot(server) {
525
703
  }
526
704
  throw loadErr;
527
705
  }
706
+ progress.phase(5, PHASES, 'indexing nodes and edges');
528
707
  let nodeCount = 0;
529
708
  let edgeCount = 0;
530
709
  let totalSize = 0;
@@ -543,6 +722,7 @@ export function registerLoadSnapshot(server) {
543
722
  edgeCount,
544
723
  totalSize,
545
724
  env,
725
+ light,
546
726
  }, { alias, replace: !keep_previous });
547
727
  const envLabel = env === 'browser'
548
728
  ? 'Browser'
@@ -556,7 +736,21 @@ export function registerLoadSnapshot(server) {
556
736
  if (fetchedFrom) {
557
737
  lines.push(`Fetched from Manifold: ${fetchedFrom}`);
558
738
  }
739
+ if (evicted.length > 0) {
740
+ lines.push(`⚠ Unloaded ${evicted.length} cold snapshot(s) to make room: ${evicted.join(', ')}`);
741
+ }
559
742
  lines.push(`Loaded ${fileName} (${formatBytes(fileStat.size)} on disk): ${formatNumber(nodeCount)} nodes, ${formatNumber(edgeCount)} edges, ${formatBytes(totalSize)} heap size (${envLabel} snapshot) [handle: ${meta.handle}]`);
743
+ if (light) {
744
+ lines.push('⚡ **LIGHT mode** — dominator tree, retained sizes and retainer paths were NOT computed. Counts, names, types, self sizes and string values are available; tools needing retention data will refuse rather than report zeros. Reload without `light` for those.');
745
+ }
746
+ // Report headroom on every load, not just when the guard fires: the
747
+ // number a caller needs in order to decide whether to keep_previous the
748
+ // NEXT one is what is left after this load, and nothing else surfaces it.
749
+ const headroomAfterMB = getHeapHeadroomMB();
750
+ if (headroomAfterMB != null) {
751
+ const limitMB = getOldSpaceLimitMB();
752
+ lines.push(`Memory: ~${formatNumber(headroomAfterMB)} MB of the ~${formatNumber(limitMB)} MB old-space limit still free after this load.`);
753
+ }
560
754
  const captured = extractCaptureTime(fileName);
561
755
  if (captured) {
562
756
  const iso = captured.toISOString().slice(0, 10);
@@ -574,10 +768,14 @@ export function registerLoadSnapshot(server) {
574
768
  .join(', ')}. Switch with memlab_snapshots.`);
575
769
  }
576
770
  }
577
- const warnings = quickDiagnosis(snapshot, totalSize);
771
+ progress.phase(6, PHASES, 'running quick diagnosis');
772
+ const warnings = quickDiagnosis(snapshot, totalSize, light);
578
773
  if (warnings.length > 0) {
579
774
  lines.push('', ...warnings);
580
775
  }
776
+ if (!suggestionsSuppressed()) {
777
+ lines.push('', ...nextStepMenu());
778
+ }
581
779
  return textResult(lines.join('\n'));
582
780
  }
583
781
  catch (err) {