@memlab/mcp-server 2.1.1 → 2.18.1

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 (175) hide show
  1. package/README.md +83 -3
  2. package/dist/analysis-budget.d.ts +71 -0
  3. package/dist/analysis-budget.d.ts.map +1 -0
  4. package/dist/analysis-budget.js +118 -0
  5. package/dist/analysis-budget.js.map +1 -0
  6. package/dist/guardrail.d.ts +24 -0
  7. package/dist/guardrail.d.ts.map +1 -0
  8. package/dist/guardrail.js +65 -0
  9. package/dist/guardrail.js.map +1 -0
  10. package/dist/heap-state.d.ts +53 -1
  11. package/dist/heap-state.d.ts.map +1 -1
  12. package/dist/heap-state.js +153 -10
  13. package/dist/heap-state.js.map +1 -1
  14. package/dist/index.js +38 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/tools/array-group-by.d.ts +12 -0
  17. package/dist/tools/array-group-by.d.ts.map +1 -0
  18. package/dist/tools/array-group-by.js +191 -0
  19. package/dist/tools/array-group-by.js.map +1 -0
  20. package/dist/tools/auto-investigate.d.ts.map +1 -1
  21. package/dist/tools/auto-investigate.js +1056 -52
  22. package/dist/tools/auto-investigate.js.map +1 -1
  23. package/dist/tools/cache-analysis.d.ts.map +1 -1
  24. package/dist/tools/cache-analysis.js +438 -21
  25. package/dist/tools/cache-analysis.js.map +1 -1
  26. package/dist/tools/check-health.d.ts.map +1 -1
  27. package/dist/tools/check-health.js +261 -1
  28. package/dist/tools/check-health.js.map +1 -1
  29. package/dist/tools/class-histogram.d.ts.map +1 -1
  30. package/dist/tools/class-histogram.js +142 -33
  31. package/dist/tools/class-histogram.js.map +1 -1
  32. package/dist/tools/closure-inspection.d.ts.map +1 -1
  33. package/dist/tools/closure-inspection.js +238 -25
  34. package/dist/tools/closure-inspection.js.map +1 -1
  35. package/dist/tools/detached-dom.d.ts.map +1 -1
  36. package/dist/tools/detached-dom.js +25 -3
  37. package/dist/tools/detached-dom.js.map +1 -1
  38. package/dist/tools/dev-artifacts.d.ts +33 -0
  39. package/dist/tools/dev-artifacts.d.ts.map +1 -0
  40. package/dist/tools/dev-artifacts.js +165 -0
  41. package/dist/tools/dev-artifacts.js.map +1 -0
  42. package/dist/tools/diff-snapshots.d.ts.map +1 -1
  43. package/dist/tools/diff-snapshots.js +200 -48
  44. package/dist/tools/diff-snapshots.js.map +1 -1
  45. package/dist/tools/duplicated-strings.d.ts.map +1 -1
  46. package/dist/tools/duplicated-strings.js +74 -12
  47. package/dist/tools/duplicated-strings.js.map +1 -1
  48. package/dist/tools/eval.d.ts.map +1 -1
  49. package/dist/tools/eval.js +189 -10
  50. package/dist/tools/eval.js.map +1 -1
  51. package/dist/tools/event-listener-leaks.d.ts.map +1 -1
  52. package/dist/tools/event-listener-leaks.js +235 -13
  53. package/dist/tools/event-listener-leaks.js.map +1 -1
  54. package/dist/tools/event-registry.d.ts +12 -0
  55. package/dist/tools/event-registry.d.ts.map +1 -0
  56. package/dist/tools/event-registry.js +183 -0
  57. package/dist/tools/event-registry.js.map +1 -0
  58. package/dist/tools/find-by-property.d.ts.map +1 -1
  59. package/dist/tools/find-by-property.js +113 -31
  60. package/dist/tools/find-by-property.js.map +1 -1
  61. package/dist/tools/find-by-shape.d.ts +12 -0
  62. package/dist/tools/find-by-shape.d.ts.map +1 -0
  63. package/dist/tools/find-by-shape.js +178 -0
  64. package/dist/tools/find-by-shape.js.map +1 -0
  65. package/dist/tools/find-nodes-by-class.d.ts.map +1 -1
  66. package/dist/tools/find-nodes-by-class.js +50 -6
  67. package/dist/tools/find-nodes-by-class.js.map +1 -1
  68. package/dist/tools/for-each.d.ts.map +1 -1
  69. package/dist/tools/for-each.js +1 -1
  70. package/dist/tools/for-each.js.map +1 -1
  71. package/dist/tools/get-node.d.ts.map +1 -1
  72. package/dist/tools/get-node.js +6 -0
  73. package/dist/tools/get-node.js.map +1 -1
  74. package/dist/tools/get-references.d.ts.map +1 -1
  75. package/dist/tools/get-references.js +35 -6
  76. package/dist/tools/get-references.js.map +1 -1
  77. package/dist/tools/get-referrers.d.ts.map +1 -1
  78. package/dist/tools/get-referrers.js +35 -6
  79. package/dist/tools/get-referrers.js.map +1 -1
  80. package/dist/tools/get-string.d.ts +12 -0
  81. package/dist/tools/get-string.d.ts.map +1 -0
  82. package/dist/tools/get-string.js +223 -0
  83. package/dist/tools/get-string.js.map +1 -0
  84. package/dist/tools/get-value.d.ts +12 -0
  85. package/dist/tools/get-value.d.ts.map +1 -0
  86. package/dist/tools/get-value.js +152 -0
  87. package/dist/tools/get-value.js.map +1 -0
  88. package/dist/tools/global-variables.d.ts.map +1 -1
  89. package/dist/tools/global-variables.js +54 -34
  90. package/dist/tools/global-variables.js.map +1 -1
  91. package/dist/tools/growth-signals.d.ts +12 -0
  92. package/dist/tools/growth-signals.d.ts.map +1 -0
  93. package/dist/tools/growth-signals.js +211 -0
  94. package/dist/tools/growth-signals.js.map +1 -0
  95. package/dist/tools/intern-opportunities.d.ts +12 -0
  96. package/dist/tools/intern-opportunities.d.ts.map +1 -0
  97. package/dist/tools/intern-opportunities.js +870 -0
  98. package/dist/tools/intern-opportunities.js.map +1 -0
  99. package/dist/tools/largest-objects.d.ts.map +1 -1
  100. package/dist/tools/largest-objects.js +37 -14
  101. package/dist/tools/largest-objects.js.map +1 -1
  102. package/dist/tools/load-snapshot.d.ts +70 -0
  103. package/dist/tools/load-snapshot.d.ts.map +1 -1
  104. package/dist/tools/load-snapshot.js +446 -17
  105. package/dist/tools/load-snapshot.js.map +1 -1
  106. package/dist/tools/object-cost-breakdown.d.ts +12 -0
  107. package/dist/tools/object-cost-breakdown.d.ts.map +1 -0
  108. package/dist/tools/object-cost-breakdown.js +439 -0
  109. package/dist/tools/object-cost-breakdown.js.map +1 -0
  110. package/dist/tools/object-shape.d.ts.map +1 -1
  111. package/dist/tools/object-shape.js +109 -2
  112. package/dist/tools/object-shape.js.map +1 -1
  113. package/dist/tools/pinch-points.d.ts.map +1 -1
  114. package/dist/tools/pinch-points.js +9 -9
  115. package/dist/tools/pinch-points.js.map +1 -1
  116. package/dist/tools/property-distribution.d.ts +12 -0
  117. package/dist/tools/property-distribution.d.ts.map +1 -0
  118. package/dist/tools/property-distribution.js +168 -0
  119. package/dist/tools/property-distribution.js.map +1 -0
  120. package/dist/tools/quick-diagnosis.d.ts.map +1 -1
  121. package/dist/tools/quick-diagnosis.js +65 -20
  122. package/dist/tools/quick-diagnosis.js.map +1 -1
  123. package/dist/tools/retainer-summary.d.ts.map +1 -1
  124. package/dist/tools/retainer-summary.js +90 -5
  125. package/dist/tools/retainer-summary.js.map +1 -1
  126. package/dist/tools/retainer-trace.d.ts.map +1 -1
  127. package/dist/tools/retainer-trace.js +173 -43
  128. package/dist/tools/retainer-trace.js.map +1 -1
  129. package/dist/tools/search-nodes.d.ts.map +1 -1
  130. package/dist/tools/search-nodes.js +10 -3
  131. package/dist/tools/search-nodes.js.map +1 -1
  132. package/dist/tools/search-strings.d.ts +12 -0
  133. package/dist/tools/search-strings.d.ts.map +1 -0
  134. package/dist/tools/search-strings.js +182 -0
  135. package/dist/tools/search-strings.js.map +1 -0
  136. package/dist/tools/sequence-analysis.d.ts +12 -0
  137. package/dist/tools/sequence-analysis.d.ts.map +1 -0
  138. package/dist/tools/sequence-analysis.js +244 -0
  139. package/dist/tools/sequence-analysis.js.map +1 -0
  140. package/dist/tools/server-status.d.ts +12 -0
  141. package/dist/tools/server-status.d.ts.map +1 -0
  142. package/dist/tools/server-status.js +41 -0
  143. package/dist/tools/server-status.js.map +1 -0
  144. package/dist/tools/shape-histogram.d.ts.map +1 -1
  145. package/dist/tools/shape-histogram.js +61 -16
  146. package/dist/tools/shape-histogram.js.map +1 -1
  147. package/dist/tools/sliced-strings.d.ts.map +1 -1
  148. package/dist/tools/sliced-strings.js +38 -15
  149. package/dist/tools/sliced-strings.js.map +1 -1
  150. package/dist/tools/snapshot-header.d.ts +28 -0
  151. package/dist/tools/snapshot-header.d.ts.map +1 -0
  152. package/dist/tools/snapshot-header.js +111 -0
  153. package/dist/tools/snapshot-header.js.map +1 -0
  154. package/dist/tools/snapshot-summary.d.ts.map +1 -1
  155. package/dist/tools/snapshot-summary.js +55 -13
  156. package/dist/tools/snapshot-summary.js.map +1 -1
  157. package/dist/tools/snapshots.d.ts +12 -0
  158. package/dist/tools/snapshots.d.ts.map +1 -0
  159. package/dist/tools/snapshots.js +93 -0
  160. package/dist/tools/snapshots.js.map +1 -0
  161. package/dist/tools/stale-collections.d.ts.map +1 -1
  162. package/dist/tools/stale-collections.js +248 -7
  163. package/dist/tools/stale-collections.js.map +1 -1
  164. package/dist/tools/trace-dominators.d.ts.map +1 -1
  165. package/dist/tools/trace-dominators.js +28 -1
  166. package/dist/tools/trace-dominators.js.map +1 -1
  167. package/dist/tools/weakmap-entries.d.ts +12 -0
  168. package/dist/tools/weakmap-entries.d.ts.map +1 -0
  169. package/dist/tools/weakmap-entries.js +156 -0
  170. package/dist/tools/weakmap-entries.js.map +1 -0
  171. package/dist/utils.d.ts +139 -2
  172. package/dist/utils.d.ts.map +1 -1
  173. package/dist/utils.js +448 -35
  174. package/dist/utils.js.map +1 -1
  175. package/package.json +3 -3
@@ -0,0 +1,870 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @format
8
+ * @oncall memory_lab
9
+ */
10
+ import { z } from 'zod';
11
+ import { getSnapshot, getSnapshotMetadata, getSessionConfig, } from '../heap-state.js';
12
+ import { formatBytes, formatNumber, markdownTable, errorResult, toolResult, } from '../utils.js';
13
+ // V8 splits a single logical object's storage across several internal backing
14
+ // structures: a `system / PropertyArray` (named props once the object grows past
15
+ // the inline slot count), `(object properties)` / `(object elements)` arrays, and
16
+ // `(sliced string)` views. A string reached through ANY of these is still held by
17
+ // ONE logical object via ONE assignment site — interning the property at the parse
18
+ // boundary collapses it (Feedback round 5 §1/§2). Only a referrer that is a
19
+ // genuinely INDEPENDENT user structure (a real Array element, or a property on a
20
+ // different logical object) keeps the per-row instance alive after interning and
21
+ // therefore makes the savings non-capturable. This predicate identifies the
22
+ // former so it is NOT mistaken for the latter.
23
+ function isOwnStorageReferrer(ref) {
24
+ // Hidden/internal edges are V8 wiring of the object's own representation
25
+ // (this covers the "(object properties) (internal)", "(object elements)
26
+ // (internal)", "(sliced string) (internal)", and "Object (internal)" buckets
27
+ // the feedback flagged as ambiguous — all of them are own-storage).
28
+ if (ref.type === 'hidden' || ref.type === 'internal')
29
+ return true;
30
+ const fromName = ref.fromNode.name || '';
31
+ return (fromName === 'system / PropertyArray' ||
32
+ fromName === 'system / SlicedString' ||
33
+ fromName.startsWith('(object properties)') ||
34
+ fromName.startsWith('(object elements)') ||
35
+ fromName.startsWith('(sliced string)'));
36
+ }
37
+ // The canonical parse-boundary intern fix caps at 128 chars (the skill's
38
+ // recommended cap: interning very long, low-cardinality strings costs a large
39
+ // unique-set retention for little collapse). So the "within-load capturable"
40
+ // figure double-counts value that a COMPLIANT fix would skip. Splitting savings
41
+ // at this length lets the headline report cappable (what the 128-char fix
42
+ // reclaims) separately from over-cap (needs an uncapped pool or a different fix)
43
+ // so the reported number matches what the recommended fix actually reclaims
44
+ // (sweep feedback §3).
45
+ const CANONICAL_CAP_CHARS = 128;
46
+ /**
47
+ * Label the array (and, one level up, its owner) that holds a duplicated
48
+ * array-element string, so columnar / rows-as-arrays duplication groups by a
49
+ * meaningful owner shape (e.g. `Query._rows[]`, `Array[][] (columnar rows)`)
50
+ * instead of a bare `Object`. Walks a bounded set of referrers with the
51
+ * non-materializing iterator (never the O(N) `.referrers` getter).
52
+ */
53
+ function arrayOwnerLabel(arrayNode) {
54
+ const refs = [];
55
+ arrayNode.forEachReferrer(ref => {
56
+ refs.push(ref);
57
+ if (refs.length >= 8)
58
+ return { stop: true };
59
+ });
60
+ for (const ref of refs) {
61
+ const fromName = ref.fromNode.name || '';
62
+ if (ref.type === 'property' || ref.type === 'context') {
63
+ const owner = fromName && fromName !== 'Object' ? fromName : 'Object';
64
+ return `${owner}.${String(ref.name_or_index)}[]`;
65
+ }
66
+ if (ref.type === 'element') {
67
+ // An array held as an element of another array = matrix / columnar rows.
68
+ const owner = fromName && fromName !== 'Object' ? fromName : 'Array';
69
+ return `${owner}[][] (columnar rows)`;
70
+ }
71
+ if ((ref.type === 'hidden' || ref.type === 'internal') &&
72
+ fromName &&
73
+ fromName !== 'Object') {
74
+ return `${fromName}[]`;
75
+ }
76
+ }
77
+ return arrayNode.name && arrayNode.name !== 'Object'
78
+ ? `${arrayNode.name}[]`
79
+ : 'Array[]';
80
+ }
81
+ // --- Headline-accuracy heuristics (Feedback round 7) ----------------------
82
+ // Both operate purely on a group's already-aggregated counts / extracted shape
83
+ // props — no heap traversal — so they are O(groups), not O(nodes).
84
+ // Low-ROI: strings barely repeat (copies ÷ unique below the floor) AND are long
85
+ // on average. Interning needs a pool holding the whole unique set for a small
86
+ // collapse — high memory cost, low payoff (e.g. a ~141-char value at 2.2×).
87
+ const LOW_ROI_DUP_FLOOR = 3;
88
+ const LOW_ROI_AVG_BYTES = 128;
89
+ function isLowRoiGroup(uniqueStrings, totalCopies, totalSize) {
90
+ if (uniqueStrings <= 0 || totalCopies <= 0)
91
+ return false;
92
+ const dupFactor = totalCopies / uniqueStrings;
93
+ const avgBytes = totalSize / totalCopies;
94
+ return dupFactor < LOW_ROI_DUP_FLOOR && avgBytes > LOW_ROI_AVG_BYTES;
95
+ }
96
+ // Report a `(concatenated string)` (rope) buildup — string accumulation, not
97
+ // value duplication, which interning cannot help — once this many such nodes are
98
+ // present. Shared by the empty-results path and the main results header so the
99
+ // two call sites cannot drift out of sync.
100
+ const CONCAT_STRING_BUILDUP_FLOOR = 1_000_000;
101
+ // Framework/infra-owned: request headers, cookies, auth tokens, and Next.js
102
+ // URL/cache context. Matched by property name and by tell-tale property names in
103
+ // the parent shape.
104
+ // NOTE: deliberately excludes the generic single-word `via` — though HTTP `Via`
105
+ // is a real header, `via` is a plausible application property name and an exact
106
+ // match would misclassify app data as framework-owned. Real `Via` headers are
107
+ // still caught by the `x-`/`sec-` prefixes and the parent-shape header-bag check.
108
+ // The `.+_oauth_token` / `.+-access-token` alternatives intentionally match any
109
+ // non-empty prefix: a property name ending in those suffixes is auth-token data
110
+ // regardless of prefix. `.+` (not `.*`) keeps bare `_oauth_token` /
111
+ // `-access-token` from matching.
112
+ const FRAMEWORK_PROP_RE = /^(cookie|set-cookie|user-agent|accept-language|accept-encoding|referer|referrer|x-[a-z0-9-]+|sec-[a-z0-9-]+|proxied_to_master|.+_oauth_token|.+-access-token)$/i;
113
+ const FRAMEWORK_SHAPE_NAME_RE = /^(URLContext|IncomingMessage|ServerResponse)$/;
114
+ const FRAMEWORK_SHAPE_PROPS = new Set([
115
+ 'cookie',
116
+ 'user-agent',
117
+ 'accept-language',
118
+ 'x-fb-validated-client-cert',
119
+ 'proxied_to_master',
120
+ 'intern_oauth_token',
121
+ ]);
122
+ function isFrameworkOwned(propertyName, parentShape, parentShapeProps) {
123
+ if (FRAMEWORK_PROP_RE.test(propertyName))
124
+ return true;
125
+ if (FRAMEWORK_SHAPE_NAME_RE.test(parentShape))
126
+ return true;
127
+ // Next.js incremental-cache / route context shape.
128
+ if (parentShapeProps.includes('defaultLocale') &&
129
+ parentShapeProps.includes('incrementalCache')) {
130
+ return true;
131
+ }
132
+ // A request-header bag: two or more header-ish props in the same shape.
133
+ // Deliberately heuristic, with two accepted trade-offs (kept conservative
134
+ // rather than tightened, since tuning needs real heap data):
135
+ // • False negative: `parentShapeProps` is the upstream sample, capped at 12
136
+ // entries and pre-sorted alphabetically, so an anonymous header bag whose
137
+ // 12 alphabetically-first props are not header-ish slips through. The
138
+ // common named shapes are still caught by FRAMEWORK_SHAPE_NAME_RE above.
139
+ // • False positive: an app object that genuinely models ≥2 of these
140
+ // HTTP-specific names (e.g. `cookie` + `user-agent`) is misclassified. The
141
+ // names are HTTP-specific enough that this is rare; `>= 2` (not `>= 1`)
142
+ // guards the most likely single-field collisions.
143
+ let headerish = 0;
144
+ for (const p of parentShapeProps) {
145
+ if (FRAMEWORK_SHAPE_PROPS.has(p))
146
+ headerish++;
147
+ }
148
+ return headerish >= 2;
149
+ }
150
+ export function registerInternOpportunities(server) {
151
+ 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
+ '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. ' +
154
+ '⚠ 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
+ limit: z
156
+ .number()
157
+ .optional()
158
+ .default(15)
159
+ .describe('Maximum number of intern groups to return (default 15)'),
160
+ min_copies: z
161
+ .number()
162
+ .optional()
163
+ .default(100)
164
+ .describe('Minimum total copies across all strings in a group (default 100)'),
165
+ min_savings: z
166
+ .number()
167
+ .optional()
168
+ .default(102400)
169
+ .describe('Minimum savings in bytes to include a group (default 100 KB)'),
170
+ summary_only: z
171
+ .boolean()
172
+ .optional()
173
+ .default(false)
174
+ .describe('Triage mode: return only the headline savings split (within-load / co-retained / cross-load), a one-line verdict, and the ranked group table — dropping the per-group top-strings, the "How to fix" block, and "Next steps". Ideal for screening many snapshots without flooding context.'),
175
+ }, async ({ limit, min_copies, min_savings, summary_only }) => {
176
+ try {
177
+ const snapshot = getSnapshot();
178
+ const meta = getSnapshotMetadata();
179
+ 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
+ }
218
+ });
219
+ // Step 2: For duplicated strings, sample referrers to get property × shape
220
+ const groupMap = new Map();
221
+ for (const [value, stats] of stringMap) {
222
+ if (stats.count < 2)
223
+ continue;
224
+ // Sample referrers to determine which (property, shape) groups hold this string
225
+ const groupDist = new Map();
226
+ let samplesProcessed = 0;
227
+ for (const nodeId of stats.exampleIds) {
228
+ const node = snapshot.getNodeById(nodeId);
229
+ if (!node)
230
+ continue;
231
+ // Gather a bounded set of this instance's referrers once, so we can
232
+ // both (a) pick the primary property/context referrer for grouping
233
+ // and (b) detect co-retention: if the exact same string instance is
234
+ // also held by an INDEPENDENT structure (e.g. a raw array/matrix
235
+ // cell on a different object), interning at the property assignment
236
+ // site frees ~nothing because the other referrer keeps the per-row
237
+ // instance alive. This is the difference between a fix that reclaims
238
+ // the bytes and one that reclaims ~0 (Feedback round 4 §1:
239
+ // retention-aware savings).
240
+ // Collect up to 8 referrers via the streaming iterator, NOT the
241
+ // `node.referrers` getter: that getter materializes a JS array of
242
+ // ALL incoming edges on every access, so a string referenced N times
243
+ // (common low-cardinality values are referenced 1000s of times) pays
244
+ // O(N) per sample. forEachReferrer stops after 8 without building the
245
+ // full array.
246
+ const refs = [];
247
+ node.forEachReferrer(ref => {
248
+ refs.push(ref);
249
+ if (refs.length >= 8)
250
+ return { stop: true };
251
+ });
252
+ // Primary referrer = first property/context edge (assignment site).
253
+ // If there is none, fall back to an array-element referrer so strings
254
+ // held as ARRAY ELEMENTS (columnar / rows-as-arrays — a very common
255
+ // Nest shape: mysql2/Drizzle rowsAsArray SELECT buffers) become a
256
+ // first-class group instead of being silently dropped. Previously the
257
+ // biggest single win of a sweep could hide because it was columnar
258
+ // (sweep feedback §2).
259
+ let primary = null;
260
+ for (const ref of refs) {
261
+ if (ref.type === 'property' || ref.type === 'context') {
262
+ primary = ref;
263
+ break;
264
+ }
265
+ }
266
+ let isArrayElement = false;
267
+ if (!primary) {
268
+ for (const ref of refs) {
269
+ if (ref.type === 'element') {
270
+ primary = ref;
271
+ isArrayElement = true;
272
+ break;
273
+ }
274
+ }
275
+ }
276
+ if (!primary)
277
+ continue;
278
+ const parent = primary.fromNode;
279
+ // For array elements, key the "property" by the column index so a
280
+ // fixed column across rows groups together (e.g. `[3]`); named props
281
+ // key by their name as before.
282
+ const propName = isArrayElement
283
+ ? `[${String(primary.name_or_index)}]`
284
+ : String(primary.name_or_index);
285
+ // Co-retained ONLY if a referrer comes from a genuinely independent
286
+ // structure. A referrer that is the same object's own V8 backing
287
+ // store (PropertyArray / object-elements / sliced-string, or any
288
+ // hidden/internal edge) still collapses under interning, so it must
289
+ // NOT be counted as co-retention — counting it was discounting the
290
+ // biggest real wins by ~2× (Feedback round 5 §1/§2).
291
+ let coRetainedVia;
292
+ for (const ref of refs) {
293
+ if (ref.fromNode.id === parent.id)
294
+ continue;
295
+ if (isOwnStorageReferrer(ref))
296
+ continue;
297
+ const fromName = ref.fromNode.name || 'Object';
298
+ if (ref.type === 'element') {
299
+ coRetainedVia =
300
+ (fromName === 'Object' || fromName === ''
301
+ ? 'Array'
302
+ : fromName) + '[] (array element)';
303
+ }
304
+ else if (ref.type === 'property' || ref.type === 'context') {
305
+ coRetainedVia = `${fromName}.${String(ref.name_or_index)}`;
306
+ }
307
+ else {
308
+ coRetainedVia = `${fromName} (${ref.type})`;
309
+ }
310
+ break;
311
+ }
312
+ // Fingerprint the parent shape from up to 12 property names.
313
+ // CRITICAL: never touch `parent.references` — that getter
314
+ // materializes a JS array of ALL outgoing edges on every access, so
315
+ // on a giant parent (a map/config object, or one with a huge backing
316
+ // store) it is O(edge_count) PER SAMPLE PER duplicated string and
317
+ // wedges the tool (observed: avg 185k, max 504k edges, ~390k of
318
+ // ~978k samples on one real heap). `edge_count` is O(1): skip shape
319
+ // detection for oversized parents (they are not the row-shaped
320
+ // objects we intern — grouping them by node name is enough), and for
321
+ // the rest use the non-materializing `forEachReference` iterator with
322
+ // an absolute visit cap as a backstop.
323
+ const parentProps = [];
324
+ let shapeKey;
325
+ if (isArrayElement) {
326
+ // Arrays carry element edges, not a property shape — label by the
327
+ // array's owner (one level up) so columnar rows group by owner.
328
+ shapeKey = arrayOwnerLabel(parent);
329
+ }
330
+ else {
331
+ const PARENT_EDGE_GUARD = 1024;
332
+ const PARENT_SCAN_CAP = 256;
333
+ if (parent.edge_count <= PARENT_EDGE_GUARD) {
334
+ let scanned = 0;
335
+ parent.forEachReference(edge => {
336
+ if (++scanned > PARENT_SCAN_CAP)
337
+ return { stop: true };
338
+ if (edge.type === 'property') {
339
+ parentProps.push(String(edge.name_or_index));
340
+ if (parentProps.length >= 12)
341
+ return { stop: true };
342
+ }
343
+ });
344
+ }
345
+ parentProps.sort();
346
+ shapeKey =
347
+ parent.name !== 'Object'
348
+ ? parent.name
349
+ : parentProps.length > 0
350
+ ? `{${parentProps.join(',')}}`
351
+ : 'Object';
352
+ }
353
+ const groupKey = `${propName}::${shapeKey}`;
354
+ const dist = groupDist.get(groupKey);
355
+ if (dist) {
356
+ dist.sampledCount++;
357
+ dist.sampleRetained += node.retainedSize;
358
+ if (coRetainedVia) {
359
+ dist.coRetainedCount++;
360
+ if (!dist.coRetainedVia)
361
+ dist.coRetainedVia = coRetainedVia;
362
+ }
363
+ }
364
+ else {
365
+ groupDist.set(groupKey, {
366
+ sampledCount: 1,
367
+ propName,
368
+ parentProps,
369
+ shapeKey,
370
+ parentId: parent.id,
371
+ sampleRetained: node.retainedSize,
372
+ coRetainedCount: coRetainedVia ? 1 : 0,
373
+ coRetainedVia,
374
+ arrayElement: isArrayElement,
375
+ });
376
+ }
377
+ samplesProcessed++;
378
+ }
379
+ if (samplesProcessed === 0)
380
+ continue;
381
+ // Extrapolate true counts from sample using stats.count
382
+ for (const [groupKey, dist] of groupDist) {
383
+ const scaleFactor = stats.count / samplesProcessed;
384
+ const trueCount = Math.round(dist.sampledCount * scaleFactor);
385
+ const trueSize = stats.totalSize * (dist.sampledCount / samplesProcessed);
386
+ const existing = groupMap.get(groupKey);
387
+ if (existing) {
388
+ const strEntry = existing.strings.get(value);
389
+ if (strEntry) {
390
+ strEntry.count += trueCount;
391
+ strEntry.size += trueSize;
392
+ }
393
+ else {
394
+ existing.strings.set(value, {
395
+ count: trueCount,
396
+ size: trueSize,
397
+ });
398
+ }
399
+ existing.totalCopies += trueCount;
400
+ existing.totalSize += trueSize;
401
+ existing.groupSamples += dist.sampledCount;
402
+ existing.coRetainedSamples += dist.coRetainedCount;
403
+ if (!existing.coRetainedVia && dist.coRetainedVia) {
404
+ existing.coRetainedVia = dist.coRetainedVia;
405
+ }
406
+ // OR the flag across samples rather than trusting the first: if a
407
+ // key is ever reached from both an element and a non-element
408
+ // referrer (e.g. a property literally named `[N]`), the group is
409
+ // still treated as columnar so the label/marker/totals stay
410
+ // consistent.
411
+ existing.arrayElement =
412
+ existing.arrayElement || dist.arrayElement;
413
+ }
414
+ else {
415
+ groupMap.set(groupKey, {
416
+ propertyName: dist.propName,
417
+ parentShapeProps: dist.parentProps,
418
+ parentShapeKey: dist.shapeKey,
419
+ strings: new Map([[value, { count: trueCount, size: trueSize }]]),
420
+ totalCopies: trueCount,
421
+ totalSize: trueSize,
422
+ exampleParentId: dist.parentId,
423
+ groupSamples: dist.sampledCount,
424
+ coRetainedSamples: dist.coRetainedCount,
425
+ coRetainedVia: dist.coRetainedVia,
426
+ arrayElement: dist.arrayElement,
427
+ });
428
+ }
429
+ }
430
+ }
431
+ // Step 3: Compute savings and filter
432
+ const groups = [];
433
+ for (const g of groupMap.values()) {
434
+ if (g.totalCopies < min_copies)
435
+ continue;
436
+ let savingsIfInterned = 0;
437
+ let savingsCappable = 0;
438
+ let savingsOverCap = 0;
439
+ const topStrings = [];
440
+ for (const [value, strStats] of g.strings) {
441
+ if (strStats.count > 1) {
442
+ const perCopy = strStats.size / strStats.count;
443
+ const s = (strStats.count - 1) * perCopy;
444
+ savingsIfInterned += s;
445
+ // A ≤128-char value is what the canonical intern fix would cap and
446
+ // reclaim; longer values are skipped by that cap (feedback §3).
447
+ if (value.length <= CANONICAL_CAP_CHARS)
448
+ savingsCappable += s;
449
+ else
450
+ savingsOverCap += s;
451
+ }
452
+ topStrings.push({
453
+ value,
454
+ count: strStats.count,
455
+ size: strStats.size,
456
+ });
457
+ }
458
+ if (savingsIfInterned < min_savings)
459
+ continue;
460
+ topStrings.sort((a, b) => b.size - a.size);
461
+ // Co-retained when the majority of sampled instances are also held by
462
+ // another structure — interning the property alone reclaims ~0.
463
+ const coRetained = g.coRetainedSamples > 0 &&
464
+ g.coRetainedSamples >= g.groupSamples * 0.5;
465
+ groups.push({
466
+ propertyName: g.propertyName,
467
+ parentShape: g.parentShapeKey,
468
+ parentShapeProps: g.parentShapeProps,
469
+ uniqueStrings: g.strings.size,
470
+ totalCopies: g.totalCopies,
471
+ totalSize: g.totalSize,
472
+ savingsIfInterned,
473
+ savingsCappable,
474
+ savingsOverCap,
475
+ topStrings: topStrings.slice(0, 3),
476
+ exampleParentId: g.exampleParentId,
477
+ arrayElement: g.arrayElement,
478
+ coRetained,
479
+ coRetainedVia: coRetained ? g.coRetainedVia : undefined,
480
+ lowRoi: isLowRoiGroup(g.strings.size, g.totalCopies, g.totalSize),
481
+ frameworkOwned: isFrameworkOwned(g.propertyName, g.parentShapeKey, g.parentShapeProps),
482
+ });
483
+ }
484
+ groups.sort((a, b) => b.savingsIfInterned - a.savingsIfInterned);
485
+ const shown = groups.slice(0, limit);
486
+ if (shown.length === 0) {
487
+ // The property×shape grouping above only sees strings held as named
488
+ // OBJECT PROPERTIES. Strings held as ARRAY ELEMENTS (columnar /
489
+ // rows-as-arrays — e.g. a DB driver's string[][] result buffer) never
490
+ // form a property group, so a heap can show millions of duplicated
491
+ // cells yet report zero opportunities here. Cross-check the already-
492
+ // built stringMap (in-memory Map iteration — no extra heap traversal)
493
+ // and surface the heaviest duplicates so the user isn't dead-ended.
494
+ const arrayDupes = [];
495
+ for (const [value, s] of stringMap) {
496
+ if (s.count < min_copies)
497
+ continue;
498
+ const savings = (s.totalSize * (s.count - 1)) / s.count;
499
+ if (savings < min_savings)
500
+ continue;
501
+ arrayDupes.push({ value, count: s.count, savings });
502
+ }
503
+ arrayDupes.sort((a, b) => b.savings - a.savings);
504
+ let msg = `No significant interning opportunities found (min ${formatNumber(min_copies)} copies, min ${formatBytes(min_savings)} savings). Try lowering thresholds. If the heap is instead dominated by a few large strings/objects (not many small duplicates), interning won't help — use memlab_largest_objects or memlab_sliced_strings to investigate blob retention.`;
505
+ if (concatStringCount > CONCAT_STRING_BUILDUP_FLOOR) {
506
+ msg += `\n\n⚠ Concatenated-string buildup: ${formatNumber(concatStringCount)} \`(concatenated string)\` nodes (~${formatBytes(concatStringSize)} self-size). This is string ACCUMULATION (repeated \`+=\` / join into a long-lived buffer), NOT value duplication — interning cannot help. Investigate with memlab_largest_objects / memlab_sliced_strings and trace the retaining structure.`;
507
+ }
508
+ if (arrayDupes.length > 0) {
509
+ const totalDup = arrayDupes.reduce((a, d) => a + d.savings, 0);
510
+ const top = arrayDupes
511
+ .slice(0, 5)
512
+ .map(d => ` • ${JSON.stringify(d.value.length > 40 ? d.value.slice(0, 40) + '…' : d.value)} ×${formatNumber(d.count)} (~${formatBytes(d.savings)})`)
513
+ .join('\n');
514
+ msg += `\n\n⚠ However, ${formatNumber(arrayDupes.length)} value(s) are heavily duplicated (~${formatBytes(totalDup)} total) but were NOT surfaced as property groups above. This is expected when they are held as ARRAY ELEMENTS / columnar rows (e.g. a string[][] query-result buffer) rather than object properties — but it can also happen when their property groups fell below the min_copies / min_savings thresholds. If they are array elements, interning at the array-construction (parse) site collapses them; if they are properties, lower the thresholds to surface the group. Top:\n${top}\nUse memlab_search_strings to locate where each is built and confirm how it is held.`;
515
+ }
516
+ return toolResult(msg);
517
+ }
518
+ // Detect partial interning patterns (Feedback #3) and, while we're here,
519
+ // mark which groups' savings are CROSS-load (not capturable by a per-load
520
+ // intern pool) so the headline can split the two (Feedback round 3 §1c).
521
+ const partialInternAlerts = [];
522
+ for (const g of shown) {
523
+ if (g.uniqueStrings < 10)
524
+ continue;
525
+ const counts = g.topStrings.map(s => s.count);
526
+ if (counts.length < 2)
527
+ continue;
528
+ const median = counts.sort((a, b) => a - b)[Math.floor(counts.length / 2)];
529
+ if (median < 2 || median > 20)
530
+ continue;
531
+ const consistent = counts.every(c => c >= median * 0.5 && c <= median * 2);
532
+ if (!consistent)
533
+ continue;
534
+ g.crossLoad = true;
535
+ const perPoolSavings = g.savingsIfInterned;
536
+ partialInternAlerts.push(`⚠️ **\`.${g.propertyName}\` on \`${g.parentShape.length > 50 ? g.parentShape.slice(0, 47) + '…}' : g.parentShape}\`:** ` +
537
+ `${formatNumber(g.uniqueStrings)} unique strings each duplicated ~${median}× — ` +
538
+ `suggests **${median} independent intern pools** instead of one shared pool. ` +
539
+ `Consolidating into a single shared pool would save ~${formatBytes(perPoolSavings)}.`);
540
+ }
541
+ // Split savings into mutually-exclusive buckets (Feedback round 4 §1 —
542
+ // retention-aware — extended in round 7):
543
+ // • co-retained — the duplicated instances are ALSO held by another
544
+ // structure, so interning the property reclaims ~0; the shared
545
+ // source must be deduped/dropped. Reported separately so the figure
546
+ // is not mistaken for an easy per-property win.
547
+ // • within-load — capturable by a per-load/per-request intern pool.
548
+ // • cross-load — needs a shared/module-scope pool (Feedback round 3 §1c).
549
+ // • framework — header/cookie/token/Next.js-context strings; not app
550
+ // data, so an app-level intern pool should not target them
551
+ // (Feedback round 7 §2).
552
+ // • low-ROI — high-cardinality + long strings whose pool cost dwarfs the
553
+ // collapse; usually skip (Feedback round 7 §1).
554
+ // Each group lands in exactly one bucket (precedence below) so the
555
+ // headline within-load figure reflects only realistic app-data wins.
556
+ // Framework/infra-owned is checked FIRST: "this is not application data"
557
+ // is the most fundamental classification and the most actionable label
558
+ // for the reader (skip it — it isn't yours), so a framework string is
559
+ // always surfaced as framework even when it is ALSO co-retained or
560
+ // duplicated across loads — HTTP request headers are duplicated across
561
+ // requests by nature, so a header would otherwise be miscounted as
562
+ // cross-load and the framework bucket under-counted. The remaining order
563
+ // is retention-then-ROI; every non-`within` bucket is excluded from the
564
+ // within-load headline regardless of which one a group lands in.
565
+ const bucketOf = (g) => {
566
+ if (g.frameworkOwned)
567
+ return 'framework';
568
+ if (g.coRetained)
569
+ return 'coRetained';
570
+ if (g.crossLoad)
571
+ return 'crossLoad';
572
+ if (g.lowRoi)
573
+ return 'lowRoi';
574
+ return 'within';
575
+ };
576
+ const sumBucket = (bucket) => shown
577
+ .filter(g => bucketOf(g) === bucket)
578
+ .reduce((sum, g) => sum + g.savingsIfInterned, 0);
579
+ const coRetainedSavings = sumBucket('coRetained');
580
+ const crossLoadSavings = sumBucket('crossLoad');
581
+ const frameworkSavings = sumBucket('framework');
582
+ const lowRoiSavings = sumBucket('lowRoi');
583
+ const withinLoadSavings = sumBucket('within');
584
+ // Within the capturable bucket, split by the canonical 128-char cap so the
585
+ // headline reports what a COMPLIANT fix reclaims, not the raw total that
586
+ // includes over-cap strings the fix skips (feedback §3). Also surface how
587
+ // much of the capturable win is columnar / array-element duplication so it
588
+ // is no longer buried (feedback §2).
589
+ const withinGroups = shown.filter(g => bucketOf(g) === 'within');
590
+ const withinCappable = withinGroups.reduce((s, g) => s + g.savingsCappable, 0);
591
+ const withinOverCap = withinGroups.reduce((s, g) => s + g.savingsOverCap, 0);
592
+ // Use the cappable portion (not the full savings) so this figure always
593
+ // fits inside the withinCappable headline — otherwise a columnar group
594
+ // with long strings could report "includes N of columnar" where N
595
+ // exceeds the leading cappable number, which reads as a contradiction.
596
+ const withinArrayElementCappable = withinGroups
597
+ .filter(g => g.arrayElement)
598
+ .reduce((s, g) => s + g.savingsCappable, 0);
599
+ const totalSavings = withinLoadSavings +
600
+ crossLoadSavings +
601
+ coRetainedSavings +
602
+ frameworkSavings +
603
+ lowRoiSavings;
604
+ const pctOf = (n) => totalSize > 0
605
+ ? ` (${((n / totalSize) * 100).toFixed(1)}% of heap)`
606
+ : '';
607
+ const pctOfHeap = pctOf(totalSavings);
608
+ // Duplication factor (copies ÷ unique) per group — the single best
609
+ // "is this cross-load?" signal: a value held ~N× by N same-shape objects
610
+ // each from a different call/load won't collapse under a per-request pool
611
+ // (Feedback round 5 §3). Reported as a column so the agent can eyeball it.
612
+ const fmtDup = (copies, unique) => {
613
+ if (unique <= 0)
614
+ return '-';
615
+ const f = copies / unique;
616
+ return f >= 10 ? `${Math.round(f)}×` : `${f.toFixed(1)}×`;
617
+ };
618
+ const headers = [
619
+ 'Property',
620
+ 'Parent Shape',
621
+ 'Unique Strings',
622
+ 'Total Copies',
623
+ 'Dup ×',
624
+ 'Avg len',
625
+ 'Total Size',
626
+ 'Savings',
627
+ '% Heap',
628
+ 'Example Parent',
629
+ ];
630
+ const rightCols = new Set([2, 3, 4, 5, 6, 7, 8]);
631
+ const rows = shown.map(g => {
632
+ const shape = g.parentShape.length > 40
633
+ ? g.parentShape.slice(0, 37) + '…}'
634
+ : g.parentShape;
635
+ const pct = totalSize > 0
636
+ ? ((g.savingsIfInterned / totalSize) * 100).toFixed(1) + '%'
637
+ : '-';
638
+ const avgLen = g.totalCopies > 0
639
+ ? formatBytes(Math.round(g.totalSize / g.totalCopies))
640
+ : '-';
641
+ // Array-element groups already read as `[3]`; a leading dot would
642
+ // produce a malformed label, so only prefix `.` for named properties.
643
+ const label = g.arrayElement ? g.propertyName : `.${g.propertyName}`;
644
+ return [
645
+ `${label}${g.arrayElement ? ' ▦' : ''}${g.coRetained ? ' ⚠' : ''}${g.crossLoad ? ' ⤫' : ''}${g.frameworkOwned ? ' ▤' : ''}${g.lowRoi ? ' ▽' : ''}`,
646
+ shape,
647
+ formatNumber(g.uniqueStrings),
648
+ formatNumber(g.totalCopies),
649
+ fmtDup(g.totalCopies, g.uniqueStrings),
650
+ avgLen,
651
+ formatBytes(g.totalSize),
652
+ formatBytes(g.savingsIfInterned),
653
+ pct,
654
+ `@${g.exampleParentId}`,
655
+ ];
656
+ });
657
+ // Retention/concurrency-bug signature (Feedback round 6 §4): a heap
658
+ // dominated by CROSS-load groups whose copies ÷ unique ≈ 2.0 is two full
659
+ // copies of the same dataset resident at once (a stale+fresh
660
+ // double-buffer / setInterval retention), NOT a value duplicated within a
661
+ // single parse. A per-request intern pool cannot collapse it — the fix is
662
+ // to stop the double retention at the source. Detect it purely from the
663
+ // already-computed group counts (no extra heap traversal) so the verdict
664
+ // can call it out explicitly instead of leaving the agent to infer it.
665
+ const isExactlyTwoX = (g) => {
666
+ if (g.uniqueStrings <= 0)
667
+ return false;
668
+ const f = g.totalCopies / g.uniqueStrings;
669
+ return f >= 1.8 && f <= 2.2;
670
+ };
671
+ // Measure cross-load duplication from the raw `crossLoad` flag, NOT the
672
+ // post-precedence bucket. Framework-owned strings are reclassified out of
673
+ // the `crossLoad` bucket, but a stale+fresh double-buffer of e.g. HTTP
674
+ // headers is a real retention/concurrency bug regardless of who owns the
675
+ // data — and the verdict for it is explicitly "NOT interning". Gating on
676
+ // the bucket sum would let the detector silently stop firing once 2×
677
+ // framework groups leave the `crossLoad` bucket.
678
+ const crossLoadDupSavings = shown
679
+ .filter(g => g.crossLoad && !g.coRetained)
680
+ .reduce((sum, g) => sum + g.savingsIfInterned, 0);
681
+ const twoXCrossLoadSavings = shown
682
+ .filter(g => g.crossLoad && !g.coRetained && isExactlyTwoX(g))
683
+ .reduce((sum, g) => sum + g.savingsIfInterned, 0);
684
+ const retentionBugSuspected = crossLoadDupSavings > 0 &&
685
+ crossLoadDupSavings >= withinLoadSavings &&
686
+ crossLoadDupSavings >= coRetainedSavings &&
687
+ twoXCrossLoadSavings >= crossLoadDupSavings * 0.5;
688
+ // One-line verdict (Feedback round 5 §9): which bucket dominates decides
689
+ // the fix shape, so lead with it before the detail. A suspected
690
+ // retention/concurrency bug is checked FIRST — it is a real memory bug
691
+ // (NOT an interning win) and is framework-independent, so it must surface
692
+ // even when the 2× duplication is framework-owned and bucketed out of
693
+ // `crossLoad`.
694
+ // Every "mostly <bucket>" branch gates on the bucket being ≥ ALL other
695
+ // buckets (including framework/low-ROI), so the headline always names the
696
+ // bucket that actually dominates the heap. Without the framework/low-ROI
697
+ // comparison the cascade is asymmetric: a second-largest co-retained or
698
+ // cross-load bucket would be reported as the headline even when
699
+ // framework/low-ROI dominate (e.g. framework=100MB, crossLoad=10MB). The
700
+ // retention-bug branch is exempt — it is a correctness flag, not a
701
+ // "biggest bucket" claim, so it leads regardless of magnitude.
702
+ let verdict;
703
+ if (retentionBugSuspected) {
704
+ verdict = `Verdict: ⚠ likely **retention/concurrency bug** (NOT interning) — ${formatBytes(twoXCrossLoadSavings)} of cross-load duplication at ~2.0× (copies ÷ unique), i.e. two copies of the same dataset held at once (stale+fresh double-buffer / setInterval). A per-request intern pool will NOT help; fix the double retention at the source.`;
705
+ }
706
+ else if (coRetainedSavings >= withinLoadSavings &&
707
+ coRetainedSavings >= crossLoadSavings &&
708
+ coRetainedSavings >= frameworkSavings &&
709
+ coRetainedSavings >= lowRoiSavings &&
710
+ coRetainedSavings > 0) {
711
+ verdict = `Verdict: mostly **co-retained** (${formatBytes(coRetainedSavings)}) — interning the property won't help; dedupe at the shared source.`;
712
+ }
713
+ else if (crossLoadSavings >= withinLoadSavings &&
714
+ crossLoadSavings >= frameworkSavings &&
715
+ crossLoadSavings >= lowRoiSavings &&
716
+ crossLoadSavings > 0) {
717
+ 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
+ }
719
+ else if (withinLoadSavings > 0 &&
720
+ withinLoadSavings >= frameworkSavings &&
721
+ withinLoadSavings >= lowRoiSavings) {
722
+ // Lead with the ≤128-char cappable figure — the amount the canonical
723
+ // intern fix actually reclaims — not the raw within-load total that also
724
+ // counts over-cap strings the fix skips (feedback §3).
725
+ const capNote = withinOverCap > 0
726
+ ? ` (+${formatBytes(withinOverCap)} in >${CANONICAL_CAP_CHARS}-char strings the 128-char cap skips — needs an uncapped pool or a different fix)`
727
+ : '';
728
+ const columnarNote = withinArrayElementCappable > 0
729
+ ? ` Includes ${formatBytes(withinArrayElementCappable)} of columnar / array-element (rowsAsArray) duplication — intern at the array-construction/parse site.`
730
+ : '';
731
+ 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
+ }
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.
737
+ const parts = [];
738
+ if (frameworkSavings > 0) {
739
+ parts.push('framework/infra-owned (headers/cookies/tokens)');
740
+ }
741
+ if (lowRoiSavings > 0) {
742
+ parts.push('low-ROI (high-cardinality, long strings)');
743
+ }
744
+ const remainder = withinLoadSavings > 0
745
+ ? ` (only ${formatBytes(withinLoadSavings)} is app-capturable by a per-load pool)`
746
+ : '';
747
+ verdict = `Verdict: largely no app-actionable interning${remainder} — the bulk is ${parts.join(' and ')}, which a per-load app intern pool should not target.`;
748
+ }
749
+ else {
750
+ verdict =
751
+ 'Verdict: no clearly-capturable interning savings — if the heap is dominated by a few large strings/objects, use memlab_largest_objects or memlab_sliced_strings to investigate blob retention.';
752
+ }
753
+ const headerLines = [
754
+ `# String Interning Opportunities`,
755
+ '',
756
+ verdict,
757
+ '',
758
+ `**Total duplication across top ${shown.length} groups: ${formatBytes(totalSavings)}${pctOfHeap}**`,
759
+ `- **Within-load (capturable by a per-load/per-request intern pool): ${formatBytes(withinLoadSavings)}${pctOf(withinLoadSavings)}**`,
760
+ ];
761
+ // Split the within-load figure at the canonical 128-char cap so the
762
+ // reported number matches what the recommended fix actually reclaims
763
+ // (feedback §3). Only shown when there's over-cap value to distinguish —
764
+ // otherwise the whole within-load figure is already cappable.
765
+ if (withinOverCap > 0) {
766
+ headerLines.push(` - ≤${CANONICAL_CAP_CHARS}-char cappable (what the canonical 128-char intern fix reclaims): ${formatBytes(withinCappable)}`, ` - >${CANONICAL_CAP_CHARS}-char over-cap (skipped by the 128-char cap — needs an uncapped pool or a different fix): ${formatBytes(withinOverCap)}`);
767
+ }
768
+ if (withinArrayElementCappable > 0) {
769
+ headerLines.push(` - ▦ of which columnar / array-element (rowsAsArray) duplication (cappable): ${formatBytes(withinArrayElementCappable)} — intern at the array-construction/parse site`);
770
+ }
771
+ if (coRetainedSavings > 0) {
772
+ headerLines.push(`- **⚠ Co-retained (interning the property reclaims ~0 — these instances are ALSO held by an independent structure, e.g. a raw array/matrix on another object; dedupe at that shared source or drop it): ${formatBytes(coRetainedSavings)}${pctOf(coRetainedSavings)}**`);
773
+ }
774
+ if (crossLoadSavings > 0) {
775
+ headerLines.push(`- **⤫ Cross-load (NOT capturable by a per-request pool — needs a shared/module-scope pool or a retention/concurrency fix): ${formatBytes(crossLoadSavings)}${pctOf(crossLoadSavings)}**`);
776
+ }
777
+ if (frameworkSavings > 0) {
778
+ 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
+ }
780
+ if (lowRoiSavings > 0) {
781
+ 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
+ }
783
+ if (concatStringCount > CONCAT_STRING_BUILDUP_FLOOR) {
784
+ headerLines.push('', `⚠ **Concatenated-string buildup:** ${formatNumber(concatStringCount)} \`(concatenated string)\` nodes (~${formatBytes(concatStringSize)} self-size) — a rope/accumulation pattern (repeated \`+=\` / join into a long-lived buffer), NOT value duplication. Interning cannot help; investigate with \`memlab_largest_objects\` / \`memlab_sliced_strings\` and trace the retainer.`);
785
+ }
786
+ const coRetainedGroups = shown.filter(g => g.coRetained);
787
+ const hasArrayElement = shown.some(g => g.arrayElement);
788
+ const lines = [
789
+ ...headerLines,
790
+ '',
791
+ markdownTable(headers, rows, rightCols),
792
+ '',
793
+ ];
794
+ if (coRetainedGroups.length > 0 ||
795
+ crossLoadSavings > 0 ||
796
+ frameworkSavings > 0 ||
797
+ lowRoiSavings > 0 ||
798
+ 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).", '');
800
+ }
801
+ // Triage mode: stop after the headline split + ranked table. Drops the
802
+ // per-group string lists, partial-interning detail, and the fix recipe
803
+ // — pure waste when screening many snapshots (Feedback round 5 §9).
804
+ if (summary_only) {
805
+ if (coRetainedGroups.length > 0) {
806
+ lines.push('## ⚠ Co-retained — interning the property will NOT reclaim these', '');
807
+ }
808
+ for (const g of coRetainedGroups) {
809
+ const shape = g.parentShape.length > 50
810
+ ? g.parentShape.slice(0, 47) + '…}'
811
+ : g.parentShape;
812
+ lines.push(`- ⚠ \`.${g.propertyName}\` on \`${shape}\` — ${formatBytes(g.savingsIfInterned)} co-retained via **${g.coRetainedVia ?? 'another structure'}**`);
813
+ }
814
+ return toolResult(lines.join('\n'));
815
+ }
816
+ // Co-retained groups: interning the property frees ~0 (Feedback round 4 §1).
817
+ if (coRetainedGroups.length > 0) {
818
+ lines.push('## ⚠ Co-retained — interning the property will NOT reclaim these', '', 'Each duplicated value below is also referenced by another structure, so deduping it at the property assignment site frees ~nothing — the other referrer keeps every per-row instance alive. Dedupe at the **shared source** instead (e.g. intern the raw array/matrix cells at ingestion, or drop that structure once parsed). Confirm with `memlab_get_referrers` on an example instance.', '');
819
+ for (const g of coRetainedGroups) {
820
+ const shape = g.parentShape.length > 50
821
+ ? g.parentShape.slice(0, 47) + '…}'
822
+ : g.parentShape;
823
+ lines.push(`- \`.${g.propertyName}\` on \`${shape}\` — ${formatBytes(g.savingsIfInterned)}; each instance also retained via **${g.coRetainedVia ?? 'another structure'}**`);
824
+ }
825
+ lines.push('');
826
+ }
827
+ // Show top strings for the top 5 groups
828
+ for (const g of shown.slice(0, 5)) {
829
+ lines.push(`### \`.${g.propertyName}\` on \`${g.parentShape.length > 60 ? g.parentShape.slice(0, 57) + '…}' : g.parentShape}\` — ${formatBytes(g.savingsIfInterned)} savings`);
830
+ for (const s of g.topStrings) {
831
+ const val = s.value.length > 60 ? s.value.slice(0, 60) + '…' : s.value;
832
+ lines.push(`- "${val}" × ${formatNumber(s.count)} copies (${formatBytes(s.size)})`);
833
+ }
834
+ lines.push('');
835
+ }
836
+ if (partialInternAlerts.length > 0) {
837
+ lines.push('## Partial Interning Detected', '', 'Strings that are partially deduplicated — interned within each dataset/call but duplicated across them:', '', ...partialInternAlerts, '', '_This typically happens when `internStrings()` or a dedup function creates a new Map per call instead of sharing one across datasets. Fix: lift the intern pool to module scope or pass it as a parameter._', '');
838
+ }
839
+ // The fix recipe + next steps are "suggestions"; honor the session-level
840
+ // suppress flag so a long sweep doesn't repeat the same boilerplate on
841
+ // every snapshot (Feedback round 5 §9a).
842
+ if (!getSessionConfig().suppressSuggestions) {
843
+ lines.push('---', '', '**How to fix:** Add a string interning pool at the JSON.parse / API response boundary:', '```js', 'const internPool = new Map();', 'function intern(s) { let v = internPool.get(s); if (!v) { internPool.set(s, s); v = s; } return v; }', '// Apply to the property during ingestion:', '// obj.propertyName = intern(obj.propertyName);', '```', '', '**Next steps:**', `- Inspect example parent: \`memlab_object_shape(${shown[0].exampleParentId})\``,
844
+ // find_by_shape needs a property-shape fingerprint; array-element
845
+ // (columnar) top groups have none (parentShapeProps is empty), so the
846
+ // recipe would render an unactionable `properties []`. Emit it only for
847
+ // a named-property top group — the columnar case is covered by the
848
+ // array-element step below.
849
+ ...(!shown[0].arrayElement && shown[0].parentShapeProps.length > 0
850
+ ? [
851
+ `- Find all instances: \`memlab_find_by_shape\` with properties ${JSON.stringify(shown[0].parentShapeProps.slice(0, 5))}`,
852
+ ]
853
+ : []), '- Search codebase for the constructor/factory that creates these objects', ...(hasArrayElement
854
+ ? [
855
+ '- For ▦ array-element (columnar / rowsAsArray) groups: the strings are cells of a result buffer, not object properties. Intern each cell where the rows are built (the DB driver’s rowsAsArray mapping or the parse loop) — e.g. `row[col] = intern(row[col])` per duplicated column — not at a property assignment site.',
856
+ ]
857
+ : []), ...(coRetainedGroups.length > 0
858
+ ? [
859
+ '- For ⚠ co-retained groups: run `memlab_get_referrers` on an example instance to find the shared owner, then dedupe at that source (the property-level pool above will not help).',
860
+ ]
861
+ : []));
862
+ }
863
+ return toolResult(lines.join('\n'));
864
+ }
865
+ catch (err) {
866
+ return errorResult(err);
867
+ }
868
+ });
869
+ }
870
+ //# sourceMappingURL=intern-opportunities.js.map