@code-yeongyu/senpi-codemode 2026.10.13 → 2026.10.16

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.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,32 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.10.16] - 2026-10-16
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ ### Fixed
24
+
25
+ ### Removed
26
+
27
+ ## [2026.10.15] - 2026-10-15
28
+
29
+ ### Breaking Changes
30
+
31
+ ### Added
32
+
33
+ ### Changed
34
+
35
+ ### Fixed
36
+
37
+ - `display()` of a large object, such as a screenshot result that wraps a multi-MB Buffer, no longer writes megabytes into the session file or breaks the session render. The stored copy is trimmed inside the value instead of dropped: every top-level key is kept, Buffers become a `<Buffer N MB>` summary, long strings, arrays and keys keep a prefix with a visible marker, and the entry stays within the display budget, emoji included. Older sessions holding such entries render a short summary, and an object whose `buffer` holds image bytes now displays as the image with its metadata ([#3320](https://github.com/code-yeongyu/senpi/pull/3320)).
38
+
39
+ ### Removed
40
+
15
41
  ## [2026.10.13] - 2026-10-13
16
42
 
17
43
  ### Breaking Changes
package/README.md CHANGED
@@ -456,7 +456,7 @@ so providers can interpret it independently.
456
456
  ## Detached cells
457
457
 
458
458
  `eval` accepts `on_timeout: "detach"|"error"`. The default is `"detach"` in
459
- interactive TUI, RPC, and app-server sessions; print and JSON one-shot runs
459
+ interactive TUI and RPC sessions; print and JSON one-shot runs
460
460
  default to `"error"` so their result is never silently detached. A detached
461
461
  cell keeps only its own language kernel busy. New same-language cells are admitted
462
462
  into its FIFO queue; calls in other languages continue normally. Queued cells may
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.10.13",
3
+ "version": "2026.10.16",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -33,15 +33,15 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@babel/parser": "8.0.4",
36
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.13",
36
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.16",
37
37
  "quickjs-wasi": "3.6.2",
38
38
  "typebox": "1.3.34"
39
39
  },
40
40
  "peerDependencies": {
41
- "@code-yeongyu/senpi": "2026.10.13"
41
+ "@code-yeongyu/senpi": "2026.10.16"
42
42
  },
43
43
  "devDependencies": {
44
- "@code-yeongyu/senpi": "2026.10.13"
44
+ "@code-yeongyu/senpi": "2026.10.16"
45
45
  },
46
46
  "keywords": [
47
47
  "senpi",
@@ -113,6 +113,38 @@ function isEncodableImage(value) {
113
113
  return !isBinaryData(value) && (typeof value.arrayBuffer === "function" || typeof value.bytes === "function");
114
114
  }
115
115
 
116
+ // { buffer: <bytes>, ...metadata } (e.g. an omowright screenshot): the bytes frame as an image when they
117
+ // carry a known signature; the remaining own fields display as compact JSON text. Non-image bytes or a
118
+ // non-plain wrapper stay on the JSON path. BMP's two-byte 'BM' signature is too weak for the wrapper path
119
+ // (a plain-text buffer can start with 'BM'), so it is only trusted when the header length field is plausible.
120
+ function wrappedBufferImageOps(value) {
121
+ if (Object.getPrototypeOf(value) !== Object.prototype || !("buffer" in value)) return undefined;
122
+ const bytes = bytesOf(value.buffer);
123
+ if (bytes === undefined) return undefined;
124
+ const mimeType = sniffImageMimeType(bytes);
125
+ if (mimeType === undefined) return undefined;
126
+ if (mimeType === "image/bmp" && !isPlausibleBmp(bytes)) return undefined;
127
+ const frameOp = frame(mimeType, Buffer.from(bytes).toString("base64"));
128
+ const meta = {};
129
+ for (const key of Object.keys(value)) {
130
+ if (key !== "buffer") meta[key] = value[key];
131
+ }
132
+ if (Object.keys(meta).length === 0) return [frameOp];
133
+ try {
134
+ return [{ kind: "text", text: JSON.stringify(meta) }, frameOp];
135
+ } catch {
136
+ return [frameOp];
137
+ }
138
+ }
139
+
140
+ function isPlausibleBmp(bytes) {
141
+ // A BMP header is at least 26 bytes (14 file + 12 info), and its little-endian file-size field must
142
+ // not exceed the bytes actually present: text after "BM" declares hundreds of MB and stays JSON.
143
+ if (bytes.length < 26) return false;
144
+ const declared = bytes[2] | (bytes[3] << 8) | (bytes[4] << 16) | (bytes[5] << 24);
145
+ return declared >= 26 && bytes.length >= declared;
146
+ }
147
+
116
148
  function frame(mimeType, dataBase64) {
117
149
  return { kind: "frame", mimeType, dataBase64 };
118
150
  }
@@ -141,7 +173,7 @@ export function resolveDisplayOps(value) {
141
173
  return [frame(payload.mimeType ?? "application/octet-stream", payload.dataBase64)];
142
174
  }
143
175
  if (isEncodableImage(value)) return [{ kind: "encode", value }];
144
- return undefined;
176
+ return wrappedBufferImageOps(value);
145
177
  }
146
178
 
147
179
  export async function encodeDisplayImage(value) {
@@ -0,0 +1,237 @@
1
+ import {
2
+ bufferPlaceholder,
3
+ isRecord,
4
+ MAX_DISPLAY_JSON_BYTES,
5
+ serializedBufferByteLength,
6
+ serializedStringLength,
7
+ } from "./display-json-size.ts";
8
+ import {
9
+ JSON_TREE_MAX_DEPTH_EXPANDED,
10
+ JSON_TREE_MAX_LINES_EXPANDED,
11
+ JSON_TREE_SCALAR_LEN_EXPANDED,
12
+ } from "./json-tree.ts";
13
+
14
+ const STUB = "…";
15
+ const STUB_COST = 3; // JSON.stringify("…")
16
+ const MAX_KEY_CHARS = 64;
17
+
18
+ function serializedLength(value: unknown): number {
19
+ return value === undefined ? 0 : JSON.stringify(value).length;
20
+ }
21
+
22
+ function elisionMarker(elided: number): string {
23
+ return `[…${elided}ch elided…]`;
24
+ }
25
+
26
+ /** keptLength moved back so the prefix never ends on the high half of a surrogate pair. */
27
+ function characterBoundary(value: string, keptLength: number): number {
28
+ const last = value.charCodeAt(keptLength - 1);
29
+ return keptLength > 0 && last >= 0xd800 && last <= 0xdbff ? keptLength - 1 : keptLength;
30
+ }
31
+
32
+ /** The longest prefix of value whose serialized length with its elision marker fits limit, or undefined. */
33
+ function trimmedString(value: string, maxChars: number, limit: number): string | undefined {
34
+ let keptLength = characterBoundary(value, Math.min(maxChars, value.length));
35
+ while (keptLength >= 0) {
36
+ const candidate = `${value.slice(0, keptLength)}${elisionMarker(value.length - keptLength)}`;
37
+ if (serializedStringLength(candidate) <= limit) return candidate;
38
+ if (keptLength === 0) return undefined;
39
+ keptLength = characterBoundary(value, Math.floor(keptLength / 2));
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ function boundString(value: string, limit: number): string {
45
+ if (value.length <= JSON_TREE_SCALAR_LEN_EXPANDED && serializedStringLength(value) <= limit) return value;
46
+ return trimmedString(value, JSON_TREE_SCALAR_LEN_EXPANDED, limit) ?? STUB;
47
+ }
48
+
49
+ /** Keys are display data too: long or escape-heavy keys keep a prefix and stay unique (#3261 review). */
50
+ function fittedKey(key: string, maxChars: number): string {
51
+ return key.length <= maxChars && serializedStringLength(key) <= maxChars + 2
52
+ ? key
53
+ : (trimmedString(key, maxChars, maxChars + 2) ?? STUB);
54
+ }
55
+
56
+ function boundKey(key: string, taken: Set<string>): string {
57
+ let unique = fittedKey(key, MAX_KEY_CHARS);
58
+ for (let suffix = 2; taken.has(unique); suffix += 1) {
59
+ // The suffix fits inside the key limit so every stored key stays within MAX_KEY_CHARS.
60
+ unique = `${fittedKey(key, MAX_KEY_CHARS - String(suffix).length - 1)}#${suffix}`;
61
+ }
62
+ taken.add(unique);
63
+ return unique;
64
+ }
65
+
66
+ /**
67
+ * Serialized length of what boundNode would keep given unlimited room, capped at cap + 1 so a
68
+ * multi-MB value is never walked past what the budget could use.
69
+ */
70
+ function boundedNeed(value: unknown, depth: number, cap: number): number {
71
+ if (value === undefined) return 0;
72
+ if (value === null || typeof value === "boolean" || typeof value === "number") return serializedLength(value);
73
+ if (typeof value === "string") {
74
+ if (value.length > cap || value.length > JSON_TREE_SCALAR_LEN_EXPANDED) return cap + 1;
75
+ return Math.min(cap + 1, serializedStringLength(value));
76
+ }
77
+ const bufferBytes = serializedBufferByteLength(value);
78
+ if (bufferBytes !== undefined) return serializedLength(bufferPlaceholder(bufferBytes));
79
+ if (depth >= JSON_TREE_MAX_DEPTH_EXPANDED) return STUB_COST;
80
+ let total = 2;
81
+ if (Array.isArray(value)) {
82
+ for (const item of value) {
83
+ total += 1 + boundedNeed(item, depth + 1, cap - total);
84
+ if (total > cap) return cap + 1;
85
+ }
86
+ return total;
87
+ }
88
+ if (isRecord(value)) {
89
+ for (const key in value) {
90
+ total += 2 + serializedStringLength(key) + boundedNeed(value[key], depth + 1, cap - total);
91
+ if (total > cap) return cap + 1;
92
+ }
93
+ return total;
94
+ }
95
+ return Math.min(cap + 1, serializedStringLength(String(value)));
96
+ }
97
+
98
+ function boundArray(value: readonly unknown[], depth: number, limit: number): unknown {
99
+ const kept: unknown[] = [];
100
+ let used = 2;
101
+ for (const [index, item] of value.entries()) {
102
+ const marker = `… (${value.length - index} more items)`;
103
+ const markerCost = serializedLength(marker) + (index > 0 ? 1 : 0);
104
+ const isLast = index === value.length - 1;
105
+ const room =
106
+ limit - used - (index > 0 ? 1 : 0) - (isLast ? 0 : serializedLength(`… (${value.length} more items)`) + 1);
107
+ if (index >= JSON_TREE_MAX_LINES_EXPANDED || room < STUB_COST + 8) {
108
+ if (used + markerCost > limit) return kept.length === 0 ? STUB : kept;
109
+ kept.push(marker);
110
+ return kept;
111
+ }
112
+ const child = boundNode(item, depth + 1, room);
113
+ used += (index > 0 ? 1 : 0) + (child === undefined ? 4 : serializedLength(child));
114
+ kept.push(child);
115
+ }
116
+ return kept;
117
+ }
118
+
119
+ function boundRecord(value: Record<string, unknown>, depth: number, limit: number): unknown {
120
+ const taken = new Set<string>();
121
+ const keys = Object.keys(value);
122
+ const shown = keys.slice(0, JSON_TREE_MAX_LINES_EXPANDED).map((key) => ({ key, stored: boundKey(key, taken) }));
123
+ const overflowKey = boundKey("…", taken);
124
+ const stubCost = (entry: { stored: string }) => serializedStringLength(entry.stored) + 2 + STUB_COST;
125
+ const overflowCost = (count: number) =>
126
+ count === 0 ? 0 : serializedStringLength(overflowKey) + 2 + serializedLength(`${count} more keys`);
127
+ // Every shown key is guaranteed at least a stub value; keys that cannot all fit become a visible count.
128
+ let base = 2 + shown.reduce((sum, entry) => sum + stubCost(entry), 0) + overflowCost(keys.length - shown.length);
129
+ while (base > limit && shown.length > 0) {
130
+ const dropped = shown.pop();
131
+ if (dropped) base -= stubCost(dropped);
132
+ base += overflowCost(keys.length - shown.length) - overflowCost(keys.length - shown.length - 1);
133
+ }
134
+ if (base > limit) return STUB;
135
+ // Max-min fair share of the remaining room: small values are kept whole, large ones share what is left.
136
+ let pool = limit - base;
137
+ const needs = shown.map((entry) =>
138
+ Math.max(0, boundedNeed(value[entry.key], depth + 1, pool + STUB_COST) - STUB_COST),
139
+ );
140
+ const order = shown.map((_, index) => index).sort((left, right) => (needs[left] ?? 0) - (needs[right] ?? 0));
141
+ const results = new Map<number, unknown>();
142
+ for (const [position, index] of order.entries()) {
143
+ const entry = shown[index];
144
+ if (!entry) continue;
145
+ const share = Math.floor(pool / (order.length - position));
146
+ const grant = Math.min(needs[index] ?? 0, share);
147
+ const child = boundNode(value[entry.key], depth + 1, STUB_COST + grant);
148
+ pool -= Math.max(0, serializedLength(child) - STUB_COST);
149
+ results.set(index, child);
150
+ }
151
+ const kept: Record<string, unknown> = {};
152
+ for (const [index, entry] of shown.entries()) kept[entry.stored] = results.get(index);
153
+ if (keys.length > shown.length) kept[overflowKey] = `${keys.length - shown.length} more keys`;
154
+ return kept;
155
+ }
156
+
157
+ /**
158
+ * Serialized length of value when bounding would keep it unchanged (no cap, depth, key, or Buffer rule
159
+ * applies), or Infinity otherwise; stops counting past cap so large values are never walked in full.
160
+ */
161
+ function unchangedLength(value: unknown, depth: number, cap: number): number {
162
+ if (value === undefined || value === null || typeof value === "boolean" || typeof value === "number") {
163
+ return value === undefined ? 4 : serializedLength(value);
164
+ }
165
+ if (typeof value === "string") {
166
+ return value.length > JSON_TREE_SCALAR_LEN_EXPANDED || value.length > cap
167
+ ? Number.POSITIVE_INFINITY
168
+ : serializedStringLength(value);
169
+ }
170
+ if (depth >= JSON_TREE_MAX_DEPTH_EXPANDED || serializedBufferByteLength(value) !== undefined) {
171
+ return Number.POSITIVE_INFINITY;
172
+ }
173
+ let total = 2;
174
+ if (Array.isArray(value)) {
175
+ if (value.length > JSON_TREE_MAX_LINES_EXPANDED) return Number.POSITIVE_INFINITY;
176
+ for (const item of value) {
177
+ total += 1 + unchangedLength(item, depth + 1, cap - total);
178
+ if (total > cap) return Number.POSITIVE_INFINITY;
179
+ }
180
+ return total;
181
+ }
182
+ if (isRecord(value)) {
183
+ let count = 0;
184
+ for (const key in value) {
185
+ count += 1;
186
+ if (count > JSON_TREE_MAX_LINES_EXPANDED || key.length > MAX_KEY_CHARS) return Number.POSITIVE_INFINITY;
187
+ const keyLength = serializedStringLength(key);
188
+ if (keyLength > MAX_KEY_CHARS + 2) return Number.POSITIVE_INFINITY;
189
+ total += 2 + keyLength + unchangedLength(value[key], depth + 1, cap - total);
190
+ if (total > cap) return Number.POSITIVE_INFINITY;
191
+ }
192
+ return total;
193
+ }
194
+ return Number.POSITIVE_INFINITY;
195
+ }
196
+
197
+ /** The value bounded so its serialized length is at most max(limit, 3). */
198
+ function boundNode(value: unknown, depth: number, limit: number): unknown {
199
+ if (value === undefined) return value;
200
+ // A value that already fits as it is stays exactly as it is.
201
+ if (unchangedLength(value, depth, limit) <= limit) return value;
202
+ if (value === null || typeof value === "boolean" || typeof value === "number") {
203
+ return serializedLength(value) <= limit ? value : STUB;
204
+ }
205
+ if (typeof value === "string") return boundString(value, limit);
206
+ const bufferBytes = serializedBufferByteLength(value);
207
+ if (bufferBytes !== undefined) {
208
+ const placeholder = bufferPlaceholder(bufferBytes);
209
+ return serializedLength(placeholder) <= limit ? placeholder : STUB;
210
+ }
211
+ if (depth >= JSON_TREE_MAX_DEPTH_EXPANDED || limit < STUB_COST + 2) return STUB;
212
+ if (Array.isArray(value)) return boundArray(value, depth, limit);
213
+ if (isRecord(value)) return boundRecord(value, depth, limit);
214
+ return boundString(String(value), limit);
215
+ }
216
+
217
+ /** Every top-level key with a stub value, or a bare stub when even that cannot fit. */
218
+ function skeleton(value: unknown): unknown {
219
+ if (!isRecord(value)) return STUB;
220
+ const stubbed = boundRecord(
221
+ Object.fromEntries(Object.keys(value).map((key) => [key, STUB])),
222
+ 0,
223
+ MAX_DISPLAY_JSON_BYTES,
224
+ );
225
+ return serializedLength(stubbed) <= MAX_DISPLAY_JSON_BYTES ? stubbed : STUB;
226
+ }
227
+
228
+ /**
229
+ * Bounds one display value for persistence in `details.jsonOutputs`. The value is trimmed inside, never
230
+ * replaced: every shown key keeps at least a stub, serialized Buffers become `<Buffer N>`, long strings,
231
+ * arrays and objects carry a visible `… elided` / `N more items` / `N more keys` marker, keys are
232
+ * trimmed like strings, and the real serialized length is checked last (#3261 review).
233
+ */
234
+ export function sanitizeDisplayJson(value: unknown): unknown {
235
+ const sanitized = boundNode(value, 0, MAX_DISPLAY_JSON_BYTES);
236
+ return serializedLength(sanitized) <= MAX_DISPLAY_JSON_BYTES ? sanitized : skeleton(value);
237
+ }
@@ -0,0 +1,86 @@
1
+ // Display JSON size accounting shared by storage bounding and legacy rendering (#3261).
2
+
3
+ /** Model-facing display text budget; the stored jsonOutputs entry follows the same order of budget. */
4
+ export const MAX_DISPLAY_TEXT_BYTES = 8_000;
5
+ /**
6
+ * Total serialized-size budget for one stored entry. Measured in characters (JS string length), so
7
+ * non-ASCII text can store up to ~3x this many UTF-8 bytes; the bound is what matters (#3261 review).
8
+ */
9
+ export const MAX_DISPLAY_JSON_BYTES = MAX_DISPLAY_TEXT_BYTES;
10
+
11
+ /**
12
+ * Byte length of a `{type:"Buffer",data:[...]}` serialization, or undefined when the shape is not one.
13
+ * Only a bounded prefix of the data array is validated, so a multi-MB legacy dump never gets walked (#3261).
14
+ */
15
+ export function serializedBufferByteLength(value: unknown): number | undefined {
16
+ if (!isRecord(value) || value.type !== "Buffer" || !Array.isArray(value.data)) return undefined;
17
+ if (value.data.length === 0) return 0;
18
+ const prefixLength = Math.min(value.data.length, 1_024);
19
+ for (let index = 0; index < prefixLength; index += 1) {
20
+ const byte = value.data[index];
21
+ if (typeof byte !== "number" || !Number.isInteger(byte) || byte < 0 || byte > 255) return undefined;
22
+ }
23
+ return value.data.length;
24
+ }
25
+
26
+ export function formatByteLength(bytes: number): string {
27
+ if (bytes < 1024) return `${bytes} B`;
28
+ if (bytes < 1024 * 1024) return `${Number((bytes / 1024).toFixed(1))} KB`;
29
+ return `${Number((bytes / (1024 * 1024)).toFixed(1))} MB`;
30
+ }
31
+
32
+ export function bufferPlaceholder(bytes: number): string {
33
+ return `<Buffer ${formatByteLength(bytes)}>`;
34
+ }
35
+
36
+ export function isRecord(value: unknown): value is Record<string, unknown> {
37
+ return value !== null && typeof value === "object" && !Array.isArray(value);
38
+ }
39
+
40
+ /** Length of value's JSON string body after escaping; bounded at 6x plus quotes, never serialized here. */
41
+ export function serializedStringLength(value: string): number {
42
+ let length = 2;
43
+ for (const character of value) {
44
+ const codePoint = character.codePointAt(0) ?? 0;
45
+ // Control characters, quotes, backslashes and lone surrogates escape (up to \uXXXX); everything else
46
+ // costs its UTF-16 length, so a character above U+FFFF counts as the two units JSON.stringify writes.
47
+ const escaped =
48
+ codePoint < 0x20 || character === '"' || character === "\\" || (codePoint >= 0xd800 && codePoint <= 0xdfff);
49
+ length += escaped ? JSON.stringify(character).length : character.length;
50
+ }
51
+ return length;
52
+ }
53
+
54
+ /**
55
+ * Iterative early-exit size estimate for stored entries (legacy sessions carry multi-MB ones); never
56
+ * enumerates more of the value than the budget plus a key.
57
+ */
58
+ export function displayJsonSizeExceeds(value: unknown, budget: number): boolean {
59
+ let size = 0;
60
+ const pending: unknown[] = [value];
61
+ while (pending.length > 0) {
62
+ const node = pending.pop();
63
+ if (node === null || node === undefined) continue;
64
+ if (typeof node === "boolean" || typeof node === "number") {
65
+ size += 8;
66
+ } else if (typeof node === "string") {
67
+ size += node.length;
68
+ } else if (Array.isArray(node)) {
69
+ size += 2;
70
+ for (const child of node) {
71
+ size += 1;
72
+ if (size > budget) return true;
73
+ pending.push(child);
74
+ }
75
+ } else if (isRecord(node)) {
76
+ size += 2;
77
+ for (const key in node) {
78
+ size += key.length + 1;
79
+ if (size > budget) return true;
80
+ pending.push(node[key]);
81
+ }
82
+ }
83
+ if (size > budget) return true;
84
+ }
85
+ return false;
86
+ }
package/src/tool/image.ts CHANGED
@@ -11,6 +11,8 @@ import {
11
11
  TailBuffer,
12
12
  truncateTail,
13
13
  } from "../output/streaming-output.ts";
14
+ import { sanitizeDisplayJson } from "./display-json-bounds.ts";
15
+ import { MAX_DISPLAY_TEXT_BYTES } from "./display-json-size.ts";
14
16
  import { type EvalImageContent, type EvalImageResizer, resizeEvalImage } from "./image-resize.ts";
15
17
 
16
18
  export {
@@ -22,8 +24,6 @@ export {
22
24
  } from "./image-resize.ts";
23
25
  export { marshalToolResult, toolResultIsError } from "./tool-result-marshal.ts";
24
26
 
25
- const MAX_DISPLAY_TEXT_BYTES = 8_000;
26
-
27
27
  // Per-cell display caps: display payloads are retained for the whole result lifetime, so an unbounded
28
28
  // burst pins base64 originals plus resized copies on the session heap (#1695).
29
29
  const MAX_DISPLAY_IMAGES_PER_CELL = 8;
@@ -149,7 +149,7 @@ export class EvalOutputCollector {
149
149
  if (error instanceof SyntaxError) throw new DisplayPayloadError(message.mimeType, error);
150
150
  throw error;
151
151
  }
152
- this.#jsonOutputs.push(value);
152
+ this.#jsonOutputs.push(sanitizeDisplayJson(value));
153
153
  this.#sink.push(`display[${this.#jsonOutputs.length}]:\n${formatDisplayJson(value)}\n`);
154
154
  return;
155
155
  }
@@ -1,5 +1,13 @@
1
1
  import { type AgentToolResult, sanitizeTerminalLabel, type Theme } from "@code-yeongyu/senpi";
2
2
  import {
3
+ bufferPlaceholder,
4
+ displayJsonSizeExceeds,
5
+ formatByteLength,
6
+ MAX_DISPLAY_JSON_BYTES,
7
+ serializedBufferByteLength,
8
+ } from "./display-json-size.ts";
9
+ import {
10
+ formatScalar,
3
11
  JSON_TREE_MAX_DEPTH_COLLAPSED,
4
12
  JSON_TREE_MAX_DEPTH_EXPANDED,
5
13
  JSON_TREE_MAX_LINES_COLLAPSED,
@@ -44,12 +52,70 @@ export function renderJsonOutputs(values: readonly unknown[], environment: Rende
44
52
  lines,
45
53
  renderAllVisualLines(style(environment.theme, "dim", `display[${index + 1}]`), environment.width),
46
54
  );
55
+ appendLines(lines, renderJsonEntryLines(value, environment, depth, lineCap, scalarLen));
56
+ }
57
+ return lines;
58
+ }
59
+ // The real legacy shape stores the screenshot as {buffer:{type:"Buffer",data},...metadata}; the buffer key
60
+ // collapses to a placeholder and the remaining keys render as one scalar summary line each.
61
+ function nestedSerializedBuffer(value: unknown): { readonly key: string } | undefined {
62
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
63
+ const record = value as Record<string, unknown>;
64
+ for (const key of Object.keys(record)) {
65
+ if (serializedBufferByteLength(record[key]) !== undefined) return { key };
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ function renderJsonEntryLines(
71
+ value: unknown,
72
+ environment: RenderEnvironment,
73
+ depth: number,
74
+ lineCap: number,
75
+ scalarLen: number,
76
+ ): string[] {
77
+ const fallback = (text: string): string[] =>
78
+ renderAllVisualLines(style(environment.theme, "dim", text), environment.width);
79
+ try {
80
+ // Sessions from before the storage bound can hold byte-per-number Buffer dumps or oversized entries;
81
+ // those collapse to a bounded placeholder or summary instead of walking the whole value.
82
+ const bufferBytes = serializedBufferByteLength(value);
83
+ if (bufferBytes !== undefined) return fallback(bufferPlaceholder(bufferBytes));
84
+ const nested = nestedSerializedBuffer(value);
85
+ if (nested !== undefined) {
86
+ const record = value as Record<string, unknown>;
87
+ const lines = fallback(
88
+ `${nested.key}: ${bufferPlaceholder(serializedBufferByteLength(record[nested.key]) ?? 0)}`,
89
+ );
90
+ const keys = Object.keys(record).filter((key) => key !== nested.key);
91
+ const shownKeys = keys.slice(0, Math.max(0, lineCap - 1));
92
+ for (const key of shownKeys) {
93
+ appendLines(
94
+ lines,
95
+ renderAllVisualLines(
96
+ style(environment.theme, "dim", ` ${key}: ${formatScalar(record[key], scalarLen)}`),
97
+ environment.width,
98
+ ),
99
+ );
100
+ }
101
+ if (keys.length > shownKeys.length)
102
+ appendLines(lines, fallback(` … ${keys.length - shownKeys.length} more keys`));
103
+ return lines;
104
+ }
105
+ if (displayJsonSizeExceeds(value, MAX_DISPLAY_JSON_BYTES)) {
106
+ return fallback(
107
+ `… oversized display value elided (${formatScalar(value, scalarLen)}, ~${formatByteLength(MAX_DISPLAY_JSON_BYTES)}+ stored)`,
108
+ );
109
+ }
47
110
  const tree = renderJsonTreeLines(value, environment.theme, depth, lineCap, scalarLen);
111
+ const lines: string[] = [];
48
112
  for (const line of tree.lines) appendLines(lines, renderAllVisualLines(line, environment.width));
49
113
  if (tree.truncated)
50
114
  appendLines(lines, renderAllVisualLines(style(environment.theme, "dim", "…"), environment.width));
115
+ return lines;
116
+ } catch {
117
+ return fallback("[display value could not be rendered]");
51
118
  }
52
- return lines;
53
119
  }
54
120
 
55
121
  export function renderDetailedLines(