@memlab/mcp-server 2.3.0 → 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 (205) hide show
  1. package/README.md +18 -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 +96 -2
  16. package/dist/heap-state.d.ts.map +1 -1
  17. package/dist/heap-state.js +197 -2
  18. package/dist/heap-state.js.map +1 -1
  19. package/dist/index.js +41 -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/auto-investigate.d.ts.map +1 -1
  46. package/dist/tools/auto-investigate.js +91 -49
  47. package/dist/tools/auto-investigate.js.map +1 -1
  48. package/dist/tools/batch.d.ts +12 -0
  49. package/dist/tools/batch.d.ts.map +1 -0
  50. package/dist/tools/batch.js +168 -0
  51. package/dist/tools/batch.js.map +1 -0
  52. package/dist/tools/cache-analysis.d.ts.map +1 -1
  53. package/dist/tools/cache-analysis.js +156 -5
  54. package/dist/tools/cache-analysis.js.map +1 -1
  55. package/dist/tools/class-histogram.d.ts.map +1 -1
  56. package/dist/tools/class-histogram.js +14 -3
  57. package/dist/tools/class-histogram.js.map +1 -1
  58. package/dist/tools/collection-trend.d.ts +55 -0
  59. package/dist/tools/collection-trend.d.ts.map +1 -0
  60. package/dist/tools/collection-trend.js +315 -0
  61. package/dist/tools/collection-trend.js.map +1 -0
  62. package/dist/tools/detached-dom.d.ts +2 -0
  63. package/dist/tools/detached-dom.d.ts.map +1 -1
  64. package/dist/tools/detached-dom.js +291 -20
  65. package/dist/tools/detached-dom.js.map +1 -1
  66. package/dist/tools/dev-artifacts.d.ts +62 -6
  67. package/dist/tools/dev-artifacts.d.ts.map +1 -1
  68. package/dist/tools/dev-artifacts.js +466 -23
  69. package/dist/tools/dev-artifacts.js.map +1 -1
  70. package/dist/tools/diff-snapshots.d.ts.map +1 -1
  71. package/dist/tools/diff-snapshots.js +101 -44
  72. package/dist/tools/diff-snapshots.js.map +1 -1
  73. package/dist/tools/dominator-attribution.d.ts +12 -0
  74. package/dist/tools/dominator-attribution.d.ts.map +1 -0
  75. package/dist/tools/dominator-attribution.js +189 -0
  76. package/dist/tools/dominator-attribution.js.map +1 -0
  77. package/dist/tools/dominator-chain.d.ts +12 -0
  78. package/dist/tools/dominator-chain.d.ts.map +1 -0
  79. package/dist/tools/dominator-chain.js +92 -0
  80. package/dist/tools/dominator-chain.js.map +1 -0
  81. package/dist/tools/duplicate-objects.d.ts +12 -0
  82. package/dist/tools/duplicate-objects.d.ts.map +1 -0
  83. package/dist/tools/duplicate-objects.js +172 -0
  84. package/dist/tools/duplicate-objects.js.map +1 -0
  85. package/dist/tools/duplicated-strings.d.ts.map +1 -1
  86. package/dist/tools/duplicated-strings.js +46 -38
  87. package/dist/tools/duplicated-strings.js.map +1 -1
  88. package/dist/tools/eval.d.ts +1 -0
  89. package/dist/tools/eval.d.ts.map +1 -1
  90. package/dist/tools/eval.js +787 -30
  91. package/dist/tools/eval.js.map +1 -1
  92. package/dist/tools/explain-delta.d.ts +12 -0
  93. package/dist/tools/explain-delta.d.ts.map +1 -0
  94. package/dist/tools/explain-delta.js +170 -0
  95. package/dist/tools/explain-delta.js.map +1 -0
  96. package/dist/tools/find-nodes-by-class.d.ts.map +1 -1
  97. package/dist/tools/find-nodes-by-class.js +112 -13
  98. package/dist/tools/find-nodes-by-class.js.map +1 -1
  99. package/dist/tools/finding-index.d.ts +22 -0
  100. package/dist/tools/finding-index.d.ts.map +1 -0
  101. package/dist/tools/finding-index.js +247 -0
  102. package/dist/tools/finding-index.js.map +1 -0
  103. package/dist/tools/for-each.d.ts.map +1 -1
  104. package/dist/tools/for-each.js +15 -3
  105. package/dist/tools/for-each.js.map +1 -1
  106. package/dist/tools/get-references.d.ts.map +1 -1
  107. package/dist/tools/get-references.js +35 -6
  108. package/dist/tools/get-references.js.map +1 -1
  109. package/dist/tools/get-referrers.d.ts.map +1 -1
  110. package/dist/tools/get-referrers.js +52 -6
  111. package/dist/tools/get-referrers.js.map +1 -1
  112. package/dist/tools/hunt-report.d.ts +12 -0
  113. package/dist/tools/hunt-report.d.ts.map +1 -0
  114. package/dist/tools/hunt-report.js +186 -0
  115. package/dist/tools/hunt-report.js.map +1 -0
  116. package/dist/tools/hypothesis.d.ts +12 -0
  117. package/dist/tools/hypothesis.d.ts.map +1 -0
  118. package/dist/tools/hypothesis.js +221 -0
  119. package/dist/tools/hypothesis.js.map +1 -0
  120. package/dist/tools/intern-opportunities.d.ts.map +1 -1
  121. package/dist/tools/intern-opportunities.js +721 -75
  122. package/dist/tools/intern-opportunities.js.map +1 -1
  123. package/dist/tools/ladder.d.ts +33 -0
  124. package/dist/tools/ladder.d.ts.map +1 -0
  125. package/dist/tools/ladder.js +266 -0
  126. package/dist/tools/ladder.js.map +1 -0
  127. package/dist/tools/largest-objects.d.ts.map +1 -1
  128. package/dist/tools/largest-objects.js +43 -16
  129. package/dist/tools/largest-objects.js.map +1 -1
  130. package/dist/tools/leak-report.d.ts +12 -0
  131. package/dist/tools/leak-report.d.ts.map +1 -0
  132. package/dist/tools/leak-report.js +256 -0
  133. package/dist/tools/leak-report.js.map +1 -0
  134. package/dist/tools/load-snapshot.d.ts +78 -0
  135. package/dist/tools/load-snapshot.d.ts.map +1 -1
  136. package/dist/tools/load-snapshot.js +519 -29
  137. package/dist/tools/load-snapshot.js.map +1 -1
  138. package/dist/tools/map-entries.d.ts +12 -0
  139. package/dist/tools/map-entries.d.ts.map +1 -0
  140. package/dist/tools/map-entries.js +139 -0
  141. package/dist/tools/map-entries.js.map +1 -0
  142. package/dist/tools/match-object.d.ts +12 -0
  143. package/dist/tools/match-object.d.ts.map +1 -0
  144. package/dist/tools/match-object.js +165 -0
  145. package/dist/tools/match-object.js.map +1 -0
  146. package/dist/tools/object-shape.d.ts.map +1 -1
  147. package/dist/tools/object-shape.js +92 -30
  148. package/dist/tools/object-shape.js.map +1 -1
  149. package/dist/tools/population-diff.d.ts +57 -0
  150. package/dist/tools/population-diff.d.ts.map +1 -0
  151. package/dist/tools/population-diff.js +338 -0
  152. package/dist/tools/population-diff.js.map +1 -0
  153. package/dist/tools/quick-diagnosis.d.ts.map +1 -1
  154. package/dist/tools/quick-diagnosis.js +12 -4
  155. package/dist/tools/quick-diagnosis.js.map +1 -1
  156. package/dist/tools/retainer-layers.d.ts +23 -0
  157. package/dist/tools/retainer-layers.d.ts.map +1 -0
  158. package/dist/tools/retainer-layers.js +179 -0
  159. package/dist/tools/retainer-layers.js.map +1 -0
  160. package/dist/tools/retainer-summary.d.ts +1 -0
  161. package/dist/tools/retainer-summary.d.ts.map +1 -1
  162. package/dist/tools/retainer-summary.js +128 -32
  163. package/dist/tools/retainer-summary.js.map +1 -1
  164. package/dist/tools/retainer-trace.d.ts.map +1 -1
  165. package/dist/tools/retainer-trace.js +105 -25
  166. package/dist/tools/retainer-trace.js.map +1 -1
  167. package/dist/tools/search-nodes.d.ts.map +1 -1
  168. package/dist/tools/search-nodes.js +5 -1
  169. package/dist/tools/search-nodes.js.map +1 -1
  170. package/dist/tools/search-strings.d.ts.map +1 -1
  171. package/dist/tools/search-strings.js +53 -7
  172. package/dist/tools/search-strings.js.map +1 -1
  173. package/dist/tools/sequence-analysis.d.ts +49 -0
  174. package/dist/tools/sequence-analysis.d.ts.map +1 -1
  175. package/dist/tools/sequence-analysis.js +268 -78
  176. package/dist/tools/sequence-analysis.js.map +1 -1
  177. package/dist/tools/shape-histogram.d.ts.map +1 -1
  178. package/dist/tools/shape-histogram.js +9 -2
  179. package/dist/tools/shape-histogram.js.map +1 -1
  180. package/dist/tools/sliced-strings.d.ts.map +1 -1
  181. package/dist/tools/sliced-strings.js +22 -4
  182. package/dist/tools/sliced-strings.js.map +1 -1
  183. package/dist/tools/snapshot-header.d.ts +28 -0
  184. package/dist/tools/snapshot-header.d.ts.map +1 -0
  185. package/dist/tools/snapshot-header.js +111 -0
  186. package/dist/tools/snapshot-header.js.map +1 -0
  187. package/dist/tools/string-patterns.d.ts.map +1 -1
  188. package/dist/tools/string-patterns.js +5 -4
  189. package/dist/tools/string-patterns.js.map +1 -1
  190. package/dist/tools/tools-index.d.ts +12 -0
  191. package/dist/tools/tools-index.d.ts.map +1 -0
  192. package/dist/tools/tools-index.js +342 -0
  193. package/dist/tools/tools-index.js.map +1 -0
  194. package/dist/tools/verify-fix.d.ts +23 -0
  195. package/dist/tools/verify-fix.d.ts.map +1 -0
  196. package/dist/tools/verify-fix.js +211 -0
  197. package/dist/tools/verify-fix.js.map +1 -0
  198. package/dist/tools/weakmap-entries.d.ts.map +1 -1
  199. package/dist/tools/weakmap-entries.js +6 -1
  200. package/dist/tools/weakmap-entries.js.map +1 -1
  201. package/dist/utils.d.ts +204 -1
  202. package/dist/utils.d.ts.map +1 -1
  203. package/dist/utils.js +478 -8
  204. package/dist/utils.js.map +1 -1
  205. package/package.json +5 -4
@@ -10,15 +10,204 @@
10
10
  import fs from 'fs';
11
11
  import os from 'os';
12
12
  import path from 'path';
13
+ import v8 from 'v8';
13
14
  import { execFileSync } from 'child_process';
14
15
  import { z } from 'zod';
16
+ import memlabCore from '@memlab/core';
15
17
  import memlabHeapAnalysis from '@memlab/heap-analysis';
16
18
  const { getFullHeapFromFile } = memlabHeapAnalysis;
17
- import { setSnapshot, getSnapshotMetadata, setSessionConfig, listSnapshots, } from '../heap-state.js';
18
- 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';
19
26
  // The bucket the Nest auto-capture + the Manifold links in diffs use, so a
20
27
  // bare snapshot filename resolves predictably (Feedback §7).
21
28
  export const DEFAULT_MANIFOLD_BUCKET = 'nest_server_nodejs_heap_snapshots';
29
+ // Size caps for the OOM guard. Server heap snapshots (Nest auto-capture, the
30
+ // Manifold bucket above) routinely run 550–2100 MB, so every one exceeds the
31
+ // conservative local-file default and a sweep previously had to pass
32
+ // max_file_size_mb on EVERY load call. When a snapshot is fetched from Manifold
33
+ // (a server capture, not an arbitrary local file) raise the effective cap to the
34
+ // analyzer's documented safe ceiling — larger than this reliably OOMs the parser
35
+ // — unless the caller set an explicit limit. Local files keep the tighter guard.
36
+ // (Sweep feedback round 6 §2.)
37
+ export const LOCAL_FILE_SIZE_LIMIT_MB = 900;
38
+ export const MANIFOLD_FETCH_SIZE_LIMIT_MB = 2100;
39
+ // A node/edge-count ceiling complements the file-size cap. The load computes a
40
+ // dominator tree via the Chrome-DevTools iterative fix-point algorithm, whose
41
+ // cost is ~O(passes × edges) and blows up with graph DEPTH and cycle density,
42
+ // not raw disk size — so a structurally deep snapshot only modestly larger on
43
+ // disk can take effectively forever. Worse, the whole load is one synchronous
44
+ // block the cooperative wall-clock guardrail cannot preempt, so there is no
45
+ // timeout to fall back on (P2403258184). We therefore refuse BEFORE starting the
46
+ // pass when the (cheaply peeked) node/edge counts exceed the ceiling, and warn
47
+ // above the soft threshold. File size is a poor proxy for dominator-pass cost,
48
+ // so these are separate from the MB caps. Override with max_nodes/max_edges, or
49
+ // bypass entirely with force:true (expect a long, uninterruptible load).
50
+ //
51
+ // These fixed values are FLOORS: the effective ceiling is auto-scaled UP from the
52
+ // process's configured old-space limit (see computeDefaultCeilings) and only
53
+ // falls back to these when the limit can't be read. A memory-starved server is
54
+ // therefore never less safe than before, while an 8 GB server admits the tier
55
+ // that previously required a hand-passed override on every call.
56
+ export const DEFAULT_MAX_NODES = 12_000_000;
57
+ export const DEFAULT_MAX_EDGES = 24_000_000;
58
+ export const WARN_NODE_COUNT = 2_000_000;
59
+ // Auto-scaling rates (sweep feedback: "the load ceiling is mismatched to Nest
60
+ // server snapshots"). Nest server captures are edge-dense (~4 edges/node), so the
61
+ // EDGE ceiling — not the node ceiling — is what rejected essentially the whole
62
+ // meaningful part of the 550–2100 MB band. Empirically, on a server started with
63
+ // --max-old-space-size=8192 the ~11–14M-node / ~45–49M-edge / ~1.1 GB tier loads
64
+ // cleanly in seconds-to-tens-of-seconds with no OOM, while the ~2 GB / 24M-node /
65
+ // 89M-edge tier is genuinely risky. Rather than hard-code one ceiling tuned to a
66
+ // single heap size, scale it from the process's actual old-space limit so an 8 GB
67
+ // server admits the 1.1 GB tier by default and a smaller server stays
68
+ // conservative. Rates chosen so ~8192 MB → ~13.9M nodes / ~55.7M edges (admits the
69
+ // 45–49M-edge band, still rejects the 89M-edge / 2 GB band).
70
+ export const NODES_PER_HEAP_MB = 1700;
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
+ }
114
+ /**
115
+ * Configured V8 old-space limit in MB (reflects --max-old-space-size). Returns 0
116
+ * when it can't be read, so callers fall back to the fixed floors.
117
+ */
118
+ export function getOldSpaceLimitMB() {
119
+ try {
120
+ return Math.round(v8.getHeapStatistics().heap_size_limit / (1024 * 1024));
121
+ }
122
+ catch {
123
+ return 0;
124
+ }
125
+ }
126
+ /**
127
+ * Default node/edge load ceilings, auto-scaled from the configured old-space
128
+ * limit and floored at the historical fixed defaults so the ceiling never
129
+ * regresses below what was previously safe. `heapLimitMB` is 0 when the limit
130
+ * couldn't be read (ceilings then equal the fixed floors).
131
+ */
132
+ export function computeDefaultCeilings() {
133
+ const heapLimitMB = getOldSpaceLimitMB();
134
+ if (heapLimitMB <= 0) {
135
+ return {
136
+ maxNodes: DEFAULT_MAX_NODES,
137
+ maxEdges: DEFAULT_MAX_EDGES,
138
+ heapLimitMB: 0,
139
+ };
140
+ }
141
+ return {
142
+ maxNodes: Math.max(DEFAULT_MAX_NODES, Math.round(heapLimitMB * NODES_PER_HEAP_MB)),
143
+ maxEdges: Math.max(DEFAULT_MAX_EDGES, Math.round(heapLimitMB * EDGES_PER_HEAP_MB)),
144
+ heapLimitMB,
145
+ };
146
+ }
147
+ /**
148
+ * Cheaply read `node_count` / `edge_count` from a .heapsnapshot header without
149
+ * parsing the whole file. The V8 format begins with
150
+ * `{"snapshot":{"meta":{…},"node_count":N,"edge_count":M,…},…}`, so both counts
151
+ * sit within the first few KB. Reads a bounded prefix and regex-extracts them;
152
+ * returns null when they aren't found in the prefix (caller then skips the count
153
+ * guard rather than failing the load).
154
+ */
155
+ export function peekSnapshotCounts(localPath) {
156
+ let fd = null;
157
+ try {
158
+ fd = fs.openSync(localPath, 'r');
159
+ const buf = Buffer.allocUnsafe(256 * 1024);
160
+ const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
161
+ const head = buf.toString('utf8', 0, bytes);
162
+ const n = head.match(/"node_count"\s*:\s*(\d+)/);
163
+ const e = head.match(/"edge_count"\s*:\s*(\d+)/);
164
+ if (!n || !e)
165
+ return null;
166
+ return { nodeCount: Number(n[1]), edgeCount: Number(e[1]) };
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ finally {
172
+ if (fd != null)
173
+ fs.closeSync(fd);
174
+ }
175
+ }
176
+ /**
177
+ * Estimate the largest same-app capture (in MB) that would fit under the load
178
+ * ceiling, from THIS snapshot's own node/edge density. Server captures of one app
179
+ * have a roughly stable nodes-per-MB and edges-per-MB, so once a capture is
180
+ * refused for exceeding the ceiling, `ceiling ÷ density` tells the reader what
181
+ * file size to look for instead — turning the manual "hit ceiling → compute
182
+ * density by hand → re-query Scuba for a smaller file" loop into one number
183
+ * (sweep feedback round 7 §2: the guess-and-retry loop was the dominant friction).
184
+ * Edge-density usually binds first on edge-dense Nest heaps, so we take the min of
185
+ * the node-bound and edge-bound estimates. Returns null when it can't be computed.
186
+ */
187
+ export function estimateMaxLoadableMB(counts, fileSizeMB, ceilings) {
188
+ if (fileSizeMB <= 0)
189
+ return null;
190
+ const nodesPerMB = counts.nodeCount / fileSizeMB;
191
+ const edgesPerMB = counts.edgeCount / fileSizeMB;
192
+ const byNodes = nodesPerMB > 0 ? ceilings.maxNodes / nodesPerMB : Infinity;
193
+ const byEdges = edgesPerMB > 0 ? ceilings.maxEdges / edgesPerMB : Infinity;
194
+ const mb = Math.min(byNodes, byEdges);
195
+ if (!Number.isFinite(mb) || mb <= 0)
196
+ return null;
197
+ return Math.floor(mb);
198
+ }
199
+ /**
200
+ * Resolve the effective max-file-size limit (MB). An explicit caller value always
201
+ * wins; otherwise default by source — Manifold-fetched server snapshots get the
202
+ * higher ceiling, local files keep the tighter guard.
203
+ */
204
+ export function resolveMaxFileSizeMB(explicit, fetchedFromManifold) {
205
+ if (explicit != null)
206
+ return explicit;
207
+ return fetchedFromManifold
208
+ ? MANIFOLD_FETCH_SIZE_LIMIT_MB
209
+ : LOCAL_FILE_SIZE_LIMIT_MB;
210
+ }
22
211
  /**
23
212
  * Resolve a `file_path` that may be a local path, a `manifold://bucket/key`
24
213
  * URL, or a bare snapshot filename to a local path, fetching from Manifold
@@ -47,12 +236,39 @@ export function resolveSnapshotPath(filePath) {
47
236
  manifoldKey = `${DEFAULT_MANIFOLD_BUCKET}/${manifoldKey}`;
48
237
  }
49
238
  const dest = path.join(os.tmpdir(), `memlab-${path.basename(manifoldKey).replace(/[^A-Za-z0-9._-]/g, '_')}`);
239
+ // Reuse an already-downloaded copy. The temp name is derived from the unique
240
+ // Manifold key, so a non-empty file here IS this object. This makes a RETRY
241
+ // after a size-limit rejection succeed (the prior attempt fully downloaded
242
+ // before the size check ran) instead of failing with "Local object exists",
243
+ // and avoids re-downloading multi-GB snapshots (feedback §A.1).
244
+ if (fs.existsSync(dest) && fs.statSync(dest).size > 0) {
245
+ process.stderr.write(`Reusing already-downloaded ${dest}\n`);
246
+ return { localPath: dest, fetchedFrom: manifoldKey };
247
+ }
50
248
  process.stderr.write(`Fetching ${manifoldKey} from Manifold → ${dest}…\n`);
51
- execFileSync('manifold', ['get', manifoldKey, dest], {
52
- stdio: ['ignore', 'ignore', 'pipe'],
53
- timeout: 5 * 60 * 1000,
54
- maxBuffer: 64 * 1024 * 1024,
55
- });
249
+ try {
250
+ // --overwrite_local_path so a stale/empty/partial leftover never blocks the
251
+ // fetch (the cause of the "Local object exists" retry failure).
252
+ execFileSync('manifold', ['get', '--overwrite_local_path', manifoldKey, dest], {
253
+ stdio: ['ignore', 'ignore', 'pipe'],
254
+ timeout: 5 * 60 * 1000,
255
+ maxBuffer: 64 * 1024 * 1024,
256
+ });
257
+ }
258
+ catch (err) {
259
+ const msg = err instanceof Error ? err.message : String(err);
260
+ // Friendlier message for a missing object (feedback §E.18) — the raw
261
+ // StorageException is opaque about WHY a path isn't found.
262
+ if (/not found|does not map|StorageException/i.test(msg)) {
263
+ throw new Error(`Snapshot not found in Manifold at "${manifoldKey}". ` +
264
+ `It may never have been uploaded (OnDemand-host captures often aren't), ` +
265
+ `or the name/path is wrong. Available mounts: flat/, nodes/, tree/. ` +
266
+ `List an app's snapshots with: ` +
267
+ `manifold ls ${DEFAULT_MANIFOLD_BUCKET}/flat | grep <app-name>\n\n` +
268
+ `Underlying error: ${msg}`);
269
+ }
270
+ throw err;
271
+ }
56
272
  return { localPath: dest, fetchedFrom: manifoldKey };
57
273
  }
58
274
  function findLargestObject(snapshot) {
@@ -75,6 +291,26 @@ function findLargestObject(snapshot) {
75
291
  });
76
292
  return best;
77
293
  }
294
+ // Nest auto-capture filenames embed the capture time as an epoch (ms or s).
295
+ // Surfacing it lets the agent check whether a candidate leak was likely already
296
+ // fixed since the snapshot was taken — the dominant outcome when sweeping a
297
+ // backlog of old snapshots (Feedback round 5 §12).
298
+ export function extractCaptureTime(fileName) {
299
+ const msMatch = fileName.match(/(?<!\d)(1\d{12})(?!\d)/);
300
+ const sMatch = fileName.match(/(?<!\d)(1\d{9})(?!\d)/);
301
+ let ms = null;
302
+ if (msMatch)
303
+ ms = Number(msMatch[1]);
304
+ else if (sMatch)
305
+ ms = Number(sMatch[1]) * 1000;
306
+ if (ms == null)
307
+ return null;
308
+ const d = new Date(ms);
309
+ const year = d.getUTCFullYear();
310
+ if (year < 2020 || year > 2035)
311
+ return null;
312
+ return d;
313
+ }
78
314
  function detectEnv(snapshot) {
79
315
  let hasWindow = false;
80
316
  let hasModule = false;
@@ -97,7 +333,7 @@ function detectEnv(snapshot) {
97
333
  return 'node';
98
334
  return 'unknown';
99
335
  }
100
- function quickDiagnosis(snapshot, totalSelfSize) {
336
+ function quickDiagnosis(snapshot, totalSelfSize, light = false) {
101
337
  const warnings = [];
102
338
  const largeStrings = [];
103
339
  const stringCounts = new Map();
@@ -134,7 +370,10 @@ function quickDiagnosis(snapshot, totalSelfSize) {
134
370
  const display = val.length > 40 ? val.slice(0, 40) + '…' : val;
135
371
  warnings.push(`⚠ "${display}" duplicated ${formatNumber(count)} times`);
136
372
  }
137
- 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);
138
377
  if (largest && totalSelfSize > 0) {
139
378
  const pct = ((largest.size / totalSelfSize) * 100).toFixed(0);
140
379
  if (largest.size >= totalSelfSize * 0.3) {
@@ -148,13 +387,34 @@ function quickDiagnosis(snapshot, totalSelfSize) {
148
387
  for (const [name, count] of anomalous) {
149
388
  warnings.push(`⚠ ${formatNumber(count)}× \`${name}\` instances`);
150
389
  }
151
- if (warnings.length > 0) {
152
- warnings.push('', 'Run `memlab_quick_diagnosis` for actionable triage or `memlab_auto_investigate` for deep analysis.');
153
- }
154
390
  return warnings;
155
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
+ }
156
416
  export function registerLoadSnapshot(server) {
157
- 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.', {
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.', {
158
418
  file_path: z
159
419
  .string()
160
420
  .describe('A local absolute path to a .heapsnapshot file, a manifold:// URL (manifold://bucket/key), or a bare snapshot filename to fetch from the nest_server_nodejs_heap_snapshots bucket.'),
@@ -178,9 +438,36 @@ export function registerLoadSnapshot(server) {
178
438
  max_file_size_mb: z
179
439
  .number()
180
440
  .optional()
181
- .default(900)
182
- .describe('Maximum file size in MB to attempt loading (default 900). Snapshots larger than this will return an error instead of risking an OOM crash. Increase if your Node.js process has extra memory (--max-old-space-size).'),
183
- }, async ({ file_path, alias, keep_previous, quiet, suppress_suggestions, max_file_size_mb, }) => {
441
+ .describe(`Maximum file size in MB to attempt loading. Defaults to ${LOCAL_FILE_SIZE_LIMIT_MB} for local files; for snapshots fetched from Manifold (a bare Nest snapshot filename or a manifold:// URL) it defaults to ${MANIFOLD_FETCH_SIZE_LIMIT_MB} — the analyzer's safe ceiling — since server captures routinely exceed ${LOCAL_FILE_SIZE_LIMIT_MB} MB. Pass an explicit value to override either default (e.g. raise it if your Node.js process has extra memory via --max-old-space-size). Snapshots larger than the effective limit return an error instead of risking an OOM crash.`),
442
+ max_nodes: z
443
+ .number()
444
+ .optional()
445
+ .describe(`Maximum node count to attempt loading. Defaults to an auto-scaled ceiling derived from the server's configured --max-old-space-size (≈${formatNumber(NODES_PER_HEAP_MB)}× the old-space MB, floored at ${formatNumber(DEFAULT_MAX_NODES)}); an 8 GB server defaults to ~${formatNumber(8192 * NODES_PER_HEAP_MB)}. The dominator-tree computation done at load is super-linear in graph size AND depth and runs uninterruptibly, so a very large/deep snapshot can wedge the server with no timeout; the count is peeked cheaply from the header and the load is refused before it starts if it exceeds this. File size is a poor proxy for this cost, so this is separate from max_file_size_mb.`),
446
+ max_edges: z
447
+ .number()
448
+ .optional()
449
+ .describe(`Maximum edge count to attempt loading; see max_nodes. Defaults to an auto-scaled ceiling (≈${formatNumber(EDGES_PER_HEAP_MB)}× the old-space MB, floored at ${formatNumber(DEFAULT_MAX_EDGES)}); an 8 GB server defaults to ~${formatNumber(8192 * EDGES_PER_HEAP_MB)}. Nest server snapshots are edge-dense (~4 edges/node), so this — not max_nodes — is usually the binding limit. Peeked from the header; the load is refused before it starts if it exceeds this.`),
450
+ force: z
451
+ .boolean()
452
+ .optional()
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();
184
471
  try {
185
472
  if (quiet != null || suppress_suggestions != null) {
186
473
  setSessionConfig({
@@ -191,6 +478,7 @@ export function registerLoadSnapshot(server) {
191
478
  });
192
479
  }
193
480
  const previousMeta = getSnapshotMetadata();
481
+ progress.phase(1, PHASES, `resolving ${file_path} (a manifold:// or bare filename is fetched here)`);
194
482
  let resolved;
195
483
  let fetchedFrom = null;
196
484
  try {
@@ -204,6 +492,43 @@ export function registerLoadSnapshot(server) {
204
492
  `Ensure the 'manifold' CLI is installed and you have access to the bucket, ` +
205
493
  `or pass a local absolute path instead.`));
206
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
+ }
207
532
  if (!fs.existsSync(resolved)) {
208
533
  return errorResult(new Error(`File not found: ${resolved}` +
209
534
  (file_path.includes('/')
@@ -212,21 +537,151 @@ export function registerLoadSnapshot(server) {
212
537
  }
213
538
  const fileStat = fs.statSync(resolved);
214
539
  const fileSizeMB = fileStat.size / (1024 * 1024);
215
- if (fileSizeMB > max_file_size_mb) {
216
- return errorResult(new Error(`Snapshot file is ${formatBytes(fileStat.size)} exceeds the ${max_file_size_mb} MB safety limit. ` +
540
+ // Manifold-fetched server snapshots get the higher ceiling by default so
541
+ // the common 550–2100 MB Nest captures load without an explicit override
542
+ // (sweep feedback round 6 §2); an explicit caller value still wins.
543
+ const effectiveMaxFileSizeMB = resolveMaxFileSizeMB(max_file_size_mb, fetchedFrom != null);
544
+ if (fileSizeMB > effectiveMaxFileSizeMB) {
545
+ const heapMB = getOldSpaceLimitMB();
546
+ const options = [
547
+ `1. Use a smaller snapshot from the same app if available`,
548
+ `2. Increase the limit: memlab_load_snapshot({max_file_size_mb: ${Math.ceil(fileSizeMB + 100)}})`,
549
+ ];
550
+ // Only recommend more memory when the server isn't already at 8 GB —
551
+ // the shipped default (feedback: the "restart with more memory" advice
552
+ // was a red herring when the server was already at 8 GB). An unknown
553
+ // limit (heapMB === 0) also satisfies this and keeps the advice, since
554
+ // we can't confirm the server is already provisioned.
555
+ if (heapMB < 8192) {
556
+ options.push(`3. Restart the MCP server with more memory: NODE_OPTIONS="--max-old-space-size=8192"`);
557
+ }
558
+ return errorResult(new Error(`Snapshot file is ${formatBytes(fileStat.size)} — exceeds the ${effectiveMaxFileSizeMB} MB safety limit. ` +
217
559
  `Loading snapshots this large often causes the MCP server to crash with an out-of-memory error, ` +
218
- `losing all analysis state.\n\n` +
560
+ `losing all analysis state.` +
561
+ (heapMB > 0
562
+ ? ` (This server's Node.js heap is ~${formatNumber(heapMB)} MB.)`
563
+ : '') +
564
+ `\n\nOptions:\n` +
565
+ options.join('\n')));
566
+ }
567
+ // Node/edge-count ceiling (P2403258184). The load's dominator pass is
568
+ // super-linear in graph size AND depth and runs as one uninterruptible
569
+ // synchronous block the wall-clock guardrail cannot preempt, so a very
570
+ // large/deep snapshot can wedge the server with no timeout. Peek the
571
+ // counts cheaply from the header and refuse BEFORE starting the pass.
572
+ const counts = peekSnapshotCounts(resolved);
573
+ const autoCeilings = computeDefaultCeilings();
574
+ const effectiveMaxNodes = max_nodes ?? autoCeilings.maxNodes;
575
+ const effectiveMaxEdges = max_edges ?? autoCeilings.maxEdges;
576
+ if (counts &&
577
+ !force &&
578
+ (counts.nodeCount > effectiveMaxNodes ||
579
+ counts.edgeCount > effectiveMaxEdges)) {
580
+ const heapMB = autoCeilings.heapLimitMB;
581
+ // Tailor the advice to the server's ACTUAL old-space size. The generic
582
+ // "restart with more memory" line is a red herring when the server is
583
+ // already at 8 GB (the shipped default) — in that case force:true is all
584
+ // that's needed, and it self-sizes from these header counts.
585
+ const heapNote = heapMB > 0
586
+ ? `This server's Node.js heap is ~${formatNumber(heapMB)} MB (--max-old-space-size), so the auto-scaled ceiling is ${formatNumber(effectiveMaxNodes)} nodes / ${formatNumber(effectiveMaxEdges)} edges. `
587
+ : '';
588
+ // Density-derived hint: from this capture's own nodes/edges per MB,
589
+ // estimate the largest same-app capture that WOULD fit, so the reader
590
+ // can pick a smaller snapshot in one query instead of hand-computing
591
+ // density and guessing (sweep feedback round 7 §2).
592
+ const estMB = estimateMaxLoadableMB(counts, fileSizeMB, {
593
+ maxNodes: effectiveMaxNodes,
594
+ maxEdges: effectiveMaxEdges,
595
+ });
596
+ const sizeHint = estMB != null
597
+ ? `This capture is ${formatBytes(fileStat.size)} at ${formatNumber(Math.round(counts.nodeCount / fileSizeMB))} nodes/MB and ${formatNumber(Math.round(counts.edgeCount / fileSizeMB))} edges/MB, so for this app the ceiling corresponds to roughly a ≤${formatNumber(estMB)} MB capture — pick a smaller snapshot below that size.`
598
+ : '';
599
+ const options = [
600
+ `1. Get trend/growth WITHOUT a full load: memlab_sequence_analysis loads snapshots transiently (one at a time) and is the right tool for a ladder.`,
601
+ `2. Use a smaller/earlier snapshot from the same ladder.${estMB != null ? ` ${sizeHint}` : ''} Check a candidate's counts first WITHOUT a load via memlab_snapshot_header.`,
602
+ `3. Load it anyway with force:true — it self-sizes from these header counts, so you do NOT also need max_nodes/max_edges: memlab_load_snapshot({file_path: "${file_path}", force: true}). Expect a long, uninterruptible dominator pass.`,
603
+ ];
604
+ // Only suggest more memory when the server is actually under-provisioned.
605
+ if (heapMB > 0 && heapMB < 8192) {
606
+ options.push(`4. This server has only ~${formatNumber(heapMB)} MB — restart it with more memory to raise the ceiling before forcing: NODE_OPTIONS="--max-old-space-size=8192".`);
607
+ }
608
+ return errorResult(new Error(`Snapshot has ${formatNumber(counts.nodeCount)} nodes / ${formatNumber(counts.edgeCount)} edges — exceeds the load ceiling ` +
609
+ `(${formatNumber(effectiveMaxNodes)} nodes / ${formatNumber(effectiveMaxEdges)} edges). ${heapNote}\n\n` +
610
+ `The load computes a dominator tree whose cost grows super-linearly with graph size and depth, and it runs as one synchronous block that the wall-clock guardrail cannot interrupt — so a snapshot this large/deep can wedge the server with no timeout. Refusing before the pass starts.\n\n` +
219
611
  `Options:\n` +
220
- `1. Use a smaller snapshot from the same app if available\n` +
221
- `2. Increase the limit: memlab_load_snapshot({max_file_size_mb: ${Math.ceil(fileSizeMB + 100)}})\n` +
222
- `3. Restart the MCP server with more memory: NODE_OPTIONS="--max-old-space-size=8192"`));
612
+ options.join('\n')));
613
+ }
614
+ const bigBySize = fileSizeMB > 200;
615
+ const bigByCount = counts != null && counts.nodeCount > WARN_NODE_COUNT;
616
+ if (bigBySize || bigByCount) {
617
+ const countStr = counts
618
+ ? `${formatNumber(counts.nodeCount)} nodes / ${formatNumber(counts.edgeCount)} edges, `
619
+ : '';
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`);
223
622
  }
224
- if (fileSizeMB > 200) {
225
- process.stderr.write(`Loading ${formatBytes(fileStat.size)} snapshot this may take a while (parsing, computing dominators, building indexes)...\n`);
623
+ // Free any resident snapshot BEFORE parsing the next so peak memory is
624
+ // one heap, not two the double-residency that tips a large load into
625
+ // GC thrash (P2403258184 §3). Only in replace mode; keep_previous
626
+ // callers explicitly opt into holding several resident.
627
+ if (!keep_previous) {
628
+ clearAllSnapshots();
226
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`);
227
669
  let snapshot;
228
670
  try {
229
- 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
+ }
230
685
  }
231
686
  catch (loadErr) {
232
687
  const msg = loadErr instanceof Error ? loadErr.message : String(loadErr);
@@ -234,14 +689,21 @@ export function registerLoadSnapshot(server) {
234
689
  msg.includes('allocation failed') ||
235
690
  msg.includes('JavaScript heap') ||
236
691
  msg.includes('ENOMEM')) {
692
+ const heapMB = getOldSpaceLimitMB();
693
+ const already8g = heapMB >= 8192;
237
694
  return errorResult(new Error(`Out of memory while loading ${formatBytes(fileStat.size)} snapshot. ` +
238
- `The snapshot requires more memory than is available to the MCP server process.\n\n` +
695
+ `The snapshot requires more memory than is available to the MCP server process` +
696
+ (heapMB > 0 ? ` (~${formatNumber(heapMB)} MB heap)` : '') +
697
+ `.\n\n` +
239
698
  `Try:\n` +
240
- `1. A smaller snapshot from the same app\n` +
241
- `2. Restart with more memory: NODE_OPTIONS="--max-old-space-size=8192"`));
699
+ `1. A smaller/earlier snapshot from the same app or ladder\n` +
700
+ (already8g
701
+ ? `2. This server is already at ~${formatNumber(heapMB)} MB — this snapshot is simply too large to load in full; use memlab_sequence_analysis for trend/growth without a full load.`
702
+ : `2. Restart with more memory: NODE_OPTIONS="--max-old-space-size=8192"`)));
242
703
  }
243
704
  throw loadErr;
244
705
  }
706
+ progress.phase(5, PHASES, 'indexing nodes and edges');
245
707
  let nodeCount = 0;
246
708
  let edgeCount = 0;
247
709
  let totalSize = 0;
@@ -260,6 +722,7 @@ export function registerLoadSnapshot(server) {
260
722
  edgeCount,
261
723
  totalSize,
262
724
  env,
725
+ light,
263
726
  }, { alias, replace: !keep_previous });
264
727
  const envLabel = env === 'browser'
265
728
  ? 'Browser'
@@ -273,7 +736,30 @@ export function registerLoadSnapshot(server) {
273
736
  if (fetchedFrom) {
274
737
  lines.push(`Fetched from Manifold: ${fetchedFrom}`);
275
738
  }
739
+ if (evicted.length > 0) {
740
+ lines.push(`⚠ Unloaded ${evicted.length} cold snapshot(s) to make room: ${evicted.join(', ')}`);
741
+ }
276
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
+ }
754
+ const captured = extractCaptureTime(fileName);
755
+ if (captured) {
756
+ const iso = captured.toISOString().slice(0, 10);
757
+ // Clamp to 0: a filename whose embedded epoch is slightly ahead of
758
+ // the host clock (clock skew, or a future-dated name within the
759
+ // allowed window) must not surface as "(-N day(s) ago)".
760
+ const ageDays = Math.max(0, Math.floor((Date.now() - captured.getTime()) / 86_400_000));
761
+ lines.push(`Captured ${iso} (${ageDays} day(s) ago) — if you find a candidate, verify the fix wasn't already deployed since then before writing one.`);
762
+ }
277
763
  if (keep_previous) {
278
764
  const all = listSnapshots();
279
765
  if (all.length > 1) {
@@ -282,10 +768,14 @@ export function registerLoadSnapshot(server) {
282
768
  .join(', ')}. Switch with memlab_snapshots.`);
283
769
  }
284
770
  }
285
- const warnings = quickDiagnosis(snapshot, totalSize);
771
+ progress.phase(6, PHASES, 'running quick diagnosis');
772
+ const warnings = quickDiagnosis(snapshot, totalSize, light);
286
773
  if (warnings.length > 0) {
287
774
  lines.push('', ...warnings);
288
775
  }
776
+ if (!suggestionsSuppressed()) {
777
+ lines.push('', ...nextStepMenu());
778
+ }
289
779
  return textResult(lines.join('\n'));
290
780
  }
291
781
  catch (err) {