@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 +26 -0
- package/README.md +1 -1
- package/package.json +4 -4
- package/src/kernels/js/display-image.js +33 -1
- package/src/tool/display-json-bounds.ts +237 -0
- package/src/tool/display-json-size.ts +86 -0
- package/src/tool/image.ts +3 -3
- package/src/tool/render-detail.ts +67 -1
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
|
|
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.
|
|
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.
|
|
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.
|
|
41
|
+
"@code-yeongyu/senpi": "2026.10.16"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
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
|
|
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(
|