@code-yeongyu/senpi-codemode 2026.9.28 → 2026.9.29
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 +99 -0
- package/README.md +13 -1
- package/package.json +4 -4
- package/src/bridge/memory-protocol.ts +39 -0
- package/src/bridge/protocol.ts +18 -0
- package/src/bridge/reserved.ts +2 -0
- package/src/config/memory-settings.ts +149 -0
- package/src/config/settings.ts +8 -2
- package/src/extension/session-manager-proxy.ts +6 -0
- package/src/extension/session-manager.ts +27 -25
- package/src/host-sdk.ts +1 -1
- package/src/index.ts +4 -0
- package/src/kernels/jl/kernel.ts +4 -1
- package/src/kernels/js/context-manager.ts +43 -5
- package/src/kernels/js/inline-worker.ts +2 -1
- package/src/kernels/js/interrupt-bounds.ts +8 -2
- package/src/kernels/js/kernel-contract.ts +13 -0
- package/src/kernels/js/local-module-loader.ts +2 -0
- package/src/kernels/js/webview-host.ts +59 -0
- package/src/kernels/js/worker-core.js +32 -3
- package/src/kernels/js/worker-global-sizes.js +123 -0
- package/src/kernels/js/worker-heap.js +59 -0
- package/src/kernels/js/worker-host.ts +2 -1
- package/src/kernels/js/worker-memory.js +94 -0
- package/src/kernels/js/worker-runtime.js +7 -2
- package/src/kernels/js/worker-slot.ts +16 -5
- package/src/kernels/js/worker-startup.ts +1 -0
- package/src/kernels/js/worker-webview-client.js +79 -0
- package/src/kernels/js/worker-webview.js +204 -0
- package/src/kernels/py/kernel-contract.ts +3 -0
- package/src/kernels/py/kernel.ts +15 -2
- package/src/kernels/py/prelude.py +327 -11
- package/src/kernels/py/transport.ts +4 -1
- package/src/kernels/rb/kernel.ts +4 -1
- package/src/kernels/shared/kernel-memory-host.ts +63 -0
- package/src/kernels/shared/kernel-memory.ts +136 -0
- package/src/kernels/shared/subprocess-contract.ts +10 -1
- package/src/kernels/shared/subprocess-kernel.ts +23 -2
- package/src/kernels/shared/subprocess-queue.ts +2 -0
- package/src/output/streaming-output-buffer.ts +229 -12
- package/src/output/streaming-output.ts +2 -2
- package/src/prompt/eval-prompt.ts +1 -1
- package/src/skill/bun-1-4/references/runtime-apis.md +1 -0
- package/src/tool/cell-runtime.ts +52 -10
- package/src/tool/detached-cell-contract.ts +4 -0
- package/src/tool/detached-cell-manager.ts +16 -1
- package/src/tool/detached-cell-notification.ts +10 -1
- package/src/tool/image.ts +6 -2
- package/src/tool/run-eval-cell.ts +19 -17
- package/src/tool/settled-image-spill.ts +108 -0
- package/src/tool/terminal-snapshot-store.ts +82 -10
- package/src/tool/types.ts +10 -0
|
@@ -4,6 +4,7 @@ import type { KernelInterruptHandle } from "../../tool/types.ts";
|
|
|
4
4
|
import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts";
|
|
5
5
|
import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
|
|
6
6
|
import { applySessionEnvironment } from "../session-env.ts";
|
|
7
|
+
import { KernelMemoryHost } from "./kernel-memory-host.ts";
|
|
7
8
|
import type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts";
|
|
8
9
|
import { type SubprocessLike, SubprocessProcess, type SubprocessSpawn, spawnSubprocess } from "./subprocess-process.ts";
|
|
9
10
|
import { SubprocessRunQueue } from "./subprocess-queue.ts";
|
|
@@ -21,13 +22,20 @@ import {
|
|
|
21
22
|
timeoutResult,
|
|
22
23
|
} from "./subprocess-run.ts";
|
|
23
24
|
|
|
24
|
-
export type {
|
|
25
|
+
export type {
|
|
26
|
+
KernelResult,
|
|
27
|
+
KernelRunInput,
|
|
28
|
+
SubprocessKernelMemory,
|
|
29
|
+
SubprocessKernelOptions,
|
|
30
|
+
ToolCallMessage,
|
|
31
|
+
} from "./subprocess-contract.ts";
|
|
25
32
|
export type { SubprocessLike, SubprocessSpawn };
|
|
26
33
|
|
|
27
34
|
export class SubprocessKernel {
|
|
28
35
|
private readonly options: SubprocessKernelOptions;
|
|
29
36
|
private readonly onMessage?: (message: KernelToHostMessage) => void;
|
|
30
37
|
private readonly runs = new SubprocessRunQueue();
|
|
38
|
+
private readonly memory: KernelMemoryHost | null;
|
|
31
39
|
private process: SubprocessProcess | null = null;
|
|
32
40
|
private processReady = false;
|
|
33
41
|
private retirementPromise: Promise<void> | null = null;
|
|
@@ -39,6 +47,8 @@ export class SubprocessKernel {
|
|
|
39
47
|
constructor(options: SubprocessKernelOptions) {
|
|
40
48
|
this.options = options;
|
|
41
49
|
this.onMessage = options.onMessage;
|
|
50
|
+
const memory = options.memory;
|
|
51
|
+
this.memory = memory ? new KernelMemoryHost(memory.language, memory.thresholds, memory) : null;
|
|
42
52
|
this.spawnProcess();
|
|
43
53
|
}
|
|
44
54
|
|
|
@@ -176,6 +186,7 @@ export class SubprocessKernel {
|
|
|
176
186
|
});
|
|
177
187
|
this.process = process;
|
|
178
188
|
this.processReady = false;
|
|
189
|
+
this.memory?.processReplaced();
|
|
179
190
|
try {
|
|
180
191
|
process.send(
|
|
181
192
|
encodeBridgeFrame({ type: "init", sessionId: this.options.sessionId, connection: this.options.connection }),
|
|
@@ -210,7 +221,17 @@ export class SubprocessKernel {
|
|
|
210
221
|
this.failClosed(new KernelStartupError(message.error.message));
|
|
211
222
|
return;
|
|
212
223
|
}
|
|
213
|
-
|
|
224
|
+
const ownResult = message.type === "result" && this.runs.active?.input.cellId === message.cellId;
|
|
225
|
+
const settled = ownResult && this.memory ? this.memory.annotate(message, process.child.pid) : message;
|
|
226
|
+
if (!this.runs.handleMessage(settled, this.onMessage)) return;
|
|
227
|
+
this.pumpRuns();
|
|
228
|
+
this.recycleOverCeilingWhenIdle();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** An over-ceiling interpreter restarts only once no cell is running or queued on it. */
|
|
232
|
+
private recycleOverCeilingWhenIdle(): void {
|
|
233
|
+
const idle = this.runs.active === null && this.runs.snapshot().queuedCellIds.length === 0;
|
|
234
|
+
if (this.memory?.claimRecycle(idle)) void this.restartProcess(this.process);
|
|
214
235
|
}
|
|
215
236
|
|
|
216
237
|
private handleExit(process: SubprocessProcess, code: number | null, signal: NodeJS.Signals | null): void {
|
|
@@ -23,10 +23,47 @@ export function truncateTailBytes(text: string, maxBytes: number): ByteSlice {
|
|
|
23
23
|
return { text: slice.toString("utf8"), bytes: slice.length };
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** UTF-8 width of the code point starting at `index`; a lone surrogate counts 3 bytes, as the encoder writes U+FFFD. */
|
|
27
|
+
function utf8Width(codePoint: number): number {
|
|
28
|
+
if (codePoint < 0x80) return 1;
|
|
29
|
+
if (codePoint < 0x800) return 2;
|
|
30
|
+
if (codePoint < 0x10000) return 3;
|
|
31
|
+
return 4;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The shortest suffix of `text` whose leading whole code points total at least `excess` UTF-8 bytes, i.e. exactly
|
|
36
|
+
* what truncateTailBytes keeps, computed by walking the dropped prefix only. Lone surrogates in the kept text become
|
|
37
|
+
* U+FFFD as they would after a UTF-8 round trip.
|
|
38
|
+
*/
|
|
39
|
+
function dropLeadingBytes(text: string, excess: number): ByteSlice {
|
|
40
|
+
const totalBytes = Buffer.byteLength(text, "utf8");
|
|
41
|
+
let index = 0;
|
|
42
|
+
let dropped = 0;
|
|
43
|
+
while (dropped < excess && index < text.length) {
|
|
44
|
+
const codePoint = text.codePointAt(index) ?? 0;
|
|
45
|
+
dropped += utf8Width(codePoint);
|
|
46
|
+
index += codePoint > 0xffff ? 2 : 1;
|
|
47
|
+
}
|
|
48
|
+
const kept = text.slice(index);
|
|
49
|
+
return { text: kept.isWellFormed() ? kept : kept.toWellFormed(), bytes: totalBytes - dropped };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Trailing byte window over a chunk stream. Appends only queue the chunk and drop whole chunks that fall
|
|
54
|
+
* out of the window, so a streaming append costs time and memory proportional to the chunk (#2262); the
|
|
55
|
+
* exact code-point-aligned trim runs when the window is read, with the same result as truncating the whole
|
|
56
|
+
* stream to `maxBytes` after every append.
|
|
57
|
+
*/
|
|
26
58
|
export class TailBuffer {
|
|
27
59
|
readonly #maxBytes: number;
|
|
28
|
-
#
|
|
60
|
+
#chunks: string[] = [];
|
|
61
|
+
#chunkBytes: number[] = [];
|
|
62
|
+
#head = 0;
|
|
63
|
+
/** Sum of the queued chunks' UTF-8 sizes; exact after #normalize. */
|
|
29
64
|
#bytes = 0;
|
|
65
|
+
/** The stream exceeded the window since the last normalization, so the next read must trim. */
|
|
66
|
+
#overflowed = false;
|
|
30
67
|
|
|
31
68
|
constructor(maxBytes: number) {
|
|
32
69
|
this.#maxBytes = Math.max(0, Math.floor(maxBytes));
|
|
@@ -34,25 +71,205 @@ export class TailBuffer {
|
|
|
34
71
|
|
|
35
72
|
append(text: string): void {
|
|
36
73
|
if (text.length === 0) return;
|
|
37
|
-
if (this.#maxBytes === 0)
|
|
38
|
-
|
|
39
|
-
|
|
74
|
+
if (this.#maxBytes === 0) return;
|
|
75
|
+
const incomingBytes = Buffer.byteLength(text, "utf8");
|
|
76
|
+
if (incomingBytes >= this.#maxBytes) {
|
|
77
|
+
this.#chunks = [text];
|
|
78
|
+
this.#chunkBytes = [incomingBytes];
|
|
79
|
+
this.#head = 0;
|
|
80
|
+
this.#bytes = incomingBytes;
|
|
81
|
+
this.#overflowed = true;
|
|
40
82
|
return;
|
|
41
83
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
84
|
+
this.#chunks.push(text);
|
|
85
|
+
this.#chunkBytes.push(incomingBytes);
|
|
86
|
+
this.#bytes += incomingBytes;
|
|
87
|
+
if (this.#bytes > this.#maxBytes) this.#overflowed = true;
|
|
88
|
+
// Keep a small margin so a surrogate pair split across chunks cannot make the kept window short.
|
|
89
|
+
while (
|
|
90
|
+
this.#head < this.#chunks.length - 1 &&
|
|
91
|
+
this.#bytes - (this.#chunkBytes[this.#head] ?? 0) >= this.#maxBytes + 4
|
|
92
|
+
) {
|
|
93
|
+
this.#bytes -= this.#chunkBytes[this.#head] ?? 0;
|
|
94
|
+
this.#head++;
|
|
95
|
+
}
|
|
96
|
+
if (this.#head > 1024 && this.#head * 2 > this.#chunks.length) {
|
|
97
|
+
this.#chunks = this.#chunks.slice(this.#head);
|
|
98
|
+
this.#chunkBytes = this.#chunkBytes.slice(this.#head);
|
|
99
|
+
this.#head = 0;
|
|
100
|
+
}
|
|
49
101
|
}
|
|
50
102
|
|
|
51
103
|
text(): string {
|
|
52
|
-
return this.#
|
|
104
|
+
return this.#normalize();
|
|
53
105
|
}
|
|
54
106
|
|
|
55
107
|
bytes(): number {
|
|
108
|
+
this.#normalize();
|
|
56
109
|
return this.#bytes;
|
|
57
110
|
}
|
|
111
|
+
|
|
112
|
+
#normalize(): string {
|
|
113
|
+
if (this.#maxBytes === 0) return "";
|
|
114
|
+
const pending = this.#chunks.length - this.#head;
|
|
115
|
+
if (pending === 0) return "";
|
|
116
|
+
const joined = pending === 1 ? (this.#chunks[this.#head] ?? "") : this.#chunks.slice(this.#head).join("");
|
|
117
|
+
const total = Buffer.byteLength(joined, "utf8");
|
|
118
|
+
const kept = this.#overflowed
|
|
119
|
+
? dropLeadingBytes(joined, Math.max(0, total - this.#maxBytes))
|
|
120
|
+
: { text: joined, bytes: total };
|
|
121
|
+
this.#chunks = [kept.text];
|
|
122
|
+
this.#chunkBytes = [kept.bytes];
|
|
123
|
+
this.#head = 0;
|
|
124
|
+
this.#bytes = kept.bytes;
|
|
125
|
+
this.#overflowed = false;
|
|
126
|
+
return kept.text;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface TailLineRingOptions {
|
|
131
|
+
readonly maxBytes: number;
|
|
132
|
+
readonly maxLines: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Rolling live-output window over a chunk stream: `text()` returns the last
|
|
137
|
+
* `maxLines` lines of the trailing `maxBytes` byte window, byte-identical to
|
|
138
|
+
* truncating the whole stream to `maxBytes` bytes and keeping its final lines,
|
|
139
|
+
* while `append` costs time proportional to the chunk (#2262).
|
|
140
|
+
*
|
|
141
|
+
* Invariant: the retained content (compacted line groups plus recent lines,
|
|
142
|
+
* joined with "\n", plus the partial current line) is a suffix of the stream
|
|
143
|
+
* that either covers the whole stream or keeps the last-`maxLines`-lines window
|
|
144
|
+
* reachable — older content is only dropped as whole lines when the remaining
|
|
145
|
+
* lines still cover the line window, and any line trimmed from its start (only
|
|
146
|
+
* for lines larger than the byte budget) is halved so repeated trims stay
|
|
147
|
+
* amortized and never drop below what the byte window still needs.
|
|
148
|
+
*/
|
|
149
|
+
export class TailLineRing {
|
|
150
|
+
readonly #maxBytes: number;
|
|
151
|
+
readonly #maxLines: number;
|
|
152
|
+
readonly #blobs: string[] = [];
|
|
153
|
+
readonly #blobBytes: number[] = [];
|
|
154
|
+
readonly #lines: string[] = [];
|
|
155
|
+
readonly #lineBytes: number[] = [];
|
|
156
|
+
#current = "";
|
|
157
|
+
#currentBytes = 0;
|
|
158
|
+
#bytes = 0;
|
|
159
|
+
|
|
160
|
+
constructor(options: TailLineRingOptions) {
|
|
161
|
+
this.#maxBytes = Math.max(0, Math.floor(options.maxBytes));
|
|
162
|
+
this.#maxLines = Math.max(1, Math.floor(options.maxLines));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
append(chunk: string): void {
|
|
166
|
+
if (chunk.length === 0) return;
|
|
167
|
+
let start = 0;
|
|
168
|
+
for (;;) {
|
|
169
|
+
const newline = chunk.indexOf("\n", start);
|
|
170
|
+
const end = newline === -1 ? chunk.length : newline;
|
|
171
|
+
if (end > start) {
|
|
172
|
+
const piece = chunk.slice(start, end);
|
|
173
|
+
const pieceBytes = Buffer.byteLength(piece, "utf8");
|
|
174
|
+
this.#current += piece;
|
|
175
|
+
this.#currentBytes += pieceBytes;
|
|
176
|
+
this.#bytes += pieceBytes;
|
|
177
|
+
}
|
|
178
|
+
if (newline === -1) break;
|
|
179
|
+
this.#completeLine();
|
|
180
|
+
start = newline + 1;
|
|
181
|
+
}
|
|
182
|
+
this.#enforce();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
text(): string {
|
|
186
|
+
const endsWithNewline = this.#current === "" && this.#bytes > 0;
|
|
187
|
+
const keptLines = endsWithNewline ? this.#maxLines : this.#maxLines - 1;
|
|
188
|
+
if (this.#lines.length >= keptLines) {
|
|
189
|
+
const parts = this.#lines.slice(-keptLines);
|
|
190
|
+
if (!endsWithNewline) parts.push(this.#current);
|
|
191
|
+
const window = `${parts.join("\n")}${endsWithNewline ? "\n" : ""}`;
|
|
192
|
+
if (Buffer.byteLength(window, "utf8") <= this.#maxBytes) return window;
|
|
193
|
+
}
|
|
194
|
+
return this.#bytePathText(endsWithNewline);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#completeLine(): void {
|
|
198
|
+
this.#lines.push(this.#current);
|
|
199
|
+
this.#lineBytes.push(this.#currentBytes);
|
|
200
|
+
this.#current = "";
|
|
201
|
+
this.#currentBytes = 0;
|
|
202
|
+
this.#bytes += 1;
|
|
203
|
+
const keep = Math.max(this.#maxLines * 4, 64);
|
|
204
|
+
if (this.#lines.length > keep * 2) this.#compact(keep);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Folds the oldest individual lines into one compacted group so a long stream of
|
|
209
|
+
* tiny lines cannot grow the line array without bound. The recent lines kept
|
|
210
|
+
* always outnumber the window, so compaction never touches rendered content.
|
|
211
|
+
*/
|
|
212
|
+
#compact(keep: number): void {
|
|
213
|
+
const movedCount = this.#lines.length - keep;
|
|
214
|
+
const moved = this.#lines.splice(0, movedCount);
|
|
215
|
+
const movedBytes = this.#lineBytes.splice(0, movedCount);
|
|
216
|
+
let bytes = 0;
|
|
217
|
+
for (const entry of movedBytes) bytes += entry;
|
|
218
|
+
this.#blobs.push(moved.join("\n"));
|
|
219
|
+
this.#blobBytes.push(bytes + Math.max(0, moved.length - 1));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#enforce(): void {
|
|
223
|
+
while (this.#bytes > this.#maxBytes) {
|
|
224
|
+
if (this.#blobs.length > 0) {
|
|
225
|
+
if (!this.#shedFront(this.#blobs, this.#blobBytes, this.#lines.length)) return;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (this.#lines.length > 0) {
|
|
229
|
+
if (!this.#shedFront(this.#lines, this.#lineBytes, this.#lines.length - 1)) return;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (this.#currentBytes <= this.#maxBytes) return;
|
|
233
|
+
const kept = truncateTailBytes(this.#current, Math.max(this.#maxBytes, Math.floor(this.#currentBytes / 2)));
|
|
234
|
+
this.#bytes -= this.#currentBytes - kept.bytes;
|
|
235
|
+
this.#current = kept.text;
|
|
236
|
+
this.#currentBytes = kept.bytes;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Drops or trims the oldest retained unit. Dropping is safe when the remaining
|
|
242
|
+
* bytes still cover the byte window or the remaining individual lines still cover
|
|
243
|
+
* the line window; otherwise the unit is trimmed from its start (halved, never
|
|
244
|
+
* below what the byte window still needs), which keeps the retained content a
|
|
245
|
+
* suffix holding at least `maxBytes` bytes. Returns false when nothing more can
|
|
246
|
+
* be shed without breaking the invariant.
|
|
247
|
+
*/
|
|
248
|
+
#shedFront(texts: string[], byteCosts: number[], retainedLines: number): boolean {
|
|
249
|
+
const unitBytes = byteCosts[0];
|
|
250
|
+
const restBytes = this.#bytes - unitBytes - 1;
|
|
251
|
+
if (restBytes >= this.#maxBytes || retainedLines >= this.#maxLines) {
|
|
252
|
+
texts.shift();
|
|
253
|
+
byteCosts.shift();
|
|
254
|
+
this.#bytes = restBytes;
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
const needBytes = this.#maxBytes - restBytes;
|
|
258
|
+
const keepBytes = Math.max(needBytes, unitBytes >> 1);
|
|
259
|
+
if (keepBytes >= unitBytes) return false;
|
|
260
|
+
const kept = truncateTailBytes(texts[0], keepBytes);
|
|
261
|
+
texts[0] = kept.text;
|
|
262
|
+
byteCosts[0] = kept.bytes;
|
|
263
|
+
this.#bytes -= unitBytes - kept.bytes;
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#bytePathText(endsWithNewline: boolean): string {
|
|
268
|
+
const units = [...this.#blobs, ...this.#lines];
|
|
269
|
+
const joined = units.length === 0 ? this.#current : `${units.join("\n")}\n${this.#current}`;
|
|
270
|
+
const tail = truncateTailBytes(joined, this.#maxBytes).text;
|
|
271
|
+
let tailLines = tail.split("\n");
|
|
272
|
+
if (endsWithNewline) tailLines = tailLines.slice(0, -1);
|
|
273
|
+
return `${tailLines.slice(-this.#maxLines).join("\n")}${endsWithNewline ? "\n" : ""}`;
|
|
274
|
+
}
|
|
58
275
|
}
|
|
@@ -2,11 +2,11 @@ import { createWriteStream, mkdirSync, type WriteStream } from "node:fs";
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail } from "../host-sdk.ts";
|
|
4
4
|
import { formatMiddleElisionMarker } from "./output-meta.ts";
|
|
5
|
-
import { TailBuffer, truncateHeadBytes } from "./streaming-output-buffer.ts";
|
|
5
|
+
import { TailBuffer, TailLineRing, truncateHeadBytes } from "./streaming-output-buffer.ts";
|
|
6
6
|
|
|
7
7
|
export { artifactNotice, formatMiddleElisionMarker, resolveSessionArtifactsDir } from "./output-meta.ts";
|
|
8
8
|
export { truncateHeadBytes, truncateTailBytes } from "./streaming-output-buffer.ts";
|
|
9
|
-
export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, TailBuffer, truncateTail };
|
|
9
|
+
export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, TailBuffer, TailLineRing, truncateTail };
|
|
10
10
|
|
|
11
11
|
export const ARTIFACT_DEFAULT_HEAD_BYTES = 3 * 1024 * 1024;
|
|
12
12
|
|
|
@@ -112,7 +112,7 @@ export function buildEvalPrompt(
|
|
|
112
112
|
promptSnippet: "Run one incremental code cell in a persistent language kernel.",
|
|
113
113
|
promptGuidelines: [
|
|
114
114
|
style === "gpt" && context.monitor === true ? GPT_MONITOR_BATCHING_GUIDELINE : BATCHING_GUIDELINES[style],
|
|
115
|
-
"Use eval reset only when a language kernel must be wiped; reset is scoped to the selected language.",
|
|
115
|
+
"Use eval reset only when a language kernel must be wiped; reset is scoped to the selected language. A bracketed kernel memory notice in a result names the globals holding the most memory; drop the ones you no longer need.",
|
|
116
116
|
],
|
|
117
117
|
};
|
|
118
118
|
}
|
|
@@ -34,6 +34,7 @@ await Bun.write("page.png", await view.screenshot());
|
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
- macOS: system WebKit, nothing to install. macOS/Linux/Windows: can drive installed Chrome/Chromium/Edge.
|
|
37
|
+
- In the eval js kernel, Chrome-backed views (the default off macOS, or `backend: "chrome"`) are served by the process main thread; the API is the same, a constructor error surfaces on the first awaited call, and `Bun.WebView.closeAll()` closes only this kernel's views. A kernel reset closes every view the kernel left open.
|
|
37
38
|
- Clicks/scrolls are trusted user input (`event.isTrusted === true`).
|
|
38
39
|
- Extends `EventTarget`; screenshots are `Blob`s; `.cdp(method, params?)` escape hatch for raw Chrome DevTools Protocol.
|
|
39
40
|
|
package/src/tool/cell-runtime.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@code-yeongyu/senpi";
|
|
2
|
+
import type { KernelMemoryReport } from "../bridge/memory-protocol.ts";
|
|
2
3
|
import type { KernelToHostMessage } from "../bridge/protocol.ts";
|
|
4
|
+
import { DEFAULT_MAX_BYTES, TailLineRing } from "../output/streaming-output.ts";
|
|
3
5
|
import type { EvalToolCallMetric } from "./call-capture.ts";
|
|
4
6
|
import { type EvalImageResizer, EvalOutputCollector, type EvalOutputResult } from "./image.ts";
|
|
5
|
-
import type { EvalRuntimeInfo, EvalStatusEvent, EvalToolDetails, EvalToolInput } from "./types.ts";
|
|
7
|
+
import type { EvalMemoryDetails, EvalRuntimeInfo, EvalStatusEvent, EvalToolDetails, EvalToolInput } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
const LIVE_UPDATE_LINES = 8;
|
|
10
|
+
/** Same cadence as the core bash tool's streaming updates (BASH_UPDATE_THROTTLE_MS). */
|
|
11
|
+
const LIVE_OUTPUT_UPDATE_THROTTLE_MS = 100;
|
|
6
12
|
|
|
7
13
|
type KernelResult = Extract<KernelToHostMessage, { type: "result" }>;
|
|
8
14
|
type DisplayMessage = Extract<KernelToHostMessage, { type: "display" }>;
|
|
@@ -40,6 +46,10 @@ export interface CellResultBuilderOptions {
|
|
|
40
46
|
export class CellResultBuilder {
|
|
41
47
|
readonly #output: EvalOutputCollector;
|
|
42
48
|
readonly #state: CellState;
|
|
49
|
+
#memory: KernelMemoryReport | undefined;
|
|
50
|
+
readonly #liveLines = new TailLineRing({ maxBytes: DEFAULT_MAX_BYTES * 2, maxLines: LIVE_UPDATE_LINES });
|
|
51
|
+
#lastOutputUpdateAt = 0;
|
|
52
|
+
#outputUpdateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
43
53
|
|
|
44
54
|
constructor(options: CellResultBuilderOptions) {
|
|
45
55
|
this.#state = options.state;
|
|
@@ -49,9 +59,9 @@ export class CellResultBuilder {
|
|
|
49
59
|
model: options.model,
|
|
50
60
|
...(options.artifactPath === undefined ? {} : { artifactPath: options.artifactPath }),
|
|
51
61
|
...(options.imageResizer === undefined ? {} : { imageResizer: options.imageResizer }),
|
|
52
|
-
onChunk: (
|
|
53
|
-
|
|
54
|
-
this
|
|
62
|
+
onChunk: (chunk) => {
|
|
63
|
+
this.#liveLines.append(chunk);
|
|
64
|
+
this.#scheduleOutputUpdate();
|
|
55
65
|
},
|
|
56
66
|
});
|
|
57
67
|
if (options.state.status !== "queued") options.state.status = "running";
|
|
@@ -73,6 +83,7 @@ export class CellResultBuilder {
|
|
|
73
83
|
|
|
74
84
|
async finalize(result: KernelResult): Promise<AgentToolResult<EvalToolDetails>> {
|
|
75
85
|
this.#state.durationMs = result.durationMs;
|
|
86
|
+
this.#memory = result.memory;
|
|
76
87
|
if (result.ok) {
|
|
77
88
|
if (result.valueRepr) this.#output.push(`${result.valueRepr}\n`);
|
|
78
89
|
this.#state.status = "complete";
|
|
@@ -96,6 +107,7 @@ export class CellResultBuilder {
|
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
liveResult(): AgentToolResult<EvalToolDetails> {
|
|
110
|
+
this.#state.output = this.#output.cellTailText();
|
|
99
111
|
return {
|
|
100
112
|
content: [{ type: "text", text: this.#liveUpdateText() }],
|
|
101
113
|
details: this.#details(undefined, this.#state.status === "error"),
|
|
@@ -110,7 +122,33 @@ export class CellResultBuilder {
|
|
|
110
122
|
});
|
|
111
123
|
}
|
|
112
124
|
|
|
125
|
+
#scheduleOutputUpdate(): void {
|
|
126
|
+
const delay = LIVE_OUTPUT_UPDATE_THROTTLE_MS - (Date.now() - this.#lastOutputUpdateAt);
|
|
127
|
+
if (delay <= 0) {
|
|
128
|
+
this.#clearOutputUpdateTimer();
|
|
129
|
+
this.#emitOutputUpdate();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.#outputUpdateTimer ??= setTimeout(() => {
|
|
133
|
+
this.#outputUpdateTimer = undefined;
|
|
134
|
+
this.#emitOutputUpdate();
|
|
135
|
+
}, delay);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#emitOutputUpdate(): void {
|
|
139
|
+
this.#lastOutputUpdateAt = Date.now();
|
|
140
|
+
this.#state.output = this.#output.cellTailText();
|
|
141
|
+
this.emitUpdate(false);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#clearOutputUpdateTimer(): void {
|
|
145
|
+
if (this.#outputUpdateTimer === undefined) return;
|
|
146
|
+
clearTimeout(this.#outputUpdateTimer);
|
|
147
|
+
this.#outputUpdateTimer = undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
113
150
|
async #finish(isError: boolean): Promise<AgentToolResult<EvalToolDetails>> {
|
|
151
|
+
this.#clearOutputUpdateTimer();
|
|
114
152
|
const output = await this.#output.finish();
|
|
115
153
|
this.#state.output = output.output;
|
|
116
154
|
const details = this.#details(output, isError);
|
|
@@ -120,7 +158,9 @@ export class CellResultBuilder {
|
|
|
120
158
|
(output.images.length > 0
|
|
121
159
|
? `(displayed ${output.images.length} image${output.images.length === 1 ? "" : "s"}; no text output)`
|
|
122
160
|
: "(no output)");
|
|
123
|
-
|
|
161
|
+
const notice = this.#memory?.notice;
|
|
162
|
+
const noticePart = notice === undefined ? [] : [{ type: "text" as const, text: notice }];
|
|
163
|
+
return { content: [{ type: "text", text }, ...noticePart, ...output.images], details };
|
|
124
164
|
}
|
|
125
165
|
|
|
126
166
|
#details(output: EvalOutputResult | undefined, isError: boolean): EvalToolDetails {
|
|
@@ -159,6 +199,7 @@ export class CellResultBuilder {
|
|
|
159
199
|
...(output === undefined || output.jsonOutputs.length === 0 ? {} : { jsonOutputs: output.jsonOutputs }),
|
|
160
200
|
...(output?.notice === undefined ? {} : { notice: output.notice }),
|
|
161
201
|
...(output?.meta === undefined ? {} : { meta: output.meta }),
|
|
202
|
+
...(output === undefined || this.#memory === undefined ? {} : { memory: memoryDetails(this.#memory) }),
|
|
162
203
|
};
|
|
163
204
|
}
|
|
164
205
|
|
|
@@ -167,11 +208,12 @@ export class CellResultBuilder {
|
|
|
167
208
|
return `queued behind ${this.#state.queuedBehind.join(", ")} in the ${this.#state.input.language} kernel`;
|
|
168
209
|
}
|
|
169
210
|
const summary = this.#state.input.summary === undefined ? "" : ` ${this.#state.input.summary}`;
|
|
170
|
-
const
|
|
171
|
-
const outputLines = aggregateOutput.split("\n");
|
|
172
|
-
const hasTrailingNewline = aggregateOutput.endsWith("\n");
|
|
173
|
-
if (hasTrailingNewline) outputLines.pop();
|
|
174
|
-
const output = `${outputLines.slice(-8).join("\n")}${hasTrailingNewline ? "\n" : ""}`;
|
|
211
|
+
const output = this.#liveLines.text();
|
|
175
212
|
return `1/1 cells ${this.#state.status}\n[1] ${this.#state.input.language}${summary} ${this.#state.status}${output.length === 0 ? "" : `\n${output}`}`;
|
|
176
213
|
}
|
|
177
214
|
}
|
|
215
|
+
|
|
216
|
+
function memoryDetails(report: KernelMemoryReport): EvalMemoryDetails {
|
|
217
|
+
const { notice: _notice, ...details } = report;
|
|
218
|
+
return details;
|
|
219
|
+
}
|
|
@@ -47,6 +47,10 @@ export interface EvalDetachedCellManagerOptions {
|
|
|
47
47
|
readonly runBudgetSeconds?: number;
|
|
48
48
|
/** Global detached-cell capacity; defaults to 15. Full capacity keeps new cells foreground. */
|
|
49
49
|
readonly maxDetachedCells?: number;
|
|
50
|
+
/** Estimated in-memory byte budget for settled-cell snapshots kept for peek/list; 0 keeps only the count cap. */
|
|
51
|
+
readonly retainedResultsBytes?: number;
|
|
52
|
+
/** Disk budget for settled-cell images spilled under `artifactsDir`; 0 keeps only the count cap. */
|
|
53
|
+
readonly retainedImagesBytes?: number;
|
|
50
54
|
readonly onStatusChange?: (entries: readonly EvalDetachedCellStatusEntry[]) => void;
|
|
51
55
|
/** Receives a full per-source liveness snapshot on every detached-cell transition; used by the goal builtin. */
|
|
52
56
|
readonly onWakeSourceState?: (state: WakeSourceState) => void;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import type { AgentToolResult } from "@code-yeongyu/senpi";
|
|
3
|
+
import { DEFAULT_RETAINED_IMAGES_MB, DEFAULT_RETAINED_RESULTS_MB } from "../config/memory-settings.ts";
|
|
2
4
|
import {
|
|
3
5
|
DEFAULT_HARD_LIMIT_SECONDS,
|
|
4
6
|
DEFAULT_MAX_DETACHED_CELLS,
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
import { detachedStatusEntries, detachedWakeSourceState } from "./detached-cell-status.ts";
|
|
21
23
|
import { DetachedNotificationQueue } from "./detached-notification-queue.ts";
|
|
22
24
|
import { createManagedCell, type LiveResultProvider, type ManagedCell } from "./managed-cell.ts";
|
|
25
|
+
import { SettledImageSpill } from "./settled-image-spill.ts";
|
|
23
26
|
import { TerminalSnapshotStore } from "./terminal-snapshot-store.ts";
|
|
24
27
|
import type { EvalKernel, EvalLanguage, EvalToolDetails, EvalToolInput } from "./types.ts";
|
|
25
28
|
|
|
@@ -38,7 +41,7 @@ export class EvalDetachedCellManager {
|
|
|
38
41
|
readonly #onWakeSourceState: ((state: WakeSourceState) => void) | undefined;
|
|
39
42
|
readonly #cells = new Map<string, ManagedCell>();
|
|
40
43
|
readonly #detached = new Map<string, ManagedCell>();
|
|
41
|
-
readonly #terminalSnapshots
|
|
44
|
+
readonly #terminalSnapshots: TerminalSnapshotStore;
|
|
42
45
|
readonly #notificationQueue: DetachedNotificationQueue;
|
|
43
46
|
readonly #now: () => number;
|
|
44
47
|
readonly #hardLimitSeconds: number;
|
|
@@ -54,6 +57,18 @@ export class EvalDetachedCellManager {
|
|
|
54
57
|
this.#hardLimitSeconds = options.hardLimitSeconds ?? DEFAULT_HARD_LIMIT_SECONDS;
|
|
55
58
|
this.#runBudgetSeconds = options.runBudgetSeconds ?? DEFAULT_RUN_BUDGET_SECONDS;
|
|
56
59
|
this.#maxDetachedCells = options.maxDetachedCells ?? DEFAULT_MAX_DETACHED_CELLS;
|
|
60
|
+
const artifactsDir = options.artifactsDir;
|
|
61
|
+
this.#terminalSnapshots = new TerminalSnapshotStore({
|
|
62
|
+
byteBudget: options.retainedResultsBytes ?? DEFAULT_RETAINED_RESULTS_MB * 1024 * 1024,
|
|
63
|
+
...(artifactsDir === undefined
|
|
64
|
+
? {}
|
|
65
|
+
: {
|
|
66
|
+
spill: new SettledImageSpill({
|
|
67
|
+
dir: join(artifactsDir, "settled-images"),
|
|
68
|
+
byteBudget: options.retainedImagesBytes ?? DEFAULT_RETAINED_IMAGES_MB * 1024 * 1024,
|
|
69
|
+
}),
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
get maxDetachedCells(): number {
|
|
@@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import type { EvalDetachedCellNotification, EvalDetachedCellSnapshot } from "./detached-cell-manager.ts";
|
|
4
4
|
import { interruptionStateNote, unknownInterruptionStateNote } from "./interrupt-note.ts";
|
|
5
|
+
import type { EvalMemoryDetails } from "./types.ts";
|
|
5
6
|
|
|
6
7
|
const NOTIFICATION_TAIL_BYTES = 512;
|
|
7
8
|
|
|
@@ -77,11 +78,19 @@ function outcomeOf(cell: EvalDetachedCellSnapshot): string {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
function stateNoteOf(cell: EvalDetachedCellSnapshot): string {
|
|
80
|
-
if (cell.state !== "cancelled") return
|
|
81
|
+
if (cell.state !== "cancelled") return memoryStateNote(cell.result.details?.memory);
|
|
81
82
|
const note = interruptionStateNote(cell.language, cell.stateRetained) ?? unknownInterruptionStateNote(cell.language);
|
|
82
83
|
return cell.interruptNote === undefined ? note : `${note} ${cell.interruptNote.trim()}`;
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
export function memoryStateNote(memory: EvalMemoryDetails | undefined): string {
|
|
87
|
+
if (memory?.overCeiling === true)
|
|
88
|
+
return "Kernel memory is over its ceiling - the kernel restarts before the next eval cell and every global is lost.";
|
|
89
|
+
if (memory?.recycled === true)
|
|
90
|
+
return "The kernel was restarted before this cell ran - globals from earlier cells are gone; this cell's variables are available to the next eval cell.";
|
|
91
|
+
return "Kernel state updated - variables are available to the next eval cell.";
|
|
92
|
+
}
|
|
93
|
+
|
|
85
94
|
function safeCellId(cellId: string): string {
|
|
86
95
|
return cellId.replace(/[^a-zA-Z0-9_-]/gu, "_");
|
|
87
96
|
}
|
package/src/tool/image.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface EvalOutputOptions {
|
|
|
36
36
|
readonly maxColumns: number;
|
|
37
37
|
readonly model: ExtensionContext["model"];
|
|
38
38
|
readonly imageResizer?: EvalImageResizer;
|
|
39
|
-
readonly onChunk: (
|
|
39
|
+
readonly onChunk: (chunk: string) => void;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface EvalOutputResult {
|
|
@@ -82,7 +82,7 @@ export class EvalOutputCollector {
|
|
|
82
82
|
onChunk: (chunk) => {
|
|
83
83
|
this.#aggregateTail.append(chunk);
|
|
84
84
|
this.#cellTail.append(chunk);
|
|
85
|
-
options.onChunk(
|
|
85
|
+
options.onChunk(chunk);
|
|
86
86
|
},
|
|
87
87
|
});
|
|
88
88
|
}
|
|
@@ -129,6 +129,10 @@ export class EvalOutputCollector {
|
|
|
129
129
|
return this.#aggregateTail.text();
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
cellTailText(): string {
|
|
133
|
+
return this.#cellTail.text();
|
|
134
|
+
}
|
|
135
|
+
|
|
132
136
|
async finish(): Promise<EvalOutputResult> {
|
|
133
137
|
await this.#processImages();
|
|
134
138
|
const elided = this.#displayImagesElided + this.#jsonOutputsElided;
|
|
@@ -188,24 +188,24 @@ async function executeCell(
|
|
|
188
188
|
onReady: () => void,
|
|
189
189
|
): Promise<AgentToolResult<EvalToolDetails>> {
|
|
190
190
|
let handler: CellHandler | undefined;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (message.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
if (message.event.op === TIMEOUT_RESUME_OP) {
|
|
201
|
-
execution.resume();
|
|
202
|
-
cellManager.resume(cell);
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
191
|
+
const onMessage = (message: KernelToHostMessage): void => {
|
|
192
|
+
if (!state.active || handler === undefined) return;
|
|
193
|
+
if (message.type === "status") {
|
|
194
|
+
if (message.event.op === TIMEOUT_PAUSE_OP) {
|
|
195
|
+
execution.pause();
|
|
196
|
+
cellManager.pause(cell);
|
|
197
|
+
return;
|
|
205
198
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
199
|
+
if (message.event.op === TIMEOUT_RESUME_OP) {
|
|
200
|
+
execution.resume();
|
|
201
|
+
cellManager.resume(cell);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const pending = handler.handle(message);
|
|
206
|
+
void pending.catch((error: unknown) => execution.cancel(error));
|
|
207
|
+
};
|
|
208
|
+
try {
|
|
209
209
|
const kernel = await execution.wait(options.kernelManager.getKernel(invocation.input.language, onMessage));
|
|
210
210
|
// Computed before the handler so the cell's capability can be entered per host tool call from the
|
|
211
211
|
// worker's message loop, which runs outside the `kernelToolsStorage.run` context below (#1754).
|
|
@@ -287,6 +287,8 @@ async function executeCell(
|
|
|
287
287
|
bridgeAbortController.abort();
|
|
288
288
|
execution.finish();
|
|
289
289
|
if (handler) await handler.flushOutput();
|
|
290
|
+
// The cell settled: stop the kernel dispatcher from holding this cell's listener (#2260).
|
|
291
|
+
options.kernelManager.releaseKernelListener?.(invocation.input.language, onMessage);
|
|
290
292
|
}
|
|
291
293
|
}
|
|
292
294
|
|