@memlab/mcp-server 2.3.0 → 2.52.0

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