@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,24 +10,69 @@
10
10
  import { z } from 'zod';
11
11
  import vm from 'node:vm';
12
12
  import memlabCore from '@memlab/core';
13
- const { utils } = memlabCore;
14
- import { getSnapshot } from '../heap-state.js';
15
- import { errorResult, toolResult, serializeNodeSummary, serializeNodeDetail, formatBytes, formatNumber, markdownTable, isNodeWorthInspecting, filterLargestObjects, queryNodes, } from '../utils.js';
13
+ const { utils, NumericSet } = memlabCore;
14
+ import { getCurrentHandle, getSavedResult, getSnapshot, isLightSnapshot, listSavedResults, setSavedResult, getEvalScratch, getSnapshotMetadata, } from '../heap-state.js';
15
+ import { errorResult, toolResult, serializeNodeSummary, serializeNodeDetail, formatBytes, formatNumber, markdownTable, isNodeWorthInspecting, makeNamePatternTest, filterLargestObjects, queryNodes, enumerateMapEntries, enumerateSetElements, objectContentSignature, boundedDominatorRetainedSize, } from '../utils.js';
16
16
  const MAX_OUTPUT_SIZE = 50 * 1024; // 50KB
17
+ // Prefix for user-named result sets inside the per-snapshot eval scratch, so
18
+ // they cannot collide with the internal `__classTypeIndex` / `__withProp:` keys.
17
19
  function truncate(str, max) {
18
20
  if (str.length <= max)
19
21
  return str;
20
22
  return str.slice(0, max) + '\n... [truncated, output exceeded 50KB]';
21
23
  }
24
+ class BudgetExceeded extends Error {
25
+ constructor(max) {
26
+ super(`max_nodes budget of ${max} exhausted`);
27
+ this.name = 'BudgetExceeded';
28
+ }
29
+ }
22
30
  const NODE_PROPERTY_ALIASES = {
23
- retained_size: 'retainedSize',
24
31
  referrer_count: 'numOfReferrers',
25
32
  };
33
+ /**
34
+ * Lets helpers recover the real node behind a sandbox proxy. Sandbox code holds
35
+ * proxies; the helpers it passes them to need the underlying object, both to
36
+ * avoid proxy overhead per property read and because the proxy deliberately
37
+ * refuses `retainedSize` (below) while the helpers legitimately read it.
38
+ */
39
+ const RAW_NODE = Symbol('memlabRawNode');
40
+ export function unwrapNode(node) {
41
+ if (node != null && typeof node === 'object') {
42
+ const raw = node[RAW_NODE];
43
+ if (raw != null)
44
+ return raw;
45
+ }
46
+ return node;
47
+ }
48
+ function unwrapNodes(nodes) {
49
+ return Array.isArray(nodes) ? nodes.map(unwrapNode) : nodes;
50
+ }
51
+ /**
52
+ * `node.retainedSize` read off a node inside eval has been observed to come
53
+ * back ~0 for every node on some loads, while the same id read through
54
+ * `snapshot.getNodeById(id).retainedSize` returns the true value. A field that
55
+ * silently yields a wrong NUMBER is worse than one that fails: every ranking
56
+ * built on it looks plausible and is wrong, and nothing in the output says so.
57
+ *
58
+ * So the sandbox refuses the read and names the working call. The docs already
59
+ * carried this as a caveat; a caveat is only as good as the reader's memory of
60
+ * it, and this class of silent-zero bug has produced published errors before.
61
+ */
62
+ const RETAINED_SIZE_PROPS = new Set(['retainedSize', 'retained_size']);
26
63
  function wrapNode(node) {
27
64
  if (node == null)
28
65
  return node;
29
66
  return new Proxy(node, {
30
67
  get(target, prop, receiver) {
68
+ if (prop === RAW_NODE)
69
+ return target;
70
+ if (typeof prop === 'string' && RETAINED_SIZE_PROPS.has(prop)) {
71
+ const id = target.id;
72
+ throw new Error(`node.${prop} is not readable inside eval — it can come back ~0 for every node on some loads, which silently corrupts any ranking built on it. ` +
73
+ `Use helpers.retainedSize(${typeof id === 'number' ? id : 'id'}) for one node, helpers.retainedSizes([ids]) for many, or helpers.aggregateRetained([ids]) for a dominator-deduped total. ` +
74
+ 'Self size (`node.self_size`) is read directly from the snapshot and IS reliable here.');
75
+ }
31
76
  if (typeof prop === 'string' && prop in NODE_PROPERTY_ALIASES) {
32
77
  return target[NODE_PROPERTY_ALIASES[prop]];
33
78
  }
@@ -78,7 +123,7 @@ function wrapEdgeIterable(iterable) {
78
123
  },
79
124
  };
80
125
  }
81
- function wrapSnapshot(snapshot) {
126
+ function wrapSnapshot(snapshot, budget) {
82
127
  return new Proxy(snapshot, {
83
128
  get(target, prop, receiver) {
84
129
  if (prop === 'getNodeById') {
@@ -92,7 +137,15 @@ function wrapSnapshot(snapshot) {
92
137
  if (nodesProp === 'forEach') {
93
138
  const origForEach = nodesTarget.forEach.bind(nodesTarget);
94
139
  return (cb) => {
95
- origForEach((node) => cb(wrapNode(node)));
140
+ origForEach((node) => {
141
+ if (++budget.visited > budget.max) {
142
+ budget.exceeded = true;
143
+ throw new BudgetExceeded(budget.max);
144
+ }
145
+ // Returning `false` from the callback breaks the walk, so the
146
+ // callback's return value has to be passed through.
147
+ return cb(wrapNode(node));
148
+ });
96
149
  };
97
150
  }
98
151
  return Reflect.get(nodesTarget, nodesProp, nodesReceiver);
@@ -103,6 +156,23 @@ function wrapSnapshot(snapshot) {
103
156
  },
104
157
  });
105
158
  }
159
+ /**
160
+ * Identifiers whose value comes from the dominator / retained-size / shortest-
161
+ * path pass that a LIGHT load skips. On such a snapshot each of these reads
162
+ * back 0 or undefined WITHOUT failing, so eval code using them returns
163
+ * confident zeros — worse than an error. Matched textually before the code
164
+ * runs, so the refusal costs nothing.
165
+ */
166
+ const RETENTION_IDENTIFIERS = [
167
+ 'retainedSize',
168
+ 'retainedSizes',
169
+ 'retained_size',
170
+ 'aggregateRetained',
171
+ 'dominatorNode',
172
+ 'hasPathEdge',
173
+ 'pathEdge',
174
+ 'filterLargestObjects',
175
+ ];
106
176
  export function registerEval(server) {
107
177
  server.tool('memlab_eval', 'Execute arbitrary JavaScript code against the loaded heap snapshot. ' +
108
178
  'The code runs in a sandboxed VM with access to `snapshot` (IHeapSnapshot), ' +
@@ -120,19 +190,25 @@ export function registerEval(server) {
120
190
  '**Iterating all nodes:** `snapshot.nodes.forEach(node => { ... })` — NOT for-of.\n' +
121
191
  '**Get node by ID:** `snapshot.getNodeById(id)` returns IHeapNode or null.\n' +
122
192
  '**String values:** `node.toStringNode()?.stringValue` for string nodes.\n' +
123
- '**Caveat — retained_size is unreliable here:** inside eval, `node.retained_size`/`.retainedSize` can read back ~0 for every node on some loads. Node counts, property/edge walks, and string values ARE trustworthy. For authoritative retained sizes call `helpers.retainedSize(id)` / `helpers.retainedSizes([ids])` (they re-resolve the node on the real snapshot), or use the dedicated tools (`memlab_largest_objects`, `memlab_class_histogram`, `memlab_pinch_points`, `memlab_object_shape`).\n\n' +
124
- '**Example — inspect Map entries:**\n' +
125
- '```\nconst map = snapshot.getNodeById(12345);\nconst entries = [];\n' +
126
- 'for (const edge of map.references) {\n' +
127
- ' if (edge.name_or_index === "table") {\n' +
128
- ' for (const te of edge.toNode.references) {\n' +
129
- ' entries.push({name: te.toNode.name, type: te.toNode.type});\n' +
130
- ' }\n }\n}\nresult = entries.slice(0, 10);\n```', {
193
+ '**Caveat — retained_size is unreliable here:** inside eval, `node.retained_size`/`.retainedSize` can read back ~0 for every node on some loads. Node counts, property/edge walks, and string values ARE trustworthy. For authoritative retained sizes call `helpers.retainedSize(id)` (number) / `helpers.retainedSizes([ids])` (a `Record<id, bytes>` object, NOT an array) — they re-resolve the node on the real snapshot or use the dedicated tools (`memlab_largest_objects`, `memlab_class_histogram`, `memlab_pinch_points`, `memlab_object_shape`).\n\n' +
194
+ '**Example — inspect Map entries (use the helper; do NOT hand-roll the ' +
195
+ 'backing-store walk browser slots are `internal`-typed and SMI values ' +
196
+ 'leave index gaps):**\n' +
197
+ '```\nresult = helpers.mapEntries(12345, 10); // [{key, value}] briefs\n' +
198
+ '// Set: helpers.setElements(setId, 10)\n```\n' +
199
+ '**Example dedup / composition of a Relay record type:**\n' +
200
+ '```\nconst ids = helpers.byTypename("AdCreativeFeatureSpecAttachment");\n' +
201
+ 'const sigs = {};\n' +
202
+ 'for (const id of ids) { const s = helpers.shapeSignature(id); sigs[s] = (sigs[s]||0)+1; }\n' +
203
+ 'result = {count: ids.length, distinct: Object.keys(sigs).length};\n```\n' +
204
+ '**Multi-step exploration:** pass `save_as` to keep a result set server-side and `helpers.load(name)` to read it back in a later call, so intermediate id lists never have to be printed to the transcript. `mode:"list_saved"` lists them. ' +
205
+ 'Runs on a LIGHT snapshot too (counts, names, types, self sizes, string values, edge walks). Code referencing retained sizes, dominators or path edges is refused up front there rather than returning zeros. ' +
206
+ 'Pass `max_nodes` to bound a full-heap walk — on overrun the partial `result` is returned with a warning instead of failing, so a broad scan is safe to attempt. Every call reports `nodes_visited`.', {
131
207
  mode: z
132
- .enum(['eval', 'describe_env'])
208
+ .enum(['eval', 'describe_env', 'list_saved'])
133
209
  .optional()
134
210
  .default('eval')
135
- .describe('"eval" (default) runs `code`. "describe_env" ignores `code` and returns the in-scope globals, the IHeapNode/IHeapEdge API, and the required calling conventions (`result =`, `.forEach`) so you can self-correct before running.'),
211
+ .describe('"eval" (default) runs `code`. "describe_env" ignores `code` and returns the in-scope globals, the IHeapNode/IHeapEdge API, and the required calling conventions (`result =`, `.forEach`) so you can self-correct before running. "list_saved" ignores `code` and lists the named result sets saved so far for this snapshot.'),
136
212
  code: z
137
213
  .string()
138
214
  .optional()
@@ -143,8 +219,20 @@ export function registerEval(server) {
143
219
  'markdownTable, isNodeWorthInspecting, filterLargestObjects, queryNodes, ' +
144
220
  'groupReferrersByEdge(nodeId), groupArrayElementsByProperty(arrayNodeId, propName), ' +
145
221
  'isOrphaned(nodeId, ownershipEdgeNames[]), countUniqueTargets(arrayNodeId, propName), ' +
146
- 'retainedSize(id), retainedSizes(ids[]) }), ' +
222
+ 'retainedSize(id)->number, retainedSizes(ids[])->Record<id,bytes> (an OBJECT keyed by id, NOT an array — index it as sizes[id] or Object.values(sizes)), ' +
223
+ 'mapEntries(mapId, limit?)->[{key,value}] & setElements(setId, limit?)->[brief] (correct Map/Set/WeakMap enumeration — handles browser internal-typed slots AND SMI-value gaps, so you never re-derive it wrong), ' +
224
+ 'props(nodeOrId)->{prop: scalar | {ref,name,type}} & getProp(nodeOrId, name) & shapeSignature(nodeOrId, {maxStringLen?}) (content signature for dedup checks), ' +
225
+ 'byClass(name, {type?})->ids[] & byTypename(name)->ids[] & withProp(name)->ids[] (INDEXED lookups — built once per snapshot then memoized in a session scratch, so repeated questions are index-speed not full-scan; byClass covers EVERY node type, matching memlab_find_nodes_by_class, so closures/strings/arrays/natives are found — pass {type:"object"} to narrow), ' +
226
+ 'aggregateRetained(ids[])->{retained,exact} (dominator-deduped retained for a SET of ids, no double-counting), ' +
227
+ 'iterByClass(name, {type?})->nodes[] & iterByType(type)->nodes[] (INDEXED iteration — no full scan), ' +
228
+ 'classCounts({pattern?, type?, minCount?})->[{name,type,count,selfSize}] (one-pass histogram, cached), ' +
229
+ 'entries(nodeOrId)->[{key,value}] (generic Map/Set/WeakMap/Array/object walk, holes filtered), ' +
230
+ 'edgeTarget(nodeOrId, edgeName)->node|null, isRealDetached(node)->boolean (same filtering the tools apply internally), ' +
231
+ 'dominates(id, {population?, limit?})->{count,selfSize,ids,truncated}, ' +
232
+ 'pathBetween(fromId, toId, {maxNodes?})->{found,exhausted,path[]}, ' +
233
+ 'save(name, value) / load(name, {allowCrossSnapshot?}) / listSaved() (SESSION-scoped, survives loading another snapshot) }), ' +
147
234
  'and standard JS built-ins. ' +
235
+ 'NOTE: `node.retainedSize` / `node.retained_size` THROW inside eval — they can read back ~0 for every node on some loads, so a silent wrong number is refused; use helpers.retainedSize(id). `node.self_size` is reliable. ' +
148
236
  'Node traversal: use node.references (outgoing) and node.referrers (incoming) with for-of. ' +
149
237
  'Edge properties: .name_or_index, .type, .toNode, .fromNode.'),
150
238
  timeout_ms: z
@@ -152,15 +240,75 @@ export function registerEval(server) {
152
240
  .optional()
153
241
  .default(60000)
154
242
  .describe('Execution timeout in milliseconds (default 60000). Full-snapshot scans on large heaps may need 120000+.'),
155
- }, async ({ mode, code, timeout_ms }) => {
243
+ save_as: z
244
+ .string()
245
+ .regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
246
+ .optional()
247
+ .describe('Save this call\'s `result` under a name, reusable in later calls via `helpers.load("<name>")`. Lets a multi-step investigation keep intermediate sets (candidate ids, per-id measurements) SERVER-SIDE instead of round-tripping them through the transcript. Save plain data (ids, counts, strings) — not node objects. Scoped to the current snapshot and dropped when it is unloaded.'),
248
+ dry_run: z
249
+ .boolean()
250
+ .optional()
251
+ .default(false)
252
+ .describe('Report what the code WOULD scan and stop, without running it (default false). Returns the snapshot size, whether the code contains a full-heap walk, and the effective max_nodes budget. Use it before an exploratory scan on a multi-million-node heap, where the difference between an indexed lookup and a full walk is the difference between milliseconds and minutes.'),
253
+ max_nodes: z
254
+ .number()
255
+ .int()
256
+ .min(1)
257
+ .optional()
258
+ .default(20000000)
259
+ .describe('Abort a `snapshot.nodes.forEach` walk after this many node visits (default 20000000, i.e. effectively unlimited). On abort the partial `result` is returned with a note instead of failing, so a broad exploratory scan can be attempted safely. Reported back as `nodes_visited` on every call.'),
260
+ }, async ({ mode, code, timeout_ms, save_as, max_nodes, dry_run }) => {
261
+ const budget = { visited: 0, max: max_nodes, exceeded: false };
156
262
  try {
157
263
  if (mode === 'describe_env') {
158
264
  return toolResult(describeEnv());
159
265
  }
266
+ if (mode === 'list_saved') {
267
+ return toolResult(describeSaved());
268
+ }
160
269
  if (code == null || code.trim() === '') {
161
270
  return errorResult(new Error('No code provided. Pass `code`, or use mode:"describe_env" to see the available globals and conventions.'));
162
271
  }
163
- const snapshot = getSnapshot();
272
+ // Light snapshots are allowed here. Most eval code touches only
273
+ // `name`, `type`, `self_size`, `references` and `referrers`, none of
274
+ // which the dominator pass produces — refusing the whole tool forced a
275
+ // full (2x slower) load for counts-only work on a baseline rung. What
276
+ // IS unavailable is refused precisely instead: by a pre-flight text
277
+ // check below, and by the helpers themselves as a backstop.
278
+ const light = isLightSnapshot();
279
+ const snapshot = getSnapshot({ allowLight: true });
280
+ const currentHandle = getCurrentHandle() ?? '(none)';
281
+ if (light) {
282
+ const needsRetention = RETENTION_IDENTIFIERS.filter(id => new RegExp(`\\b${id}\\b`).test(code));
283
+ if (needsRetention.length > 0) {
284
+ return errorResult(new Error(`This snapshot was loaded in LIGHT mode (no dominator tree, no retained sizes, no shortest-path edges), and the code references ${needsRetention.map(i => `\`${i}\``).join(', ')}. ` +
285
+ 'Those would read 0 / undefined rather than fail, so the run is refused instead of returning confident zeros. ' +
286
+ 'Reload without `light` for retention work, or drop the reference — counts, names, types, self sizes, string values and edge walks all work fine on a light snapshot. ' +
287
+ '(If the identifier only appears inside a string literal, this is a false match; the same call succeeds on a non-light load.)'));
288
+ }
289
+ }
290
+ if (dry_run) {
291
+ // Estimate, do not execute. A full-heap walk is detected textually —
292
+ // the honest limit of a pre-flight check, and stated as such rather
293
+ // than implying the code was analysed.
294
+ const meta = getSnapshotMetadata();
295
+ const fullWalk = /\b(?:snapshot\.)?(?:nodes|edges)\s*\.\s*forEach/.test(code);
296
+ const indexed = /helpers\.(byClass|byTypename|withProp|getNode)/.test(code);
297
+ return toolResult([
298
+ '## Dry run — nothing was executed',
299
+ '',
300
+ `Snapshot: ${formatNumber(meta?.nodeCount ?? 0)} nodes, ${formatNumber(meta?.edgeCount ?? 0)} edges.`,
301
+ `Walk budget (\`max_nodes\`): ${formatNumber(budget.max)}.`,
302
+ '',
303
+ fullWalk
304
+ ? `⚠ The code contains a full-heap walk, so it will visit up to ${formatNumber(Math.min(budget.max, meta?.nodeCount ?? 0))} nodes. On a heap this size that is seconds to minutes.${indexed ? '' : ' `helpers.byClass` / `byTypename` / `withProp` are indexed and avoid the walk when you know what you are looking for.'}`
305
+ : indexed
306
+ ? 'No full-heap walk detected; the code uses the indexed helpers, which do not scan the heap.'
307
+ : 'No full-heap walk detected by text match. This is a textual check, not an analysis — a walk reached indirectly will not be seen here.',
308
+ '',
309
+ '_Re-run without `dry_run` to execute._',
310
+ ].join('\n'));
311
+ }
164
312
  const consoleOutput = [];
165
313
  const capturedConsole = {
166
314
  log: (...args) => consoleOutput.push(args.map(String).join(' ')),
@@ -235,11 +383,22 @@ export function registerEval(server) {
235
383
  // ~0; these helpers look the node up fresh on the real snapshot (the
236
384
  // same path the dedicated tools use) so custom analyses can rank by
237
385
  // retained size.
386
+ // On a light snapshot these would return 0 for every id, which is
387
+ // indistinguishable from a genuinely tiny object. Throw instead: the
388
+ // pre-flight check above catches the common case, and this covers code
389
+ // that reaches them indirectly.
390
+ const requireRetention = (what) => {
391
+ if (!light)
392
+ return;
393
+ throw new Error(`helpers.${what} needs retained sizes, which a LIGHT snapshot does not have (it would return 0 for every id). Reload with memlab_load_snapshot({file_path, light: false}).`);
394
+ };
238
395
  const retainedSize = (id) => {
396
+ requireRetention('retainedSize');
239
397
  const n = snapshot.getNodeById(id);
240
398
  return n ? n.retainedSize : 0;
241
399
  };
242
400
  const retainedSizes = (ids) => {
401
+ requireRetention('retainedSizes');
243
402
  const out = {};
244
403
  for (const id of ids) {
245
404
  const n = snapshot.getNodeById(id);
@@ -269,14 +428,455 @@ export function registerEval(server) {
269
428
  }
270
429
  return { uniqueCount: uniqueIds.size, totalElements: total };
271
430
  };
431
+ // Compact, ready-to-use view of a node (no proxy, values inlined) so
432
+ // custom scripts get data they can JSON-return directly instead of
433
+ // re-deriving `.toStringNode()?.stringValue` etc. `retained_size` is
434
+ // resolved via the trusted `retainedSize(id)` re-lookup (NOT the raw
435
+ // `.retainedSize`, which can read back ~0 inside eval on some loads — the
436
+ // foot-gun this whole tool's description warns about).
437
+ const nodeBrief = (n) => n == null
438
+ ? null
439
+ : {
440
+ id: n.id,
441
+ name: n.name,
442
+ type: n.type,
443
+ self_size: n.self_size,
444
+ retained_size: retainedSize(n.id),
445
+ string: n.isString
446
+ ? (n.toStringNode()?.stringValue ?? null)
447
+ : null,
448
+ };
449
+ const resolveNode = (nodeOrId) => {
450
+ if (nodeOrId == null)
451
+ return null;
452
+ const id = typeof nodeOrId === 'number' ? nodeOrId : nodeOrId.id;
453
+ return snapshot.getNodeById(id);
454
+ };
455
+ // Correctly enumerate Map/WeakMap entries and Set elements via the shared
456
+ // index-aware backing-store walk (handles browser `internal`-typed slots
457
+ // AND SMI-value gaps). Removes the #1 eval foot-gun: hand-rolling this
458
+ // and silently getting 0 results by filtering on `type === 'element'`.
459
+ const mapEntries = (id, limit = 1000) => {
460
+ const node = snapshot.getNodeById(id);
461
+ if (!node)
462
+ throw new Error(`mapEntries: node @${id} not found`);
463
+ // Guard the node type — enumerateMapEntries assumes key/value slots, so
464
+ // running it on a Set (element/chain layout) would emit each element as
465
+ // a lone key with value:null, which is silently misleading.
466
+ if (node.name !== 'Map' && node.name !== 'WeakMap') {
467
+ throw new Error(`mapEntries: @${id} is a ${node.name} (${node.type}), not a Map/WeakMap. ` +
468
+ `For a Set use helpers.setElements(${id}); otherwise inspect with helpers.props()/get_references.`);
469
+ }
470
+ return enumerateMapEntries(node)
471
+ .slice(0, limit)
472
+ .map(e => ({ key: nodeBrief(e.key), value: nodeBrief(e.value) }));
473
+ };
474
+ const setElements = (id, limit = 1000) => {
475
+ const node = snapshot.getNodeById(id);
476
+ if (!node)
477
+ throw new Error(`setElements: node @${id} not found`);
478
+ if (node.name !== 'Set' && node.name !== 'WeakSet') {
479
+ throw new Error(`setElements: @${id} is a ${node.name} (${node.type}), not a Set/WeakSet. ` +
480
+ `For a Map use helpers.mapEntries(${id}).`);
481
+ }
482
+ return enumerateSetElements(node).slice(0, limit).map(nodeBrief);
483
+ };
484
+ // Read an object's own properties as a plain object: scalars inlined,
485
+ // object-valued props as `{ref, name, type}`. Saves the repetitive
486
+ // `for (const e of n.references) if (e.name_or_index === X)` boilerplate.
487
+ const props = (nodeOrId) => {
488
+ const node = resolveNode(nodeOrId);
489
+ if (!node)
490
+ return {};
491
+ const out = {};
492
+ for (const e of node.references) {
493
+ if (e.type !== 'property')
494
+ continue;
495
+ const name = String(e.name_or_index);
496
+ if (name === '__proto__')
497
+ continue;
498
+ const t = e.toNode;
499
+ if (t.isString)
500
+ out[name] = t.toStringNode()?.stringValue ?? '';
501
+ else if (t.name === 'true')
502
+ out[name] = true;
503
+ else if (t.name === 'false')
504
+ out[name] = false;
505
+ else if (t.name === 'null')
506
+ out[name] = null;
507
+ else if (t.name === 'undefined')
508
+ out[name] = undefined;
509
+ else
510
+ out[name] = { ref: t.id, name: t.name, type: t.type };
511
+ }
512
+ return out;
513
+ };
514
+ const getProp = (nodeOrId, name) => props(nodeOrId)[name];
515
+ const shapeSignature = (nodeOrId, opts) => {
516
+ const node = resolveNode(nodeOrId);
517
+ return node ? objectContentSignature(node, opts ?? {}) : '';
518
+ };
519
+ // Index helpers — build once per snapshot, memoized in the eval scratch
520
+ // so a follow-up call is index-speed, not a fresh 12M-node scan. Ids are
521
+ // only valid for the active snapshot (the scratch is keyed to it).
522
+ const scratch = getEvalScratch();
523
+ const buildClassTypeIndex = () => {
524
+ const cached = scratch.__classTypeIndex;
525
+ if (cached)
526
+ return cached;
527
+ const byClass = new Map();
528
+ const byTypename = new Map();
529
+ snapshot.nodes.forEach((node) => {
530
+ if (node.id <= 3)
531
+ return; // skip oddball/root nodes, matching the histogram/duplicate-objects tools for count parity
532
+ // Index EVERY node type. Restricting this to `object` made the
533
+ // helper silently return [] for closures, strings, arrays and
534
+ // native (`blink::*`) nodes — which is most of what other tools
535
+ // report. Measured: byClass('setComposerLinks_$0') returned [] on a
536
+ // snapshot where a manual walk found 1,011 of them, because the
537
+ // class is a closure; the empty result reads as "does not exist".
538
+ // memlab_find_nodes_by_class matches any type by default and this
539
+ // helper is documented as its indexed equivalent, so the two must
540
+ // agree.
541
+ let a = byClass.get(node.name);
542
+ if (!a) {
543
+ a = [];
544
+ byClass.set(node.name, a);
545
+ }
546
+ a.push(node.id);
547
+ // `__typename` is a JS object property, so only object nodes can
548
+ // carry one; skipping the edge walk for other types keeps the
549
+ // widened index roughly as cheap as the object-only one.
550
+ if (node.type !== 'object')
551
+ return;
552
+ for (const e of node.references) {
553
+ if (e.type === 'property' &&
554
+ String(e.name_or_index) === '__typename') {
555
+ const t = e.toNode;
556
+ const tn = t.isString ? t.toStringNode()?.stringValue : null;
557
+ if (tn) {
558
+ let b = byTypename.get(tn);
559
+ if (!b) {
560
+ b = [];
561
+ byTypename.set(tn, b);
562
+ }
563
+ b.push(node.id);
564
+ }
565
+ break;
566
+ }
567
+ }
568
+ });
569
+ const idx = { byClass, byTypename };
570
+ scratch.__classTypeIndex = idx;
571
+ return idx;
572
+ };
573
+ const byClass = (name, opts) => {
574
+ const ids = buildClassTypeIndex().byClass.get(name) ?? [];
575
+ const want = opts?.type;
576
+ if (want == null)
577
+ return ids;
578
+ return ids.filter(id => snapshot.getNodeById(id)?.type === want);
579
+ };
580
+ const byTypename = (name) => buildClassTypeIndex().byTypename.get(name) ?? [];
581
+ const withProp = (name) => {
582
+ const key = `__withProp:${name}`;
583
+ const cached = scratch[key];
584
+ if (cached)
585
+ return cached;
586
+ const ids = [];
587
+ snapshot.nodes.forEach((node) => {
588
+ if (node.id <= 3)
589
+ return; // skip oddball/root nodes for parity with other tools
590
+ // Every node type is scanned: the `property` edge check below is
591
+ // what constrains the match, and closures do carry named property
592
+ // edges. Restricting the walk to `object` hid them, the same way it
593
+ // hid non-object classes from byClass.
594
+ for (const e of node.references) {
595
+ if (e.type === 'property' && String(e.name_or_index) === name) {
596
+ ids.push(node.id);
597
+ break;
598
+ }
599
+ }
600
+ });
601
+ scratch[key] = ids;
602
+ return ids;
603
+ };
604
+ // Dominator-deduped retained size for a SET of ids (bounded walk). Unlike
605
+ // summing helpers.retainedSize over the ids, this does not double-count
606
+ // bytes when one id dominates another in the set.
607
+ const aggregateRetained = (ids) => {
608
+ requireRetention('aggregateRetained');
609
+ return boundedDominatorRetainedSize(new NumericSet(ids), snapshot);
610
+ };
611
+ // ---- additional traversal helpers -------------------------------
612
+ // Each of these was hand-written inside `code` during a leak hunt,
613
+ // several of them more than once and with small differences that made
614
+ // results incomparable. Shipping them makes the common traversals both
615
+ // cheaper to write and consistent with what the dedicated tools do.
616
+ // The oddball/root filtering the tools apply internally. Hand-written
617
+ // eval that omits it counts nodes the tools do not, so the two disagree
618
+ // for reasons that have nothing to do with the question being asked.
619
+ const isRealDetached = (node) => {
620
+ const n = unwrapNode(node);
621
+ if (n == null || n.id <= 3)
622
+ return false;
623
+ return n.is_detached || n.name.startsWith('Detached ');
624
+ };
625
+ // Cached type -> ids index, mirroring the class index above, so a
626
+ // second pass over "every closure" does not re-walk the heap.
627
+ const buildTypeIndex = () => {
628
+ const cached = scratch.__typeIndex;
629
+ if (cached)
630
+ return cached;
631
+ const byType = new Map();
632
+ snapshot.nodes.forEach((node) => {
633
+ if (node.id <= 3)
634
+ return;
635
+ let a = byType.get(node.type);
636
+ if (!a) {
637
+ a = [];
638
+ byType.set(node.type, a);
639
+ }
640
+ a.push(node.id);
641
+ });
642
+ scratch.__typeIndex = byType;
643
+ return byType;
644
+ };
645
+ const nodesFromIds = (ids) => {
646
+ const out = [];
647
+ for (const id of ids) {
648
+ const n = snapshot.getNodeById(id);
649
+ if (n)
650
+ out.push(n);
651
+ }
652
+ return out;
653
+ };
654
+ const iterByClass = (name, opts) => nodesFromIds(byClass(name, opts)).map(wrapNode);
655
+ const iterByType = (type) => nodesFromIds(buildTypeIndex().get(type) ?? []).map(wrapNode);
656
+ // One-pass class histogram, cached, optionally filtered. `byClass`
657
+ // answers "where are the X"; this answers "what is in here at all",
658
+ // which otherwise means a full manual walk every time.
659
+ const classCounts = (opts) => {
660
+ const cacheKey = '__classCounts';
661
+ let all = scratch[cacheKey];
662
+ if (!all) {
663
+ const acc = new Map();
664
+ snapshot.nodes.forEach((node) => {
665
+ if (node.id <= 3)
666
+ return;
667
+ const key = `${node.type}::${node.name}`;
668
+ const e = acc.get(key);
669
+ if (e) {
670
+ e.count++;
671
+ e.selfSize += node.self_size;
672
+ }
673
+ else {
674
+ acc.set(key, {
675
+ name: node.name,
676
+ type: node.type,
677
+ count: 1,
678
+ selfSize: node.self_size,
679
+ });
680
+ }
681
+ });
682
+ all = [...acc.values()].sort((a, b) => b.count - a.count);
683
+ scratch[cacheKey] = all;
684
+ }
685
+ const matches = makeNamePatternTest(opts?.pattern);
686
+ const minCount = opts?.minCount ?? 1;
687
+ return all.filter(r => r.count >= minCount &&
688
+ (opts?.type == null || r.type === opts.type) &&
689
+ matches(r.name));
690
+ };
691
+ // The node behind a named edge. Written from scratch in four separate
692
+ // evals because `props()` returns {ref,name,type} wrappers, which are
693
+ // awkward exactly when the node itself is what you need.
694
+ const edgeTarget = (nodeOrId, edgeName) => {
695
+ const n = typeof nodeOrId === 'number'
696
+ ? snapshot.getNodeById(nodeOrId)
697
+ : unwrapNode(nodeOrId);
698
+ if (n == null)
699
+ return null;
700
+ for (const e of n.references) {
701
+ if (e.type === 'hidden')
702
+ continue;
703
+ if (String(e.name_or_index) !== edgeName)
704
+ continue;
705
+ return e.toNode.id > 3 ? wrapNode(e.toNode) : null;
706
+ }
707
+ return null;
708
+ };
709
+ // Generic container walk. `mapEntries` / `setElements` cover Map and
710
+ // Set; WeakMap tables and plain arrays needed a manual `references`
711
+ // walk with hole filtering every time.
712
+ const entries = (nodeOrId) => {
713
+ const n = typeof nodeOrId === 'number'
714
+ ? snapshot.getNodeById(nodeOrId)
715
+ : unwrapNode(nodeOrId);
716
+ if (n == null)
717
+ return [];
718
+ if (n.name === 'Map' || n.name === 'WeakMap') {
719
+ return enumerateMapEntries(n).map(e => ({
720
+ key: wrapNode(e.key),
721
+ value: e.value == null ? null : wrapNode(e.value),
722
+ }));
723
+ }
724
+ if (n.name === 'Set' || n.name === 'WeakSet') {
725
+ return enumerateSetElements(n).map(el => ({
726
+ key: null,
727
+ value: wrapNode(el),
728
+ }));
729
+ }
730
+ const out = [];
731
+ for (const e of n.references) {
732
+ if (e.type === 'hidden')
733
+ continue;
734
+ const name = String(e.name_or_index);
735
+ if (name === '__proto__' || name === 'map')
736
+ continue;
737
+ if (e.type === 'element') {
738
+ out.push({
739
+ key: Number(e.name_or_index),
740
+ value: wrapNode(e.toNode),
741
+ });
742
+ }
743
+ else if (name === 'elements' && e.type === 'internal') {
744
+ for (const el of e.toNode.references) {
745
+ if (el.type !== 'element')
746
+ continue;
747
+ out.push({
748
+ key: Number(el.name_or_index),
749
+ value: wrapNode(el.toNode),
750
+ });
751
+ }
752
+ }
753
+ else if (e.type === 'property') {
754
+ out.push({ key: name, value: wrapNode(e.toNode) });
755
+ }
756
+ }
757
+ return out;
758
+ };
759
+ // What does this node actually own? The question behind
760
+ // memlab_dominator_attribution, exposed for ad-hoc populations.
761
+ const dominates = (id, opts) => {
762
+ requireRetention('dominates');
763
+ const limit = opts?.limit ?? 1000;
764
+ const pop = opts?.population;
765
+ let count = 0;
766
+ let selfSize = 0;
767
+ const ids = [];
768
+ let truncated = false;
769
+ snapshot.nodes.forEach((node) => {
770
+ if (node.id <= 3 || node.id === id)
771
+ return;
772
+ if (pop != null && !pop(wrapNode(node)))
773
+ return;
774
+ let cur = node.dominatorNode ?? null;
775
+ let hops = 0;
776
+ while (cur && hops++ < 500) {
777
+ if (cur.id === id) {
778
+ count++;
779
+ selfSize += node.self_size;
780
+ if (ids.length < limit)
781
+ ids.push(node.id);
782
+ else
783
+ truncated = true;
784
+ break;
785
+ }
786
+ const next = cur.dominatorNode ?? null;
787
+ if (!next || next.id === cur.id)
788
+ break;
789
+ cur = next;
790
+ }
791
+ });
792
+ return { count, selfSize, ids, truncated };
793
+ };
794
+ // Shortest reference path a -> b, by BFS over outgoing edges. Bounded,
795
+ // and reports that it gave up rather than returning null as if no path
796
+ // existed.
797
+ const pathBetween = (fromId, toId, opts) => {
798
+ const maxNodes = opts?.maxNodes ?? 200_000;
799
+ const start = snapshot.getNodeById(fromId);
800
+ if (start == null || snapshot.getNodeById(toId) == null) {
801
+ return { found: false, exhausted: false, path: [] };
802
+ }
803
+ const prev = new Map();
804
+ const seen = new Set([fromId]);
805
+ let queue = [start];
806
+ let visited = 0;
807
+ while (queue.length > 0) {
808
+ const next = [];
809
+ for (const node of queue) {
810
+ if (++visited > maxNodes) {
811
+ return { found: false, exhausted: true, path: [] };
812
+ }
813
+ for (const e of node.references) {
814
+ const t = e.toNode;
815
+ if (t.id <= 3 || seen.has(t.id))
816
+ continue;
817
+ seen.add(t.id);
818
+ prev.set(t.id, { via: String(e.name_or_index), from: node.id });
819
+ if (t.id === toId) {
820
+ const path = [];
821
+ let cur = toId;
822
+ while (cur !== fromId) {
823
+ const p = prev.get(cur);
824
+ if (p == null)
825
+ break;
826
+ const n = snapshot.getNodeById(cur);
827
+ path.unshift(`.${p.via} -> @${cur} ${n?.name ?? ''}`);
828
+ cur = p.from;
829
+ }
830
+ path.unshift(`@${fromId} ${start.name}`);
831
+ return { found: true, exhausted: false, path };
832
+ }
833
+ next.push(t);
834
+ }
835
+ }
836
+ queue = next;
837
+ }
838
+ return { found: false, exhausted: false, path: [] };
839
+ };
840
+ // Named result sets are SESSION-scoped, not snapshot-scoped: comparing
841
+ // a baseline scan against a final scan is the whole job, and the old
842
+ // per-snapshot scratch dropped the baseline the moment the next rung
843
+ // was loaded — exactly when it was needed.
844
+ //
845
+ // Node ids, however, are per-capture. A set of ids saved against one
846
+ // snapshot means nothing against another, so a cross-snapshot load is
847
+ // refused unless the caller opts in. Counts and strings are portable;
848
+ // ids are not, and silently letting them through is the failure this
849
+ // whole class of guard exists to prevent.
850
+ const save = (name, value) => {
851
+ setSavedResult(name, value, currentHandle);
852
+ return value;
853
+ };
854
+ const load = (name, opts) => {
855
+ const entry = getSavedResult(name);
856
+ if (entry == null) {
857
+ throw new Error(`No saved result named "${name}". Saved names: ${savedNames().join(', ') || '(none)'}. ` +
858
+ 'Save one with the save_as parameter or helpers.save(name, value).');
859
+ }
860
+ if (entry.handle !== currentHandle &&
861
+ opts?.allowCrossSnapshot !== true) {
862
+ throw new Error(`"${name}" was saved against snapshot "${entry.handle}" and the current snapshot is "${currentHandle}". Node ids are per-capture, so ids from another snapshot resolve to unrelated objects or to nothing. ` +
863
+ 'If the value is counts/strings/shapes rather than ids, pass {allowCrossSnapshot: true} to read it anyway.');
864
+ }
865
+ return entry.value;
866
+ };
867
+ const listSaved = () => listSavedResults();
868
+ // Sandbox code holds PROXIED nodes (see wrapNode). The helpers below
869
+ // read `retainedSize`, which the proxy refuses on purpose, so they take
870
+ // the real node: unwrap at the boundary rather than making every caller
871
+ // remember which helpers are proxy-safe.
272
872
  const helpers = {
273
- serializeNodeSummary,
274
- serializeNodeDetail,
873
+ serializeNodeSummary: (n) => serializeNodeSummary(unwrapNode(n)),
874
+ serializeNodeDetail: (n) => serializeNodeDetail(unwrapNode(n)),
275
875
  formatBytes,
276
876
  formatNumber,
277
877
  markdownTable,
278
- isNodeWorthInspecting,
279
- filterLargestObjects,
878
+ isNodeWorthInspecting: (n, ...rest) => isNodeWorthInspecting(unwrapNode(n), ...rest),
879
+ filterLargestObjects: (nodes, ...rest) => filterLargestObjects(Array.isArray(nodes) ? nodes.map(unwrapNode) : nodes, ...rest),
280
880
  queryNodes,
281
881
  groupReferrersByEdge,
282
882
  groupArrayElementsByProperty,
@@ -284,9 +884,30 @@ export function registerEval(server) {
284
884
  countUniqueTargets,
285
885
  retainedSize,
286
886
  retainedSizes,
887
+ nodeBrief,
888
+ mapEntries,
889
+ setElements,
890
+ props,
891
+ getProp,
892
+ shapeSignature,
893
+ byClass,
894
+ byTypename,
895
+ withProp,
896
+ aggregateRetained,
897
+ isRealDetached,
898
+ iterByClass,
899
+ iterByType,
900
+ classCounts,
901
+ edgeTarget,
902
+ entries,
903
+ dominates,
904
+ pathBetween,
905
+ save,
906
+ load,
907
+ listSaved,
287
908
  };
288
909
  const sandbox = {
289
- snapshot: wrapSnapshot(snapshot),
910
+ snapshot: wrapSnapshot(snapshot, budget),
290
911
  utils,
291
912
  helpers,
292
913
  console: capturedConsole,
@@ -319,16 +940,37 @@ export function registerEval(server) {
319
940
  };
320
941
  const context = vm.createContext(sandbox);
321
942
  const script = new vm.Script(code, { filename: 'memlab_eval' });
322
- script.runInContext(context, { timeout: timeout_ms });
943
+ // A budget abort is a controlled stop, not a failure: whatever the code
944
+ // had already assigned to `result` is still returned, annotated below.
945
+ try {
946
+ script.runInContext(context, { timeout: timeout_ms });
947
+ }
948
+ catch (err) {
949
+ // Keyed on the error itself, never on `budget.exceeded`: code that
950
+ // catches the abort and then throws for an unrelated reason must
951
+ // still surface that error.
952
+ if (!(err instanceof BudgetExceeded))
953
+ throw err;
954
+ }
323
955
  // Actionable hint when nothing was assigned to `result` (the #1 user
324
956
  // error — code that `return`s a value or runs a value-returning IIFE
325
957
  // never populates `result`, so output is silently "undefined").
326
- if (sandbox.result === undefined && consoleOutput.length === 0) {
958
+ if (sandbox.result === undefined &&
959
+ consoleOutput.length === 0 &&
960
+ !budget.exceeded) {
327
961
  return toolResult('Your code ran without error but never assigned to `result`, so there is nothing to return.\n' +
328
962
  'Assign the value you want back to `result` (do NOT use `return` at the top level), e.g.:\n' +
329
963
  ' `result = someValue;`\n' +
330
964
  'Use mode:"describe_env" to see the full calling convention.');
331
965
  }
966
+ // `undefined` is never worth persisting: on reload it is
967
+ // indistinguishable from a name that was never saved, and the usual
968
+ // cause is the "never assigned to `result`" mistake — which the hint
969
+ // above only catches when the run produced no console output.
970
+ const nothingToSave = sandbox.result === undefined;
971
+ if (save_as != null && !budget.exceeded && !nothingToSave) {
972
+ setSavedResult(save_as, sandbox.result, currentHandle);
973
+ }
332
974
  let output;
333
975
  try {
334
976
  output = JSON.stringify(sandbox.result, null, 2) ?? 'undefined';
@@ -341,6 +983,25 @@ export function registerEval(server) {
341
983
  const consolePart = truncate(consoleOutput.join('\n'), MAX_OUTPUT_SIZE - output.length > 1024 ? 4096 : 1024);
342
984
  output += '\n\n--- console output ---\n' + consolePart;
343
985
  }
986
+ const footer = [];
987
+ if (budget.exceeded) {
988
+ footer.push(`⚠️ Walk aborted after ${formatNumber(budget.max)} node visits (max_nodes). The value above is PARTIAL. ` +
989
+ 'Raise max_nodes, or narrow the scan with an indexed helper (`helpers.byClass` / `byTypename` / `withProp`) instead of a full `snapshot.nodes` walk.');
990
+ if (save_as != null) {
991
+ footer.push(`Not saved as "${save_as}" — a partial result would be indistinguishable from a complete one on reload.`);
992
+ }
993
+ }
994
+ else if (budget.visited > 0) {
995
+ footer.push(`nodes_visited: ${formatNumber(budget.visited)}`);
996
+ }
997
+ if (save_as != null && !budget.exceeded) {
998
+ footer.push(nothingToSave
999
+ ? `Not saved as "${save_as}" — \`result\` was undefined, and a saved \`undefined\` is indistinguishable from a name that was never saved. Assign the value you want to keep to \`result\` (do NOT \`return\` at the top level) and re-run.`
1000
+ : `Saved as "${save_as}" — read it back in a later call with \`helpers.load("${save_as}")\`.`);
1001
+ }
1002
+ if (footer.length > 0) {
1003
+ output += '\n\n--- ' + footer.join('\n');
1004
+ }
344
1005
  return toolResult(output);
345
1006
  }
346
1007
  catch (err) {
@@ -367,6 +1028,75 @@ function actionableEvalError(err, code) {
367
1028
  }
368
1029
  return msg;
369
1030
  }
1031
+ function savedNames() {
1032
+ return listSavedResults()
1033
+ .map(r => r.name)
1034
+ .sort();
1035
+ }
1036
+ /** One-line shape description so `list_saved` is useful without re-dumping the data. */
1037
+ function describeSavedValue(value) {
1038
+ if (value === null)
1039
+ return 'null';
1040
+ if (Array.isArray(value))
1041
+ return `array (${formatNumber(value.length)} items)`;
1042
+ if (typeof value === 'string') {
1043
+ return `string (${formatNumber(value.length)} chars)`;
1044
+ }
1045
+ if (typeof value === 'object') {
1046
+ // The sandbox is seeded with the host realm's Map/Set/Date/typed arrays, so
1047
+ // `instanceof` holds for values built inside eval. Without these cases every
1048
+ // one of them reports `object (0 keys)` — a container holding millions of
1049
+ // entries would look empty here.
1050
+ if (value instanceof Map) {
1051
+ return `Map (${formatNumber(value.size)} entries)`;
1052
+ }
1053
+ if (value instanceof Set) {
1054
+ return `Set (${formatNumber(value.size)} items)`;
1055
+ }
1056
+ if (value instanceof Date) {
1057
+ return `Date (${value.toISOString()})`;
1058
+ }
1059
+ if (ArrayBuffer.isView(value)) {
1060
+ const ctor = value.constructor?.name ?? 'TypedArray';
1061
+ const len = value.length;
1062
+ return typeof len === 'number'
1063
+ ? `${ctor} (${formatNumber(len)} items)`
1064
+ : `${ctor} (${formatNumber(value.byteLength)} bytes)`;
1065
+ }
1066
+ const keys = formatNumber(Object.keys(value).length);
1067
+ // A named constructor (WeakMap, a class instance, …) is reported by name so
1068
+ // a `0 keys` line is attributable rather than just puzzling.
1069
+ const ctor = value.constructor?.name;
1070
+ return ctor == null || ctor === 'Object'
1071
+ ? `object (${keys} keys)`
1072
+ : `${ctor} (${keys} own keys)`;
1073
+ }
1074
+ return typeof value;
1075
+ }
1076
+ function describeSaved() {
1077
+ const saved = listSavedResults();
1078
+ if (saved.length === 0) {
1079
+ return [
1080
+ '# Saved result sets: (none)',
1081
+ '',
1082
+ 'Save one by passing `save_as: "<name>"` on a memlab_eval call, or calling `helpers.save("<name>", value)` inside your code.',
1083
+ 'Read it back in a later call with `helpers.load("<name>")`.',
1084
+ 'Saved sets last for the SERVER SESSION and survive loading another snapshot, so a baseline scan can be compared against a later rung. The snapshot each was saved against is recorded: reading one back under a different snapshot is refused unless you pass `{allowCrossSnapshot: true}`, because node ids are per-capture.',
1085
+ ].join('\n');
1086
+ }
1087
+ const current = getCurrentHandle();
1088
+ return [
1089
+ `# Saved result sets (${saved.length}) — session-scoped`,
1090
+ '',
1091
+ markdownTable(['name', 'saved against', 'shape'], saved.map(r => [
1092
+ r.name,
1093
+ r.handle === current ? `${r.handle} (current)` : r.handle,
1094
+ describeSavedValue(getSavedResult(r.name)?.value),
1095
+ ])),
1096
+ '',
1097
+ 'Read one back with `helpers.load("<name>")`. Ids saved against another snapshot need `helpers.load("<name>", {allowCrossSnapshot: true})` — and are only meaningful if the value is counts/strings/shapes rather than node ids.',
1098
+ ].join('\n');
1099
+ }
370
1100
  function describeEnv() {
371
1101
  return [
372
1102
  '# memlab_eval environment',
@@ -379,14 +1109,41 @@ function describeEnv() {
379
1109
  '## In-scope globals',
380
1110
  '- `snapshot` — IHeapSnapshot: `.nodes.forEach(cb)`, `.edges.forEach(cb)`, `.getNodeById(id)`.',
381
1111
  '- `utils` — @memlab/core utils (e.g. `aggregateDominatorMetrics`, `isFiberNode`, `isDetachedDOMNode`).',
382
- '- `helpers` — `serializeNodeSummary`, `serializeNodeDetail`, `formatBytes`, `formatNumber`, `markdownTable`, `isNodeWorthInspecting`, `filterLargestObjects`, `queryNodes`, `groupReferrersByEdge(nodeId)`, `groupArrayElementsByProperty(arrayNodeId, prop)`, `isOrphaned(nodeId, ownerEdges[])`, `countUniqueTargets(arrayNodeId, prop)`, `retainedSize(id)`, `retainedSizes(ids[])`.',
1112
+ '- `helpers` — `serializeNodeSummary`, `serializeNodeDetail`, `formatBytes`, `formatNumber`, `markdownTable`, `isNodeWorthInspecting`, `filterLargestObjects`, `queryNodes`, `groupReferrersByEdge(nodeId)`, `groupArrayElementsByProperty(arrayNodeId, prop)`, `isOrphaned(nodeId, ownerEdges[])`, `countUniqueTargets(arrayNodeId, prop)`, `retainedSize(id) -> number`, `retainedSizes(ids[]) -> Record<id, bytes>` (an OBJECT keyed by id, NOT an array — use `sizes[id]` or `Object.values(sizes)`, not `.reduce`/`.map` directly).',
383
1113
  '- Standard JS built-ins (Array, Object, Map, Set, JSON, Math, RegExp, …). No require/process/fs/network.',
384
1114
  '',
1115
+ '## Collection / shape / index helpers (prefer these over hand-rolling)',
1116
+ '- `helpers.mapEntries(mapId, limit=1000) -> [{key, value}]` and `helpers.setElements(setId, limit=1000) -> [brief]` — CORRECT Map/Set/WeakMap enumeration. Handles browser `internal`-typed backing slots and SMI-value gaps (naive `type === "element"` filtering or positional `[i],[i+1]` pairing silently returns 0 / mispairs). Each brief is `{id, name, type, self_size, retained_size, string}`.',
1117
+ "- `helpers.props(nodeOrId) -> {prop: scalar | {ref, name, type}}` and `helpers.getProp(nodeOrId, name)` — read an object's own properties without the `for (const e of n.references) …` boilerplate. Number-valued props surface as a ref to a `smi number`/`heap number` node; their actual numeric value is not in the snapshot format.",
1118
+ '- `helpers.shapeSignature(nodeOrId, {maxStringLen?}) -> string` — stable shallow content signature (sorted prop names + scalar values) for duplicate-record detection. Numeric values are NOT captured (see `memlab_duplicate_objects`), so records differing only in a number field hash the same.',
1119
+ '- `helpers.byClass(name, {type?}) -> ids[]`, `helpers.byTypename(name) -> ids[]`, `helpers.withProp(name) -> ids[]` — INDEXED id lookups. The class/typename index is built once per snapshot and memoized in a session scratch, so a follow-up call is index-speed, not another full `snapshot.nodes` scan. `byClass` indexes EVERY node type (closure, string, array, native, …), matching `memlab_find_nodes_by_class`; pass `{type: "object"}` to narrow. `byTypename` is object-only because `__typename` is a JS property. (See also the `memlab_duplicate_objects` tool for a ready-made dedup report.)',
1120
+ '- `helpers.iterByClass(name, {type?}) -> node[]` / `helpers.iterByType(type) -> node[]` — indexed iteration; no full scan, index built once per snapshot.',
1121
+ '- `helpers.classCounts({pattern?, type?, minCount?}) -> [{name, type, count, selfSize}]` — one-pass class histogram, cached; `pattern` is a case-insensitive regex (substring fallback).',
1122
+ '- `helpers.entries(nodeOrId) -> [{key, value}]` — generic container walk: Map/WeakMap (paired, SMI gaps handled), Set/WeakSet, Array (both direct `element` edges and the `(object elements)` backing store), plain object properties. Holes and `__proto__`/`map` are filtered.',
1123
+ '- `helpers.edgeTarget(nodeOrId, edgeName) -> node | null` — the node behind a named edge, when you need the node and not the `{ref,name,type}` wrapper `props()` returns.',
1124
+ '- `helpers.isRealDetached(node) -> boolean` — the oddball/root filtering the detached-DOM tools apply internally, so hand-written eval counts the same set they do.',
1125
+ '- `helpers.dominates(id, {population?, limit?}) -> {count, selfSize, ids, truncated}` — what this node actually owns (bounded 500-hop dominator walk). `population` is a predicate over nodes.',
1126
+ '- `helpers.pathBetween(fromId, toId, {maxNodes?}) -> {found, exhausted, path[]}` — BFS over outgoing edges; `exhausted:true` means the budget ran out, which is NOT the same as "no path".',
1127
+ '- `helpers.save(name, value)` / `helpers.load(name, {allowCrossSnapshot?})` / `helpers.listSaved()` — named result sets, SESSION-scoped: they survive loading another snapshot, which is what makes a baseline-vs-final comparison possible. The snapshot each was saved against is recorded, and a cross-snapshot read is refused unless you opt in — node ids are per-capture and mean nothing in another snapshot.',
1128
+ '- `helpers.aggregateRetained(ids[]) -> {retained, exact}` — dominator-deduped retained size for a SET of ids (does not double-count when one id dominates another); `exact:false` means the bounded walk was truncated (upper bound).',
1129
+ '',
1130
+ '## Named result sets (multi-step exploration)',
1131
+ 'Keep intermediate sets SERVER-SIDE instead of round-tripping them through the transcript — the ids never have to be printed, so a long investigation costs a fraction of the tokens.',
1132
+ '- `save_as: "<name>"` (tool parameter) — saves this call\'s `result` under that name after it completes. An `undefined` `result` is NOT saved (it would be indistinguishable from an unsaved name); the response says so.',
1133
+ '- `helpers.save(name, value) -> value` — save mid-script (returns the value, so it composes inline).',
1134
+ '- `helpers.load(name) -> value` — read a saved set back in a later call. Throws with the list of known names if it does not exist.',
1135
+ '- `helpers.listSaved() -> names[]`, or call the tool with `mode:"list_saved"` for names + shapes.',
1136
+ 'Save plain data (ids, counts, strings) — NOT node objects or proxies. Sets are scoped to the current snapshot and dropped when it is unloaded, so a saved id list can never be read against the wrong snapshot.',
1137
+ 'Typical shape: call 1 `save_as:"candidates"` builds the id list; call 2 does `const ids = helpers.load("candidates");` and measures them; call 3 traces only the survivors.',
1138
+ '',
1139
+ '## Traversal budget',
1140
+ 'Every call reports `nodes_visited`. Pass `max_nodes` to bound a `snapshot.nodes.forEach` walk: on overrun the walk aborts and the PARTIAL `result` is returned with a warning rather than failing, so a broad exploratory scan is safe to attempt. A partial result is never saved by `save_as`.',
1141
+ '',
385
1142
  '## IHeapNode API',
386
- '`.id`, `.name`, `.type`, `.self_size`, `.retainedSize` (alias `.retained_size`), `.edge_count`, `.is_detached`, `.numOfReferrers` (alias `.referrer_count`), `.isString`, `.toStringNode()?.stringValue`, `.hasPathEdge`, `.pathEdge`, `.dominatorNode`, `.location` (`script_id`/`line`/`column`).',
1143
+ '`.id`, `.name`, `.type`, `.self_size`, `.edge_count`, `.is_detached`, `.numOfReferrers` (alias `.referrer_count`), `.isString`, `.toStringNode()?.stringValue`, `.hasPathEdge`, `.pathEdge`, `.dominatorNode`, `.location` (`script_id`/`line`/`column`).',
387
1144
  '',
388
- '## Caveat: retained_size',
389
- 'Inside eval, `.retainedSize`/`.retained_size` can read back ~0 for every node on some loads. Counts, property/edge walks, and string values are reliable. For authoritative retained sizes call `helpers.retainedSize(id)` or `helpers.retainedSizes([ids])` (they re-resolve the node on the real snapshot, so you can rank custom analyses by retained size), or use `memlab_largest_objects`, `memlab_class_histogram`, `memlab_pinch_points`, or `memlab_object_shape`.',
1145
+ '## `.retainedSize` THROWS here',
1146
+ '`node.retainedSize` / `node.retained_size` raise inside eval instead of returning a number. They have been observed reading back ~0 for every node on some loads while the same id read via `snapshot.getNodeById(id)` returns the true value — and a silently wrong number ranks a whole analysis wrongly with nothing in the output to say so. Use `helpers.retainedSize(id)` (number), `helpers.retainedSizes([ids])` (a `Record<id, bytes>` OBJECT index it as `sizes[id]` or iterate `Object.values(sizes)`, do not `.map`/`.reduce` it directly), or `helpers.aggregateRetained([ids])` for a dominator-deduped total. `node.self_size` is read straight from the snapshot and is reliable.',
390
1147
  '',
391
1148
  '## IHeapEdge API',
392
1149
  '`.name_or_index`, `.type` (property/element/context/internal/hidden/shortcut), `.toNode`, `.fromNode`.',