@memlab/mcp-server 2.18.1 → 2.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +8 -14
  2. package/bin/.eslintrc.js +26 -0
  3. package/bin/memlab-cli.js +313 -0
  4. package/dist/artifact-classes.d.ts +86 -0
  5. package/dist/artifact-classes.d.ts.map +1 -0
  6. package/dist/artifact-classes.js +261 -0
  7. package/dist/artifact-classes.js.map +1 -0
  8. package/dist/capture-mode.d.ts +60 -0
  9. package/dist/capture-mode.d.ts.map +1 -0
  10. package/dist/capture-mode.js +39 -0
  11. package/dist/capture-mode.js.map +1 -0
  12. package/dist/guardrail.d.ts.map +1 -1
  13. package/dist/guardrail.js +13 -0
  14. package/dist/guardrail.js.map +1 -1
  15. package/dist/heap-state.d.ts +89 -2
  16. package/dist/heap-state.d.ts.map +1 -1
  17. package/dist/heap-state.js +186 -2
  18. package/dist/heap-state.js.map +1 -1
  19. package/dist/index.js +39 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/progress.d.ts +25 -0
  22. package/dist/progress.d.ts.map +1 -0
  23. package/dist/progress.js +46 -0
  24. package/dist/progress.js.map +1 -0
  25. package/dist/result-handles.d.ts +11 -0
  26. package/dist/result-handles.d.ts.map +1 -0
  27. package/dist/result-handles.js +77 -0
  28. package/dist/result-handles.js.map +1 -0
  29. package/dist/string-index.d.ts +55 -0
  30. package/dist/string-index.d.ts.map +1 -0
  31. package/dist/string-index.js +91 -0
  32. package/dist/string-index.js.map +1 -0
  33. package/dist/tool-registry.d.ts +42 -0
  34. package/dist/tool-registry.d.ts.map +1 -0
  35. package/dist/tool-registry.js +20 -0
  36. package/dist/tool-registry.js.map +1 -0
  37. package/dist/tools/analyze-run.d.ts +12 -0
  38. package/dist/tools/analyze-run.d.ts.map +1 -0
  39. package/dist/tools/analyze-run.js +343 -0
  40. package/dist/tools/analyze-run.js.map +1 -0
  41. package/dist/tools/app-heap.d.ts +12 -0
  42. package/dist/tools/app-heap.d.ts.map +1 -0
  43. package/dist/tools/app-heap.js +140 -0
  44. package/dist/tools/app-heap.js.map +1 -0
  45. package/dist/tools/batch.d.ts +12 -0
  46. package/dist/tools/batch.d.ts.map +1 -0
  47. package/dist/tools/batch.js +168 -0
  48. package/dist/tools/batch.js.map +1 -0
  49. package/dist/tools/cache-analysis.d.ts.map +1 -1
  50. package/dist/tools/cache-analysis.js +156 -5
  51. package/dist/tools/cache-analysis.js.map +1 -1
  52. package/dist/tools/class-histogram.d.ts.map +1 -1
  53. package/dist/tools/class-histogram.js +14 -3
  54. package/dist/tools/class-histogram.js.map +1 -1
  55. package/dist/tools/collection-trend.d.ts +55 -0
  56. package/dist/tools/collection-trend.d.ts.map +1 -0
  57. package/dist/tools/collection-trend.js +315 -0
  58. package/dist/tools/collection-trend.js.map +1 -0
  59. package/dist/tools/detached-dom.d.ts +2 -0
  60. package/dist/tools/detached-dom.d.ts.map +1 -1
  61. package/dist/tools/detached-dom.js +291 -20
  62. package/dist/tools/detached-dom.js.map +1 -1
  63. package/dist/tools/dev-artifacts.d.ts +62 -6
  64. package/dist/tools/dev-artifacts.d.ts.map +1 -1
  65. package/dist/tools/dev-artifacts.js +466 -23
  66. package/dist/tools/dev-artifacts.js.map +1 -1
  67. package/dist/tools/dominator-attribution.d.ts +12 -0
  68. package/dist/tools/dominator-attribution.d.ts.map +1 -0
  69. package/dist/tools/dominator-attribution.js +189 -0
  70. package/dist/tools/dominator-attribution.js.map +1 -0
  71. package/dist/tools/dominator-chain.d.ts +12 -0
  72. package/dist/tools/dominator-chain.d.ts.map +1 -0
  73. package/dist/tools/dominator-chain.js +92 -0
  74. package/dist/tools/dominator-chain.js.map +1 -0
  75. package/dist/tools/duplicate-objects.d.ts +12 -0
  76. package/dist/tools/duplicate-objects.d.ts.map +1 -0
  77. package/dist/tools/duplicate-objects.js +172 -0
  78. package/dist/tools/duplicate-objects.js.map +1 -0
  79. package/dist/tools/duplicated-strings.d.ts.map +1 -1
  80. package/dist/tools/duplicated-strings.js +46 -38
  81. package/dist/tools/duplicated-strings.js.map +1 -1
  82. package/dist/tools/eval.d.ts +1 -0
  83. package/dist/tools/eval.d.ts.map +1 -1
  84. package/dist/tools/eval.js +785 -28
  85. package/dist/tools/eval.js.map +1 -1
  86. package/dist/tools/explain-delta.d.ts +12 -0
  87. package/dist/tools/explain-delta.d.ts.map +1 -0
  88. package/dist/tools/explain-delta.js +170 -0
  89. package/dist/tools/explain-delta.js.map +1 -0
  90. package/dist/tools/find-nodes-by-class.d.ts.map +1 -1
  91. package/dist/tools/find-nodes-by-class.js +112 -13
  92. package/dist/tools/find-nodes-by-class.js.map +1 -1
  93. package/dist/tools/finding-index.d.ts +22 -0
  94. package/dist/tools/finding-index.d.ts.map +1 -0
  95. package/dist/tools/finding-index.js +247 -0
  96. package/dist/tools/finding-index.js.map +1 -0
  97. package/dist/tools/for-each.d.ts.map +1 -1
  98. package/dist/tools/for-each.js +15 -3
  99. package/dist/tools/for-each.js.map +1 -1
  100. package/dist/tools/get-referrers.d.ts.map +1 -1
  101. package/dist/tools/get-referrers.js +17 -0
  102. package/dist/tools/get-referrers.js.map +1 -1
  103. package/dist/tools/hunt-report.d.ts +12 -0
  104. package/dist/tools/hunt-report.d.ts.map +1 -0
  105. package/dist/tools/hunt-report.js +186 -0
  106. package/dist/tools/hunt-report.js.map +1 -0
  107. package/dist/tools/hypothesis.d.ts +12 -0
  108. package/dist/tools/hypothesis.d.ts.map +1 -0
  109. package/dist/tools/hypothesis.js +221 -0
  110. package/dist/tools/hypothesis.js.map +1 -0
  111. package/dist/tools/intern-opportunities.d.ts.map +1 -1
  112. package/dist/tools/intern-opportunities.js +112 -47
  113. package/dist/tools/intern-opportunities.js.map +1 -1
  114. package/dist/tools/ladder.d.ts +33 -0
  115. package/dist/tools/ladder.d.ts.map +1 -0
  116. package/dist/tools/ladder.js +266 -0
  117. package/dist/tools/ladder.js.map +1 -0
  118. package/dist/tools/largest-objects.d.ts.map +1 -1
  119. package/dist/tools/largest-objects.js +6 -2
  120. package/dist/tools/largest-objects.js.map +1 -1
  121. package/dist/tools/leak-report.d.ts +12 -0
  122. package/dist/tools/leak-report.d.ts.map +1 -0
  123. package/dist/tools/leak-report.js +256 -0
  124. package/dist/tools/leak-report.js.map +1 -0
  125. package/dist/tools/load-snapshot.d.ts +18 -0
  126. package/dist/tools/load-snapshot.d.ts.map +1 -1
  127. package/dist/tools/load-snapshot.js +211 -13
  128. package/dist/tools/load-snapshot.js.map +1 -1
  129. package/dist/tools/map-entries.d.ts +12 -0
  130. package/dist/tools/map-entries.d.ts.map +1 -0
  131. package/dist/tools/map-entries.js +139 -0
  132. package/dist/tools/map-entries.js.map +1 -0
  133. package/dist/tools/match-object.d.ts +12 -0
  134. package/dist/tools/match-object.d.ts.map +1 -0
  135. package/dist/tools/match-object.js +165 -0
  136. package/dist/tools/match-object.js.map +1 -0
  137. package/dist/tools/object-shape.d.ts.map +1 -1
  138. package/dist/tools/object-shape.js +53 -5
  139. package/dist/tools/object-shape.js.map +1 -1
  140. package/dist/tools/population-diff.d.ts +57 -0
  141. package/dist/tools/population-diff.d.ts.map +1 -0
  142. package/dist/tools/population-diff.js +338 -0
  143. package/dist/tools/population-diff.js.map +1 -0
  144. package/dist/tools/retainer-layers.d.ts +23 -0
  145. package/dist/tools/retainer-layers.d.ts.map +1 -0
  146. package/dist/tools/retainer-layers.js +179 -0
  147. package/dist/tools/retainer-layers.js.map +1 -0
  148. package/dist/tools/retainer-summary.d.ts +1 -0
  149. package/dist/tools/retainer-summary.d.ts.map +1 -1
  150. package/dist/tools/retainer-summary.js +128 -32
  151. package/dist/tools/retainer-summary.js.map +1 -1
  152. package/dist/tools/retainer-trace.d.ts.map +1 -1
  153. package/dist/tools/retainer-trace.js +4 -2
  154. package/dist/tools/retainer-trace.js.map +1 -1
  155. package/dist/tools/search-nodes.d.ts.map +1 -1
  156. package/dist/tools/search-nodes.js +5 -1
  157. package/dist/tools/search-nodes.js.map +1 -1
  158. package/dist/tools/search-strings.d.ts.map +1 -1
  159. package/dist/tools/search-strings.js +53 -7
  160. package/dist/tools/search-strings.js.map +1 -1
  161. package/dist/tools/sequence-analysis.d.ts +49 -0
  162. package/dist/tools/sequence-analysis.d.ts.map +1 -1
  163. package/dist/tools/sequence-analysis.js +266 -86
  164. package/dist/tools/sequence-analysis.js.map +1 -1
  165. package/dist/tools/shape-histogram.d.ts.map +1 -1
  166. package/dist/tools/shape-histogram.js +9 -2
  167. package/dist/tools/shape-histogram.js.map +1 -1
  168. package/dist/tools/string-patterns.d.ts.map +1 -1
  169. package/dist/tools/string-patterns.js +5 -4
  170. package/dist/tools/string-patterns.js.map +1 -1
  171. package/dist/tools/tools-index.d.ts +12 -0
  172. package/dist/tools/tools-index.d.ts.map +1 -0
  173. package/dist/tools/tools-index.js +342 -0
  174. package/dist/tools/tools-index.js.map +1 -0
  175. package/dist/tools/verify-fix.d.ts +23 -0
  176. package/dist/tools/verify-fix.d.ts.map +1 -0
  177. package/dist/tools/verify-fix.js +211 -0
  178. package/dist/tools/verify-fix.js.map +1 -0
  179. package/dist/tools/weakmap-entries.d.ts.map +1 -1
  180. package/dist/tools/weakmap-entries.js +6 -1
  181. package/dist/tools/weakmap-entries.js.map +1 -1
  182. package/dist/utils.d.ts +146 -1
  183. package/dist/utils.d.ts.map +1 -1
  184. package/dist/utils.js +335 -10
  185. package/dist/utils.js.map +1 -1
  186. package/package.json +5 -4
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @format
8
+ * @oncall memory_lab
9
+ */
10
+ import fs from 'fs';
11
+ import vm from 'vm';
12
+ import { z } from 'zod';
13
+ import memlabHeapAnalysis from '@memlab/heap-analysis';
14
+ const { getFullHeapFromFile } = memlabHeapAnalysis;
15
+ import { resolveSnapshotPath } from './load-snapshot.js';
16
+ import { resolveLadderPaths } from './ladder.js';
17
+ import { formatBytes, formatNumber, markdownTable, errorResult, toolResult, } from '../utils.js';
18
+ // Property names used to fingerprint a shape, matching the other shape tools.
19
+ const SHAPE_PROP_CAP = 12;
20
+ // Shapes tracked per rung. Once this many distinct shapes are seen, tracking
21
+ // STOPS and the tail is uncounted (not folded into an "(other)" bucket) — the
22
+ // report says so explicitly. A heap with a million distinct shapes must not turn
23
+ // this into a memory problem of its own.
24
+ const MAX_SHAPES = 2000;
25
+ function shapeKey(node) {
26
+ if (node.edge_count > 1024)
27
+ return `${node.name} (too wide)`;
28
+ const props = [];
29
+ node.forEachReference(edge => {
30
+ if (edge.type === 'property') {
31
+ props.push(String(edge.name_or_index));
32
+ if (props.length >= SHAPE_PROP_CAP)
33
+ return { stop: true };
34
+ }
35
+ });
36
+ props.sort();
37
+ return `${node.name} {${props.join(',')}}`;
38
+ }
39
+ function trendOf(counts) {
40
+ let up = true;
41
+ for (let i = 1; i < counts.length; i++) {
42
+ if (counts[i] <= counts[i - 1])
43
+ up = false;
44
+ }
45
+ const net = counts[counts.length - 1] - counts[0];
46
+ if (up && net > 0)
47
+ return '↑ every step';
48
+ if (net > 0)
49
+ return 'grew net';
50
+ if (net < 0)
51
+ return 'shrank';
52
+ return 'flat';
53
+ }
54
+ export function registerHypothesis(server) {
55
+ server.tool('memlab_hypothesis', 'Test one hypothesis against EVERY rung of a snapshot ladder in a single call: supply a JavaScript predicate over heap nodes and get its match count, total self size, and trend per rung. ' +
56
+ 'This is the "is my theory true across the whole ladder?" tool. Without it, confirming a specific theory — "the growth is Maps with a `_pending` field", "it is closures capturing `chatId`" — meant loading each rung separately and re-running a query by hand, at minutes per rung, which is why theories tended to be checked against one snapshot and generalized. ' +
57
+ 'Set group_by_shape to break matches down by property shape per rung instead of a single count, which answers "WHICH variant of this class is the one accumulating?" — the shape sweep that otherwise required a separate pass. Snapshots are loaded one at a time and released, so a long ladder is memory-safe.', {
58
+ paths: z
59
+ .array(z.string())
60
+ .min(1)
61
+ .describe('Ordered snapshot paths (oldest first). Local paths, manifold:// URLs, bare filenames, or a single ["ladder:<name>"] reference to a ladder saved with memlab_ladder.'),
62
+ predicate: z
63
+ .string()
64
+ .describe('JavaScript expression over `node` returning a boolean, e.g. `node.name === "Map" && node.retainedSize > 10000`, or `node.type === "closure" && node.name.includes("chat")`. Evaluated in a sandbox with no I/O; only the node is exposed. Compiled once and applied per node, so keep it cheap — avoid walking `node.references` for every node in a multi-million-node heap.'),
65
+ group_by_shape: z
66
+ .boolean()
67
+ .optional()
68
+ .default(false)
69
+ .describe('Break matches down by property-shape fingerprint per rung (default false: one count per rung). Use to find which variant of a class is accumulating.'),
70
+ limit: z
71
+ .number()
72
+ .optional()
73
+ .default(15)
74
+ .describe('Maximum shape rows to report when group_by_shape is set.'),
75
+ max_file_size_mb: z
76
+ .number()
77
+ .optional()
78
+ .describe('Per-file size ceiling in MB; see memlab_load_snapshot.'),
79
+ }, async ({ paths, predicate, group_by_shape, limit, max_file_size_mb }) => {
80
+ try {
81
+ let test;
82
+ try {
83
+ // Compiled ONCE, in a context with nothing in it: the predicate gets
84
+ // the node it is called with and no ambient capability.
85
+ test = vm.runInNewContext(`(function(node){ return !!(${predicate}); })`, Object.create(null), { timeout: 1000 });
86
+ }
87
+ catch (e) {
88
+ return errorResult(`Could not compile predicate: ${e instanceof Error ? e.message : String(e)}. It must be a JavaScript EXPRESSION over \`node\` (no statements, no return).`);
89
+ }
90
+ const rungs = [];
91
+ const { paths: rungPaths } = resolveLadderPaths(paths);
92
+ for (const p of rungPaths) {
93
+ const { localPath, fetchedFrom } = resolveSnapshotPath(p);
94
+ if (!fs.existsSync(localPath)) {
95
+ return errorResult(`File not found: ${localPath}`);
96
+ }
97
+ const sizeMB = fs.statSync(localPath).size / (1024 * 1024);
98
+ if (max_file_size_mb != null && sizeMB > max_file_size_mb) {
99
+ return errorResult(`${p} is ${sizeMB.toFixed(0)} MB, over the ${max_file_size_mb} MB limit.`);
100
+ }
101
+ const snapshot = await getFullHeapFromFile(localPath);
102
+ let matched = 0;
103
+ let selfBytes = 0;
104
+ let scanned = 0;
105
+ let predicateError = null;
106
+ // memlab's nodes.forEach has no early exit, so a predicate that throws
107
+ // on the first node would otherwise still walk every remaining node in
108
+ // a multi-million-node heap before the error is reported. Throwing a
109
+ // sentinel out of the callback actually stops the walk.
110
+ class AbortWalk extends Error {
111
+ }
112
+ const shapes = new Map();
113
+ try {
114
+ snapshot.nodes.forEach(node => {
115
+ scanned++;
116
+ let hit = false;
117
+ try {
118
+ hit = test(node);
119
+ }
120
+ catch (e) {
121
+ // Report the predicate's own failure rather than a silent zero:
122
+ // a predicate that throws on the first node would otherwise look
123
+ // exactly like a hypothesis that is simply false.
124
+ predicateError = e instanceof Error ? e.message : String(e);
125
+ throw new AbortWalk();
126
+ }
127
+ if (!hit)
128
+ return;
129
+ matched++;
130
+ selfBytes += node.self_size;
131
+ if (group_by_shape && shapes.size < MAX_SHAPES) {
132
+ const k = shapeKey(node);
133
+ shapes.set(k, (shapes.get(k) ?? 0) + 1);
134
+ }
135
+ });
136
+ }
137
+ catch (e) {
138
+ if (!(e instanceof AbortWalk))
139
+ throw e;
140
+ }
141
+ if (predicateError != null) {
142
+ return errorResult(`Predicate threw while scanning ${p}: ${predicateError}. Guard against missing fields — it is applied to every node in the heap, not just the ones you have in mind.`);
143
+ }
144
+ rungs.push({
145
+ label: fetchedFrom ?? p.replace(/^.*\//, ''),
146
+ matched,
147
+ selfBytes,
148
+ scanned,
149
+ shapes,
150
+ });
151
+ }
152
+ const counts = rungs.map(r => r.matched);
153
+ const net = counts[counts.length - 1] - counts[0];
154
+ const lines = [
155
+ `## Hypothesis across ${rungs.length} snapshot(s)`,
156
+ '',
157
+ `\`${predicate}\``,
158
+ '',
159
+ markdownTable(['#', 'Snapshot', 'Matches', 'Self size', 'Scanned'], rungs.map((r, i) => [
160
+ String(i + 1),
161
+ r.label.length > 38 ? '…' + r.label.slice(-37) : r.label,
162
+ formatNumber(r.matched),
163
+ formatBytes(r.selfBytes),
164
+ formatNumber(r.scanned),
165
+ ]), new Set([2, 3, 4])),
166
+ '',
167
+ ];
168
+ if (rungs.length > 1) {
169
+ lines.push(`**Verdict: ${trendOf(counts)}** — ${net >= 0 ? '+' : '−'}${formatNumber(Math.abs(net))} matches across the ladder (${formatNumber(counts[0])} → ${formatNumber(counts[counts.length - 1])}).`);
170
+ if (rungs.length === 2) {
171
+ lines.push('', '> ⚠️ With 2 rungs "↑ every step" is the same statement as "grew overall" and cannot separate a trend from a single GC-band sample. Add a third rung.');
172
+ }
173
+ }
174
+ else {
175
+ lines.push('_Single snapshot: this is a count, not a trend. Pass an ordered ladder to test whether the hypothesis holds over time._');
176
+ }
177
+ if (counts.every(c => c === 0)) {
178
+ lines.push('', '_Zero matches in every rung. The predicate compiled and ran against every node, so this is a real negative — the hypothesis is false as written, or the property/class is named differently than assumed (check with `memlab_class_histogram`)._');
179
+ }
180
+ if (group_by_shape) {
181
+ const allShapes = new Set();
182
+ for (const r of rungs)
183
+ for (const k of r.shapes.keys())
184
+ allShapes.add(k);
185
+ const shapeRows = [...allShapes]
186
+ .map(k => ({
187
+ shape: k,
188
+ per: rungs.map(r => r.shapes.get(k) ?? 0),
189
+ }))
190
+ .map(x => ({
191
+ ...x,
192
+ net: x.per[x.per.length - 1] - x.per[0],
193
+ }))
194
+ .sort((a, b) => b.net - a.net ||
195
+ b.per[b.per.length - 1] - a.per[a.per.length - 1])
196
+ .slice(0, limit);
197
+ if (shapeRows.length > 0) {
198
+ lines.push('', '### Matches by shape', '', markdownTable([
199
+ 'Shape',
200
+ ...rungs.map((_, i) => `#${i + 1}`),
201
+ 'Δ',
202
+ ...(rungs.length > 1 ? ['Trend'] : []),
203
+ ], shapeRows.map(r => [
204
+ r.shape.length > 56 ? r.shape.slice(0, 53) + '…}' : r.shape,
205
+ ...r.per.map(c => formatNumber(c)),
206
+ `${r.net >= 0 ? '+' : '−'}${formatNumber(Math.abs(r.net))}`,
207
+ ...(rungs.length > 1 ? [trendOf(r.per)] : []),
208
+ ]), new Set(rungs.map((_, i) => i + 1).concat([rungs.length + 1]))));
209
+ if (rungs.some(r => r.shapes.size >= MAX_SHAPES)) {
210
+ lines.push('', `_⚠ Shape tracking capped at ${formatNumber(MAX_SHAPES)} distinct shapes per rung; the tail is not counted. Narrow the predicate._`);
211
+ }
212
+ }
213
+ }
214
+ return toolResult(lines.join('\n'));
215
+ }
216
+ catch (err) {
217
+ return errorResult(err);
218
+ }
219
+ });
220
+ }
221
+ //# sourceMappingURL=hypothesis.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hypothesis.js","sourceRoot":"","sources":["../../src/tools/hypothesis.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"intern-opportunities.d.ts","sourceRoot":"","sources":["../../src/tools/intern-opportunities.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,yCAAyC,CAAC;AAyNvE,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAi3BnE"}
1
+ {"version":3,"file":"intern-opportunities.d.ts","sourceRoot":"","sources":["../../src/tools/intern-opportunities.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,yCAAyC,CAAC;AAmPvE,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAq8BnE"}
@@ -10,6 +10,9 @@
10
10
  import { z } from 'zod';
11
11
  import { getSnapshot, getSnapshotMetadata, getSessionConfig, } from '../heap-state.js';
12
12
  import { formatBytes, formatNumber, markdownTable, errorResult, toolResult, } from '../utils.js';
13
+ import { collectDevRoots, computeReachableWithoutDevRoots, } from './dev-artifacts.js';
14
+ import { buildStringIndex } from '../string-index.js';
15
+ import { classifyNonProductionString, isHarnessToolManifestShape, } from '../artifact-classes.js';
13
16
  // V8 splits a single logical object's storage across several internal backing
14
17
  // structures: a `system / PropertyArray` (named props once the object grows past
15
18
  // the inline slot count), `(object properties)` / `(object elements)` arrays, and
@@ -150,7 +153,7 @@ function isFrameworkOwned(propertyName, parentShape, parentShapeProps) {
150
153
  export function registerInternOpportunities(server) {
151
154
  server.tool('memlab_intern_opportunities', 'Identify string interning opportunities by grouping duplicated strings by the property name and parent object shape that holds them. Shows total savings per (property × shape) combination — the key metric for deciding where to add a string interning pool. Also surfaces ARRAY-ELEMENT / columnar duplication (strings held as elements of a rowsAsArray / string[][] result buffer — a common Nest mysql2/Drizzle shape) as first-class groups keyed by column index and array-owner shape, folded into the within-load headline; these are marked with a filled square and fixed by interning at the array-construction/parse site. The within-load figure is split at the canonical 128-char intern cap into cappable (<=128 chars, what the recommended fix reclaims) vs over-cap (longer strings the cap skips), so the headline matches what a compliant fix actually reclaims. Replaces the manual workflow of: duplicated_strings → retainer_summary → codebase grep. ' +
152
155
  'Retention-aware: flags groups whose duplicated instances are ALSO held by another structure (e.g. a raw array/matrix) as "co-retained" — interning the property there reclaims ~0, so the savings are reported separately and you must dedupe the shared source instead. ' +
153
- 'The headline "within-load capturable" figure also excludes framework/infra-owned strings (HTTP headers, cookies, auth tokens, Next.js URL/cache context) and low-ROI groups (high-cardinality + long strings, where the intern pool costs more than it saves), reporting each in its own bucket; and it flags concatenated-string (rope) buildup, which is accumulation rather than duplication and cannot be interned. ' +
156
+ 'The headline "within-load capturable" figure also excludes framework/infra-owned strings (HTTP headers, cookies, auth tokens, Next.js URL/cache context), measurement-harness content (the CDP/devtools bridge injected to drive the session — an injected bundle can be the single largest "opportunity" in a driven snapshot while reclaiming nothing in production), and low-ROI groups (high-cardinality + long strings, where the intern pool costs more than it saves), reporting each in its own bucket; and it flags concatenated-string (rope) buildup, which is accumulation rather than duplication and cannot be interned. ' +
154
157
  '⚠ Full-heap scan (builds string-duplication groups) — slow and memory-heavy on very large heaps (millions of nodes); raise min_copies / min_savings to bound it.', {
155
158
  limit: z
156
159
  .number()
@@ -177,45 +180,35 @@ export function registerInternOpportunities(server) {
177
180
  const snapshot = getSnapshot();
178
181
  const meta = getSnapshotMetadata();
179
182
  const totalSize = meta?.totalSize ?? 0;
180
- // Step 1: Build frequency map of duplicated strings
181
- const stringMap = new Map();
182
- // Cheap retention-pattern signal (Feedback round 7 §4): a heap dominated
183
- // by `(concatenated string)` (cons/rope) nodes is string ACCUMULATION
184
- // (repeated `+=` / join into a long-lived buffer), NOT value duplication
185
- // interning cannot help. Count them by name in the pass we already do
186
- // (O(1) per node, no value materialization) so we can flag the pattern
187
- // instead of silently reporting tiny interning wins.
188
- let concatStringCount = 0;
189
- let concatStringSize = 0;
190
- snapshot.nodes.forEach(node => {
191
- if (node.name === '(concatenated string)') {
192
- concatStringCount++;
193
- concatStringSize += node.self_size;
194
- }
195
- if (node.type !== 'string')
196
- return;
197
- if (node.name === 'system / SlicedString')
198
- return;
199
- const strNode = node.toStringNode();
200
- if (!strNode)
201
- return;
202
- const value = strNode.stringValue;
203
- const entry = stringMap.get(value);
204
- if (entry) {
205
- entry.count++;
206
- entry.totalSize += node.retainedSize;
207
- if (entry.exampleIds.length < 20) {
208
- entry.exampleIds.push(node.id);
209
- }
210
- }
211
- else {
212
- stringMap.set(value, {
213
- count: 1,
214
- totalSize: node.retainedSize,
215
- exampleIds: [node.id],
216
- });
217
- }
183
+ // Step 1: per-value string duplication index.
184
+ //
185
+ // Shared with memlab_duplicated_strings and cached per snapshot, so the
186
+ // two tools (routinely run back to back) walk the string nodes once
187
+ // between them rather than once each. The dev-root reachability pass is
188
+ // requested here because only this tool needs it: strings retained ONLY
189
+ // through a dev/automation root overwhelmingly the attached
190
+ // inspector's console holding dev-build log records — cannot be
191
+ // reclaimed by an intern pool in production, and counting them inflates
192
+ // the headline saving.
193
+ //
194
+ // `(concatenated string)` (cons/rope) nodes are counted by the same
195
+ // index: a heap dominated by them is string ACCUMULATION (repeated `+=`
196
+ // / join into a long-lived buffer), NOT value duplication, and
197
+ // interning cannot help. Flagging the pattern beats reporting a tiny
198
+ // interning win against it. (Feedback round 7 §4.)
199
+ const devRoots = collectDevRoots(snapshot);
200
+ const productionReachable = devRoots.byId.size > 0
201
+ ? computeReachableWithoutDevRoots(snapshot, devRoots)
202
+ : null;
203
+ const isDevOnlyNode = (node) => productionReachable != null &&
204
+ productionReachable[node.nodeIndex] === 0;
205
+ const stringIndex = buildStringIndex(snapshot, {
206
+ withDevOnly: true,
207
+ isDevOnlyNode,
218
208
  });
209
+ const stringMap = stringIndex.byValue;
210
+ const concatStringCount = stringIndex.concatStringCount;
211
+ const concatStringSize = stringIndex.concatStringSize;
219
212
  // Step 2: For duplicated strings, sample referrers to get property × shape
220
213
  const groupMap = new Map();
221
214
  for (const [value, stats] of stringMap) {
@@ -383,6 +376,8 @@ export function registerInternOpportunities(server) {
383
376
  const scaleFactor = stats.count / samplesProcessed;
384
377
  const trueCount = Math.round(dist.sampledCount * scaleFactor);
385
378
  const trueSize = stats.totalSize * (dist.sampledCount / samplesProcessed);
379
+ const devOnlyShare = stats.count > 0 ? stats.devOnlyCount / stats.count : 0;
380
+ const trueDevOnly = trueCount * devOnlyShare;
386
381
  const existing = groupMap.get(groupKey);
387
382
  if (existing) {
388
383
  const strEntry = existing.strings.get(value);
@@ -398,6 +393,7 @@ export function registerInternOpportunities(server) {
398
393
  }
399
394
  existing.totalCopies += trueCount;
400
395
  existing.totalSize += trueSize;
396
+ existing.devOnlyCopies += trueDevOnly;
401
397
  existing.groupSamples += dist.sampledCount;
402
398
  existing.coRetainedSamples += dist.coRetainedCount;
403
399
  if (!existing.coRetainedVia && dist.coRetainedVia) {
@@ -419,6 +415,7 @@ export function registerInternOpportunities(server) {
419
415
  strings: new Map([[value, { count: trueCount, size: trueSize }]]),
420
416
  totalCopies: trueCount,
421
417
  totalSize: trueSize,
418
+ devOnlyCopies: trueDevOnly,
422
419
  exampleParentId: dist.parentId,
423
420
  groupSamples: dist.sampledCount,
424
421
  coRetainedSamples: dist.coRetainedCount,
@@ -436,6 +433,12 @@ export function registerInternOpportunities(server) {
436
433
  let savingsIfInterned = 0;
437
434
  let savingsCappable = 0;
438
435
  let savingsOverCap = 0;
436
+ // Savings attributable to harness-injected string CONTENT. Compared
437
+ // against savingsIfInterned below rather than tripping on the first
438
+ // match, so one stray bridge string in an otherwise-app group does
439
+ // not relabel the whole group.
440
+ let savingsHarness = 0;
441
+ let harnessWhat;
439
442
  const topStrings = [];
440
443
  for (const [value, strStats] of g.strings) {
441
444
  if (strStats.count > 1) {
@@ -448,6 +451,16 @@ export function registerInternOpportunities(server) {
448
451
  savingsCappable += s;
449
452
  else
450
453
  savingsOverCap += s;
454
+ const nonProd = classifyNonProductionString(value);
455
+ if (nonProd != null) {
456
+ savingsHarness += s;
457
+ if (!harnessWhat) {
458
+ harnessWhat =
459
+ nonProd.kind === 'dev-build'
460
+ ? `${nonProd.what} — DEV build only, absent in production`
461
+ : nonProd.what;
462
+ }
463
+ }
451
464
  }
452
465
  topStrings.push({
453
466
  value,
@@ -462,6 +475,16 @@ export function registerInternOpportunities(server) {
462
475
  // another structure — interning the property alone reclaims ~0.
463
476
  const coRetained = g.coRetainedSamples > 0 &&
464
477
  g.coRetainedSamples >= g.groupSamples * 0.5;
478
+ // Harness by CONTENT (majority of the group's savings sits in
479
+ // bridge-injected strings) or by SHAPE (the parent is one of the
480
+ // bridge's tool-manifest entries, whose long `.description` strings a
481
+ // per-load pool would happily "save"). The shape test is what catches
482
+ // the manifest — its descriptions are ordinary English and no content
483
+ // signature can distinguish them from app copy.
484
+ const harnessShape = isHarnessToolManifestShape(g.parentShapeProps);
485
+ const harness = harnessShape ||
486
+ (savingsIfInterned > 0 &&
487
+ savingsHarness >= savingsIfInterned * 0.5);
465
488
  groups.push({
466
489
  propertyName: g.propertyName,
467
490
  parentShape: g.parentShapeKey,
@@ -479,6 +502,15 @@ export function registerInternOpportunities(server) {
479
502
  coRetainedVia: coRetained ? g.coRetainedVia : undefined,
480
503
  lowRoi: isLowRoiGroup(g.strings.size, g.totalCopies, g.totalSize),
481
504
  frameworkOwned: isFrameworkOwned(g.propertyName, g.parentShapeKey, g.parentShapeProps),
505
+ // Majority-dev-only rather than any-dev-only: a group can legitimately
506
+ // mix a few console-held copies with real app data, and only a group
507
+ // that is essentially all inspector-held should leave the headline.
508
+ devOnly: g.totalCopies > 0 && g.devOnlyCopies / g.totalCopies >= 0.8,
509
+ harness,
510
+ harnessWhat: harness
511
+ ? (harnessWhat ??
512
+ 'automation/devtools bridge tool manifest (.description strings)')
513
+ : undefined,
482
514
  });
483
515
  }
484
516
  groups.sort((a, b) => b.savingsIfInterned - a.savingsIfInterned);
@@ -563,6 +595,15 @@ export function registerInternOpportunities(server) {
563
595
  // is retention-then-ROI; every non-`within` bucket is excluded from the
564
596
  // within-load headline regardless of which one a group lands in.
565
597
  const bucketOf = (g) => {
598
+ // Checked before everything else: harness content is not the
599
+ // application's memory at all — it is the rig injected to observe it —
600
+ // so no other classification of it is meaningful.
601
+ if (g.harness)
602
+ return 'harness';
603
+ // Retention through a dev root means interning reclaims nothing in
604
+ // production, whatever the property is named.
605
+ if (g.devOnly)
606
+ return 'devOnly';
566
607
  if (g.frameworkOwned)
567
608
  return 'framework';
568
609
  if (g.coRetained)
@@ -579,7 +620,9 @@ export function registerInternOpportunities(server) {
579
620
  const coRetainedSavings = sumBucket('coRetained');
580
621
  const crossLoadSavings = sumBucket('crossLoad');
581
622
  const frameworkSavings = sumBucket('framework');
623
+ const devOnlySavings = sumBucket('devOnly');
582
624
  const lowRoiSavings = sumBucket('lowRoi');
625
+ const harnessSavings = sumBucket('harness');
583
626
  const withinLoadSavings = sumBucket('within');
584
627
  // Within the capturable bucket, split by the canonical 128-char cap so the
585
628
  // headline reports what a COMPLIANT fix reclaims, not the raw total that
@@ -600,7 +643,9 @@ export function registerInternOpportunities(server) {
600
643
  crossLoadSavings +
601
644
  coRetainedSavings +
602
645
  frameworkSavings +
603
- lowRoiSavings;
646
+ devOnlySavings +
647
+ lowRoiSavings +
648
+ harnessSavings;
604
649
  const pctOf = (n) => totalSize > 0
605
650
  ? ` (${((n / totalSize) * 100).toFixed(1)}% of heap)`
606
651
  : '';
@@ -642,7 +687,7 @@ export function registerInternOpportunities(server) {
642
687
  // produce a malformed label, so only prefix `.` for named properties.
643
688
  const label = g.arrayElement ? g.propertyName : `.${g.propertyName}`;
644
689
  return [
645
- `${label}${g.arrayElement ? ' ▦' : ''}${g.coRetained ? ' ⚠' : ''}${g.crossLoad ? ' ⤫' : ''}${g.frameworkOwned ? ' ▤' : ''}${g.lowRoi ? ' ▽' : ''}`,
690
+ `${label}${g.arrayElement ? ' ▦' : ''}${g.coRetained ? ' ⚠' : ''}${g.crossLoad ? ' ⤫' : ''}${g.devOnly ? ' 🛠' : ''}${g.harness ? ' 🧪' : ''}${g.frameworkOwned ? ' ▤' : ''}${g.lowRoi ? ' ▽' : ''}`,
646
691
  shape,
647
692
  formatNumber(g.uniqueStrings),
648
693
  formatNumber(g.totalCopies),
@@ -707,18 +752,21 @@ export function registerInternOpportunities(server) {
707
752
  coRetainedSavings >= crossLoadSavings &&
708
753
  coRetainedSavings >= frameworkSavings &&
709
754
  coRetainedSavings >= lowRoiSavings &&
755
+ coRetainedSavings >= harnessSavings &&
710
756
  coRetainedSavings > 0) {
711
757
  verdict = `Verdict: mostly **co-retained** (${formatBytes(coRetainedSavings)}) — interning the property won't help; dedupe at the shared source.`;
712
758
  }
713
759
  else if (crossLoadSavings >= withinLoadSavings &&
714
760
  crossLoadSavings >= frameworkSavings &&
715
761
  crossLoadSavings >= lowRoiSavings &&
762
+ crossLoadSavings >= harnessSavings &&
716
763
  crossLoadSavings > 0) {
717
764
  verdict = `Verdict: mostly **cross-load** (${formatBytes(crossLoadSavings)}) — a per-request pool won't collapse it; needs a shared/module-scope pool or a retention fix.`;
718
765
  }
719
766
  else if (withinLoadSavings > 0 &&
720
767
  withinLoadSavings >= frameworkSavings &&
721
- withinLoadSavings >= lowRoiSavings) {
768
+ withinLoadSavings >= lowRoiSavings &&
769
+ withinLoadSavings >= harnessSavings) {
722
770
  // Lead with the ≤128-char cappable figure — the amount the canonical
723
771
  // intern fix actually reclaims — not the raw within-load total that also
724
772
  // counts over-cap strings the fix skips (feedback §3).
@@ -730,11 +778,15 @@ export function registerInternOpportunities(server) {
730
778
  : '';
731
779
  verdict = `Verdict: **${formatBytes(withinCappable)} cappable** by the canonical ≤${CANONICAL_CAP_CHARS}-char per-load/per-request intern pool at the parse boundary${capNote}.${columnarNote}`;
732
780
  }
733
- else if (frameworkSavings + lowRoiSavings > 0) {
734
- // Framework/low-ROI dominate (each ≥ the within-load figure). Lead with
735
- // them so a small capturable remainder isn't mistaken for the headline,
736
- // but still name that remainder when nonzero so it isn't hidden.
781
+ else if (frameworkSavings + lowRoiSavings + harnessSavings > 0) {
782
+ // Framework/low-ROI/harness dominate (each ≥ the within-load figure).
783
+ // Lead with them so a small capturable remainder isn't mistaken for the
784
+ // headline, but still name that remainder when nonzero so it isn't
785
+ // hidden.
737
786
  const parts = [];
787
+ if (harnessSavings > 0) {
788
+ parts.push('measurement-harness content (the injected automation/devtools bridge — not application memory)');
789
+ }
738
790
  if (frameworkSavings > 0) {
739
791
  parts.push('framework/infra-owned (headers/cookies/tokens)');
740
792
  }
@@ -777,6 +829,17 @@ export function registerInternOpportunities(server) {
777
829
  if (frameworkSavings > 0) {
778
830
  headerLines.push(`- **▤ Framework/infra-owned (HTTP headers, cookies, auth tokens, Next.js URL/cache context — not app data; out of scope for an app-level intern pool): ${formatBytes(frameworkSavings)}${pctOf(frameworkSavings)}**`);
779
831
  }
832
+ if (devOnlySavings > 0) {
833
+ headerLines.push(`- **🛠 Dev/automation-retained (held only via the attached inspector's console or another dev root — reclaims NOTHING in production; do not chase): ${formatBytes(devOnlySavings)}${pctOf(devOnlySavings)}**`);
834
+ }
835
+ if (harnessSavings > 0) {
836
+ const whats = [
837
+ ...new Set(shown
838
+ .filter(g => g.harness && g.harnessWhat)
839
+ .map(g => g.harnessWhat)),
840
+ ];
841
+ headerLines.push(`- **🧪 Measurement-harness content (${whats.join('; ')}) — the CDP/devtools bridge injected into the page to drive this session, NOT application memory; interning it reclaims nothing in production: ${formatBytes(harnessSavings)}${pctOf(harnessSavings)}**`);
842
+ }
780
843
  if (lowRoiSavings > 0) {
781
844
  headerLines.push(`- **▽ Low-ROI (high-cardinality, few repeats + long strings — a per-load pool retains a large unique set for a small collapse; usually skip): ${formatBytes(lowRoiSavings)}${pctOf(lowRoiSavings)}**`);
782
845
  }
@@ -794,9 +857,11 @@ export function registerInternOpportunities(server) {
794
857
  if (coRetainedGroups.length > 0 ||
795
858
  crossLoadSavings > 0 ||
796
859
  frameworkSavings > 0 ||
860
+ devOnlySavings > 0 ||
861
+ harnessSavings > 0 ||
797
862
  lowRoiSavings > 0 ||
798
863
  hasArrayElement) {
799
- lines.push("▦ = array element (columnar / rowsAsArray — intern at the parse site); ⚠ = co-retained (interning won't reclaim); ⤫ = cross-load (high Dup ×, needs shared pool); ▤ = framework/infra-owned (not app data); ▽ = low-ROI (high-cardinality + long; usually skip).", '');
864
+ lines.push("▦ = array element (columnar / rowsAsArray — intern at the parse site); ⚠ = co-retained (interning won't reclaim); ⤫ = cross-load (high Dup ×, needs shared pool); ▤ = framework/infra-owned (not app data); 🛠 = dev/automation-retained (inspector console — reclaims nothing in production); 🧪 = measurement-harness content (injected automation/devtools bridge — not app memory); ▽ = low-ROI (high-cardinality + long; usually skip).", '');
800
865
  }
801
866
  // Triage mode: stop after the headline split + ranked table. Drops the
802
867
  // per-group string lists, partial-interning detail, and the fix recipe