@memlab/mcp-server 2.3.0 → 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 (55) hide show
  1. package/README.md +10 -0
  2. package/dist/heap-state.d.ts +7 -0
  3. package/dist/heap-state.d.ts.map +1 -1
  4. package/dist/heap-state.js +11 -0
  5. package/dist/heap-state.js.map +1 -1
  6. package/dist/index.js +3 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/tools/auto-investigate.d.ts.map +1 -1
  9. package/dist/tools/auto-investigate.js +91 -49
  10. package/dist/tools/auto-investigate.js.map +1 -1
  11. package/dist/tools/diff-snapshots.d.ts.map +1 -1
  12. package/dist/tools/diff-snapshots.js +101 -44
  13. package/dist/tools/diff-snapshots.js.map +1 -1
  14. package/dist/tools/eval.js +4 -4
  15. package/dist/tools/eval.js.map +1 -1
  16. package/dist/tools/get-references.d.ts.map +1 -1
  17. package/dist/tools/get-references.js +35 -6
  18. package/dist/tools/get-references.js.map +1 -1
  19. package/dist/tools/get-referrers.d.ts.map +1 -1
  20. package/dist/tools/get-referrers.js +35 -6
  21. package/dist/tools/get-referrers.js.map +1 -1
  22. package/dist/tools/intern-opportunities.d.ts.map +1 -1
  23. package/dist/tools/intern-opportunities.js +630 -49
  24. package/dist/tools/intern-opportunities.js.map +1 -1
  25. package/dist/tools/largest-objects.d.ts.map +1 -1
  26. package/dist/tools/largest-objects.js +37 -14
  27. package/dist/tools/largest-objects.js.map +1 -1
  28. package/dist/tools/load-snapshot.d.ts +60 -0
  29. package/dist/tools/load-snapshot.d.ts.map +1 -1
  30. package/dist/tools/load-snapshot.js +313 -21
  31. package/dist/tools/load-snapshot.js.map +1 -1
  32. package/dist/tools/object-shape.d.ts.map +1 -1
  33. package/dist/tools/object-shape.js +39 -25
  34. package/dist/tools/object-shape.js.map +1 -1
  35. package/dist/tools/quick-diagnosis.d.ts.map +1 -1
  36. package/dist/tools/quick-diagnosis.js +12 -4
  37. package/dist/tools/quick-diagnosis.js.map +1 -1
  38. package/dist/tools/retainer-trace.d.ts.map +1 -1
  39. package/dist/tools/retainer-trace.js +103 -25
  40. package/dist/tools/retainer-trace.js.map +1 -1
  41. package/dist/tools/sequence-analysis.d.ts.map +1 -1
  42. package/dist/tools/sequence-analysis.js +10 -0
  43. package/dist/tools/sequence-analysis.js.map +1 -1
  44. package/dist/tools/sliced-strings.d.ts.map +1 -1
  45. package/dist/tools/sliced-strings.js +22 -4
  46. package/dist/tools/sliced-strings.js.map +1 -1
  47. package/dist/tools/snapshot-header.d.ts +28 -0
  48. package/dist/tools/snapshot-header.d.ts.map +1 -0
  49. package/dist/tools/snapshot-header.js +111 -0
  50. package/dist/tools/snapshot-header.js.map +1 -0
  51. package/dist/utils.d.ts +58 -0
  52. package/dist/utils.d.ts.map +1 -1
  53. package/dist/utils.js +145 -0
  54. package/dist/utils.js.map +1 -1
  55. package/package.json +1 -1
@@ -8,10 +8,149 @@
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
+ // 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
+ }
13
150
  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. ' +
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. ' +
15
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.', {
16
155
  limit: z
17
156
  .number()
@@ -28,14 +167,31 @@ export function registerInternOpportunities(server) {
28
167
  .optional()
29
168
  .default(102400)
30
169
  .describe('Minimum savings in bytes to include a group (default 100 KB)'),
31
- }, async ({ limit, min_copies, min_savings }) => {
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 }) => {
32
176
  try {
33
177
  const snapshot = getSnapshot();
34
178
  const meta = getSnapshotMetadata();
35
179
  const totalSize = meta?.totalSize ?? 0;
36
180
  // Step 1: Build frequency map of duplicated strings
37
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;
38
190
  snapshot.nodes.forEach(node => {
191
+ if (node.name === '(concatenated string)') {
192
+ concatStringCount++;
193
+ concatStringSize += node.self_size;
194
+ }
39
195
  if (node.type !== 'string')
40
196
  return;
41
197
  if (node.name === 'system / SlicedString')
@@ -72,44 +228,153 @@ export function registerInternOpportunities(server) {
72
228
  const node = snapshot.getNodeById(nodeId);
73
229
  if (!node)
74
230
  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;
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;
86
273
  }
87
274
  }
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;
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)}`;
99
306
  }
100
307
  else {
101
- groupDist.set(groupKey, {
102
- sampledCount: 1,
103
- propName,
104
- parentProps,
105
- shapeKey,
106
- parentId: parent.id,
107
- sampleRetained: node.retainedSize,
108
- });
308
+ coRetainedVia = `${fromName} (${ref.type})`;
109
309
  }
110
- samplesProcessed++;
111
310
  break;
112
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++;
113
378
  }
114
379
  if (samplesProcessed === 0)
115
380
  continue;
@@ -133,6 +398,18 @@ export function registerInternOpportunities(server) {
133
398
  }
134
399
  existing.totalCopies += trueCount;
135
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;
136
413
  }
137
414
  else {
138
415
  groupMap.set(groupKey, {
@@ -143,6 +420,10 @@ export function registerInternOpportunities(server) {
143
420
  totalCopies: trueCount,
144
421
  totalSize: trueSize,
145
422
  exampleParentId: dist.parentId,
423
+ groupSamples: dist.sampledCount,
424
+ coRetainedSamples: dist.coRetainedCount,
425
+ coRetainedVia: dist.coRetainedVia,
426
+ arrayElement: dist.arrayElement,
146
427
  });
147
428
  }
148
429
  }
@@ -153,11 +434,20 @@ export function registerInternOpportunities(server) {
153
434
  if (g.totalCopies < min_copies)
154
435
  continue;
155
436
  let savingsIfInterned = 0;
437
+ let savingsCappable = 0;
438
+ let savingsOverCap = 0;
156
439
  const topStrings = [];
157
440
  for (const [value, strStats] of g.strings) {
158
441
  if (strStats.count > 1) {
159
442
  const perCopy = strStats.size / strStats.count;
160
- savingsIfInterned += (strStats.count - 1) * perCopy;
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;
161
451
  }
162
452
  topStrings.push({
163
453
  value,
@@ -168,6 +458,10 @@ export function registerInternOpportunities(server) {
168
458
  if (savingsIfInterned < min_savings)
169
459
  continue;
170
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;
171
465
  groups.push({
172
466
  propertyName: g.propertyName,
173
467
  parentShape: g.parentShapeKey,
@@ -176,14 +470,50 @@ export function registerInternOpportunities(server) {
176
470
  totalCopies: g.totalCopies,
177
471
  totalSize: g.totalSize,
178
472
  savingsIfInterned,
473
+ savingsCappable,
474
+ savingsOverCap,
179
475
  topStrings: topStrings.slice(0, 3),
180
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),
181
482
  });
182
483
  }
183
484
  groups.sort((a, b) => b.savingsIfInterned - a.savingsIfInterned);
184
485
  const shown = groups.slice(0, limit);
185
486
  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.`);
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);
187
517
  }
188
518
  // Detect partial interning patterns (Feedback #3) and, while we're here,
189
519
  // mark which groups' savings are CROSS-load (not capturable by a per-load
@@ -208,31 +538,96 @@ export function registerInternOpportunities(server) {
208
538
  `suggests **${median} independent intern pools** instead of one shared pool. ` +
209
539
  `Consolidating into a single shared pool would save ~${formatBytes(perPoolSavings)}.`);
210
540
  }
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)
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)
219
578
  .reduce((sum, g) => sum + g.savingsIfInterned, 0);
220
- const totalSavings = withinLoadSavings + crossLoadSavings;
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;
221
604
  const pctOf = (n) => totalSize > 0
222
605
  ? ` (${((n / totalSize) * 100).toFixed(1)}% of heap)`
223
606
  : '';
224
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
+ };
225
618
  const headers = [
226
619
  'Property',
227
620
  'Parent Shape',
228
621
  'Unique Strings',
229
622
  'Total Copies',
623
+ 'Dup ×',
624
+ 'Avg len',
230
625
  'Total Size',
231
626
  'Savings',
232
627
  '% Heap',
233
628
  'Example Parent',
234
629
  ];
235
- const rightCols = new Set([2, 3, 4, 5, 6]);
630
+ const rightCols = new Set([2, 3, 4, 5, 6, 7, 8]);
236
631
  const rows = shown.map(g => {
237
632
  const shape = g.parentShape.length > 40
238
633
  ? g.parentShape.slice(0, 37) + '…}'
@@ -240,32 +635,195 @@ export function registerInternOpportunities(server) {
240
635
  const pct = totalSize > 0
241
636
  ? ((g.savingsIfInterned / totalSize) * 100).toFixed(1) + '%'
242
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}`;
243
644
  return [
244
- `.${g.propertyName}`,
645
+ `${label}${g.arrayElement ? ' ▦' : ''}${g.coRetained ? ' ⚠' : ''}${g.crossLoad ? ' ⤫' : ''}${g.frameworkOwned ? ' ▤' : ''}${g.lowRoi ? ' ▽' : ''}`,
245
646
  shape,
246
647
  formatNumber(g.uniqueStrings),
247
648
  formatNumber(g.totalCopies),
649
+ fmtDup(g.totalCopies, g.uniqueStrings),
650
+ avgLen,
248
651
  formatBytes(g.totalSize),
249
652
  formatBytes(g.savingsIfInterned),
250
653
  pct,
251
654
  `@${g.exampleParentId}`,
252
655
  ];
253
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
+ }
254
753
  const headerLines = [
255
754
  `# String Interning Opportunities`,
256
755
  '',
756
+ verdict,
757
+ '',
257
758
  `**Total duplication across top ${shown.length} groups: ${formatBytes(totalSavings)}${pctOfHeap}**`,
258
759
  `- **Within-load (capturable by a per-load/per-request intern pool): ${formatBytes(withinLoadSavings)}${pctOf(withinLoadSavings)}**`,
259
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
+ }
260
774
  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)}**`);
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.`);
262
785
  }
786
+ const coRetainedGroups = shown.filter(g => g.coRetained);
787
+ const hasArrayElement = shown.some(g => g.arrayElement);
263
788
  const lines = [
264
789
  ...headerLines,
265
790
  '',
266
791
  markdownTable(headers, rows, rightCols),
267
792
  '',
268
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
+ }
269
827
  // Show top strings for the top 5 groups
270
828
  for (const g of shown.slice(0, 5)) {
271
829
  lines.push(`### \`.${g.propertyName}\` on \`${g.parentShape.length > 60 ? g.parentShape.slice(0, 57) + '…}' : g.parentShape}\` — ${formatBytes(g.savingsIfInterned)} savings`);
@@ -278,7 +836,30 @@ export function registerInternOpportunities(server) {
278
836
  if (partialInternAlerts.length > 0) {
279
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._', '');
280
838
  }
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');
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
+ }
282
863
  return toolResult(lines.join('\n'));
283
864
  }
284
865
  catch (err) {