@code-yeongyu/senpi-codemode 2026.7.25-2
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 +250 -0
- package/LICENSE +22 -0
- package/README.md +161 -0
- package/package.json +58 -0
- package/src/bridge/http-server.ts +236 -0
- package/src/bridge/protocol.ts +198 -0
- package/src/bridge/reserved.ts +9 -0
- package/src/bridges/agent-bridge.ts +197 -0
- package/src/bridges/output-bridge.ts +96 -0
- package/src/bridges/schema-injection.ts +3 -0
- package/src/codemode/runtime.ts +258 -0
- package/src/codemode/tools.ts +106 -0
- package/src/completion/handler.ts +192 -0
- package/src/completion/tool-bridge.ts +55 -0
- package/src/config/settings.ts +215 -0
- package/src/extension/runtime-factory.ts +114 -0
- package/src/extension/session-manager-proxy.ts +116 -0
- package/src/extension/session-manager.ts +215 -0
- package/src/host-sdk.ts +1 -0
- package/src/index.ts +181 -0
- package/src/interpreters/detect.ts +161 -0
- package/src/kernels/jl/kernel.ts +37 -0
- package/src/kernels/jl/prelude.jl +283 -0
- package/src/kernels/jl/runner.jl +327 -0
- package/src/kernels/js/context-manager.ts +296 -0
- package/src/kernels/js/inline-worker-entry.js +23 -0
- package/src/kernels/js/inline-worker.ts +15 -0
- package/src/kernels/js/kernel-contract.ts +38 -0
- package/src/kernels/js/local-module-loader.ts +108 -0
- package/src/kernels/js/prelude.ts +15 -0
- package/src/kernels/js/rewrite-imports.ts +164 -0
- package/src/kernels/js/run-queue.ts +82 -0
- package/src/kernels/js/worker-core.d.ts +18 -0
- package/src/kernels/js/worker-core.js +94 -0
- package/src/kernels/js/worker-entry.js +23 -0
- package/src/kernels/js/worker-host.ts +117 -0
- package/src/kernels/js/worker-indirect-eval.js +88 -0
- package/src/kernels/js/worker-runtime.js +401 -0
- package/src/kernels/py/kernel-contract.ts +32 -0
- package/src/kernels/py/kernel.ts +290 -0
- package/src/kernels/py/prelude.py +954 -0
- package/src/kernels/py/process.ts +119 -0
- package/src/kernels/py/transport.ts +237 -0
- package/src/kernels/rb/kernel.ts +26 -0
- package/src/kernels/rb/prelude.rb +270 -0
- package/src/kernels/rb/runner.rb +204 -0
- package/src/kernels/shared/subprocess-contract.ts +22 -0
- package/src/kernels/shared/subprocess-kernel.ts +266 -0
- package/src/kernels/shared/subprocess-process.ts +174 -0
- package/src/kernels/shared/subprocess-queue.ts +101 -0
- package/src/kernels/shared/subprocess-run.ts +98 -0
- package/src/output/output-meta.ts +89 -0
- package/src/output/streaming-output.ts +296 -0
- package/src/prompt/eval-prompt.ts +319 -0
- package/src/timeouts/bridge-timeout.ts +16 -0
- package/src/timeouts/idle-timeout.ts +84 -0
- package/src/tool/cell-handler.ts +279 -0
- package/src/tool/eval-tool.ts +285 -0
- package/src/tool/image.ts +274 -0
- package/src/tool/json-tree.ts +247 -0
- package/src/tool/render.ts +876 -0
- package/src/tool/status-events.ts +12 -0
- package/src/tool/types.ts +114 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import type { KernelResult, KernelRunInput, ToolCallMessage } from "./subprocess-contract.ts";
|
|
3
|
+
import { createPendingRun, failureResult, type PendingRun, settlePendingRun } from "./subprocess-run.ts";
|
|
4
|
+
|
|
5
|
+
export class SubprocessRunQueue {
|
|
6
|
+
readonly #queue: PendingRun[] = [];
|
|
7
|
+
readonly #pendingCalls: ToolCallMessage[] = [];
|
|
8
|
+
readonly #callWaiters: Array<(message: ToolCallMessage) => void> = [];
|
|
9
|
+
#active: PendingRun | null = null;
|
|
10
|
+
|
|
11
|
+
get active(): PendingRun | null {
|
|
12
|
+
return this.#active;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
enqueue(input: KernelRunInput): Promise<KernelResult> {
|
|
16
|
+
return new Promise((resolve) => this.#queue.push(createPendingRun(input, resolve)));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
startNext(startedAt: number): PendingRun | null {
|
|
20
|
+
if (this.#active) return null;
|
|
21
|
+
const next = this.#queue.shift() ?? null;
|
|
22
|
+
if (next) next.startedAt = startedAt;
|
|
23
|
+
this.#active = next;
|
|
24
|
+
return next;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
takeWaiting(): PendingRun | null {
|
|
28
|
+
return this.#queue.shift() ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
releaseActive(run: PendingRun): boolean {
|
|
32
|
+
if (this.#active !== run) return false;
|
|
33
|
+
this.#active = null;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
settle(run: PendingRun, result: KernelResult): void {
|
|
38
|
+
if (settlePendingRun(run, result) && this.#active === run) this.#active = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
settleAll(error: Error): void {
|
|
42
|
+
const runs = this.#active ? [this.#active, ...this.#queue] : [...this.#queue];
|
|
43
|
+
this.#active = null;
|
|
44
|
+
this.#queue.length = 0;
|
|
45
|
+
for (const run of runs) this.settle(run, failureResult(run, error));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
clearToolCalls(): void {
|
|
49
|
+
this.#pendingCalls.length = 0;
|
|
50
|
+
this.#callWaiters.length = 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
nextToolCall(): Promise<ToolCallMessage> {
|
|
54
|
+
const queued = this.#pendingCalls.shift();
|
|
55
|
+
if (queued !== undefined) return Promise.resolve(queued);
|
|
56
|
+
return new Promise((resolve) => this.#callWaiters.push(resolve));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
pushToolCall(message: ToolCallMessage): void {
|
|
60
|
+
const waiter = this.#callWaiters.shift();
|
|
61
|
+
if (waiter) waiter(message);
|
|
62
|
+
else this.#pendingCalls.push(message);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
handleMessage(
|
|
66
|
+
message: KernelToHostMessage,
|
|
67
|
+
onMessage: ((message: KernelToHostMessage) => void) | undefined,
|
|
68
|
+
): boolean {
|
|
69
|
+
switch (message.type) {
|
|
70
|
+
case "result": {
|
|
71
|
+
const run = this.#active;
|
|
72
|
+
if (!run || run.input.cellId !== message.cellId) return false;
|
|
73
|
+
onMessage?.(message);
|
|
74
|
+
this.releaseActive(run);
|
|
75
|
+
this.settle(run, message);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
case "tool-call":
|
|
79
|
+
if (!this.#active) return false;
|
|
80
|
+
onMessage?.(message);
|
|
81
|
+
this.pushToolCall(message);
|
|
82
|
+
return false;
|
|
83
|
+
case "text":
|
|
84
|
+
case "display":
|
|
85
|
+
case "log":
|
|
86
|
+
case "phase":
|
|
87
|
+
case "status":
|
|
88
|
+
if (this.#active) onMessage?.(message);
|
|
89
|
+
return false;
|
|
90
|
+
case "ready":
|
|
91
|
+
case "init-failed":
|
|
92
|
+
case "closed":
|
|
93
|
+
onMessage?.(message);
|
|
94
|
+
return false;
|
|
95
|
+
default: {
|
|
96
|
+
const exhaustive: never = message;
|
|
97
|
+
return exhaustive;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { KernelResult, KernelRunInput } from "./subprocess-contract.ts";
|
|
2
|
+
|
|
3
|
+
export interface PendingRun {
|
|
4
|
+
readonly input: KernelRunInput;
|
|
5
|
+
readonly resolve: (message: KernelResult) => void;
|
|
6
|
+
timer: NodeJS.Timeout | null;
|
|
7
|
+
startedAt: number | null;
|
|
8
|
+
settled: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class KernelClosedError extends Error {
|
|
12
|
+
constructor() {
|
|
13
|
+
super("Kernel is closed");
|
|
14
|
+
this.name = "KernelClosedError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class KernelStartupError extends Error {
|
|
19
|
+
constructor(message: string) {
|
|
20
|
+
super(`Kernel startup failed: ${message}`);
|
|
21
|
+
this.name = "KernelStartupError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class KernelRetirementError extends Error {
|
|
26
|
+
constructor() {
|
|
27
|
+
super("Kernel process did not exit after SIGKILL");
|
|
28
|
+
this.name = "KernelRetirementError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class KernelExitedError extends Error {
|
|
33
|
+
constructor(status: string | number) {
|
|
34
|
+
super(`Kernel exited before completing the cell (${status})`);
|
|
35
|
+
this.name = "KernelExitedError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class KernelProcessError extends Error {
|
|
40
|
+
constructor(message: string) {
|
|
41
|
+
super(`Kernel process error: ${message}`);
|
|
42
|
+
this.name = "KernelProcessError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class CellInterruptedError extends Error {
|
|
47
|
+
constructor(reason: string) {
|
|
48
|
+
super(reason === "Eval interrupted" ? reason : `Eval interrupted: ${reason}`);
|
|
49
|
+
this.name = "CellInterruptedError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class KernelResetError extends Error {
|
|
54
|
+
constructor() {
|
|
55
|
+
super("Kernel reset");
|
|
56
|
+
this.name = "KernelResetError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class KernelClosingError extends Error {
|
|
61
|
+
constructor() {
|
|
62
|
+
super("Kernel closed");
|
|
63
|
+
this.name = "KernelClosingError";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createPendingRun(input: KernelRunInput, resolve: (message: KernelResult) => void): PendingRun {
|
|
68
|
+
return { input, resolve, timer: null, startedAt: null, settled: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function failureResult(run: PendingRun, error: Error): KernelResult {
|
|
72
|
+
return {
|
|
73
|
+
type: "result",
|
|
74
|
+
cellId: run.input.cellId,
|
|
75
|
+
ok: false,
|
|
76
|
+
error: { message: error.message },
|
|
77
|
+
durationMs: run.startedAt === null ? 0 : Math.max(0, Math.round(performance.now() - run.startedAt)),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function timeoutResult(run: PendingRun, timeoutMs: number): KernelResult {
|
|
82
|
+
return {
|
|
83
|
+
type: "result",
|
|
84
|
+
cellId: run.input.cellId,
|
|
85
|
+
ok: false,
|
|
86
|
+
error: { message: `Cell timed out after ${timeoutMs}ms` },
|
|
87
|
+
durationMs: timeoutMs,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function settlePendingRun(run: PendingRun, result: KernelResult): boolean {
|
|
92
|
+
if (run.settled) return false;
|
|
93
|
+
run.settled = true;
|
|
94
|
+
if (run.timer) clearTimeout(run.timer);
|
|
95
|
+
run.timer = null;
|
|
96
|
+
run.resolve(result);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export interface SessionArtifactsDir {
|
|
7
|
+
readonly dir: string;
|
|
8
|
+
readonly temp: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatMiddleElisionMarker(elidedLines: number, elidedBytes: number): string {
|
|
12
|
+
return elidedLines <= 1 ? `[…${elidedBytes}B elided…]` : `[…${elidedLines}ln elided…]`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function artifactNotice(path: string): string {
|
|
16
|
+
return `[Full output: ${path}]`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveSessionArtifactsDir(sessionFile: string | undefined): SessionArtifactsDir {
|
|
20
|
+
const temp = sessionFile === undefined;
|
|
21
|
+
const dir =
|
|
22
|
+
sessionFile === undefined
|
|
23
|
+
? join(tmpdir(), `senpi-codemode-${randomBytes(8).toString("hex")}`)
|
|
24
|
+
: `${sessionFile.endsWith(".jsonl") ? sessionFile.slice(0, -6) : sessionFile}-artifacts`;
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
return { dir, temp };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TruncationMeta {
|
|
30
|
+
readonly direction: "head" | "tail" | "middle";
|
|
31
|
+
readonly truncatedBy: "lines" | "bytes" | "middle";
|
|
32
|
+
readonly totalLines: number;
|
|
33
|
+
readonly totalBytes: number;
|
|
34
|
+
readonly outputLines: number;
|
|
35
|
+
readonly outputBytes: number;
|
|
36
|
+
readonly maxBytes?: number;
|
|
37
|
+
readonly shownRange?: { readonly start: number; readonly end: number };
|
|
38
|
+
readonly headRange?: { readonly start: number; readonly end: number };
|
|
39
|
+
readonly tailRange?: { readonly start: number; readonly end: number };
|
|
40
|
+
readonly elidedBytes?: number;
|
|
41
|
+
readonly elidedLines?: number;
|
|
42
|
+
/** Plain absolute artifact path; retained under the upstream field name. */
|
|
43
|
+
readonly artifactId?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatBytes(bytes: number): string {
|
|
47
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
48
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
49
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertNever(value: never): never {
|
|
53
|
+
throw new TypeError(`Unhandled truncation direction: ${String(value)}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function formatTruncationWarning(meta: TruncationMeta | undefined): string | null {
|
|
57
|
+
if (meta === undefined) return null;
|
|
58
|
+
let message: string;
|
|
59
|
+
switch (meta.direction) {
|
|
60
|
+
case "middle": {
|
|
61
|
+
const elidedLines = meta.elidedLines ?? Math.max(0, meta.totalLines - meta.outputLines);
|
|
62
|
+
const elidedBytes = meta.elidedBytes ?? Math.max(0, meta.totalBytes - meta.outputBytes);
|
|
63
|
+
message =
|
|
64
|
+
meta.headRange !== undefined && meta.tailRange !== undefined
|
|
65
|
+
? `Showing lines ${meta.headRange.start}-${meta.headRange.end} and ${meta.tailRange.start}-${meta.tailRange.end} of ${meta.totalLines}; ${elidedLines} middle line${elidedLines === 1 ? "" : "s"} (${formatBytes(elidedBytes)}) elided`
|
|
66
|
+
: `Showing ${meta.outputLines} of ${meta.totalLines} lines; middle elided`;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case "head":
|
|
70
|
+
case "tail":
|
|
71
|
+
message =
|
|
72
|
+
meta.shownRange !== undefined && meta.shownRange.end >= meta.shownRange.start
|
|
73
|
+
? `Showing lines ${meta.shownRange.start}-${meta.shownRange.end} of ${meta.totalLines}`
|
|
74
|
+
: `Showing ${meta.outputLines} of ${meta.totalLines} lines`;
|
|
75
|
+
if (meta.truncatedBy === "bytes") message += ` (${formatBytes(meta.maxBytes ?? meta.outputBytes)} limit)`;
|
|
76
|
+
break;
|
|
77
|
+
default:
|
|
78
|
+
return assertNever(meta.direction);
|
|
79
|
+
}
|
|
80
|
+
if (meta.artifactId !== undefined) message += `. Full output: ${meta.artifactId}`;
|
|
81
|
+
return `[${message}]`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function stripOutputNotice(text: string, meta: TruncationMeta | undefined): string {
|
|
85
|
+
const notice = formatTruncationWarning(meta);
|
|
86
|
+
if (notice === null) return text;
|
|
87
|
+
const trimmed = text.trimEnd();
|
|
88
|
+
return trimmed.endsWith(notice) ? trimmed.slice(0, -notice.length).trimEnd() : text;
|
|
89
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { createWriteStream, mkdirSync, type WriteStream } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail } from "../host-sdk.ts";
|
|
4
|
+
import { formatMiddleElisionMarker } from "./output-meta.ts";
|
|
5
|
+
|
|
6
|
+
export { artifactNotice, formatMiddleElisionMarker, resolveSessionArtifactsDir } from "./output-meta.ts";
|
|
7
|
+
export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail };
|
|
8
|
+
|
|
9
|
+
export const ARTIFACT_DEFAULT_HEAD_BYTES = 3 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export interface OutputSummary {
|
|
12
|
+
readonly output: string;
|
|
13
|
+
readonly truncated: boolean;
|
|
14
|
+
readonly totalLines: number;
|
|
15
|
+
readonly totalBytes: number;
|
|
16
|
+
readonly outputLines: number;
|
|
17
|
+
readonly outputBytes: number;
|
|
18
|
+
readonly elidedBytes?: number;
|
|
19
|
+
readonly elidedLines?: number;
|
|
20
|
+
readonly columnDroppedBytes?: number;
|
|
21
|
+
readonly columnTruncatedLines?: number;
|
|
22
|
+
readonly artifactId?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OutputSinkOptions {
|
|
26
|
+
readonly artifactPath?: string;
|
|
27
|
+
readonly spillThreshold?: number;
|
|
28
|
+
readonly headBytes?: number;
|
|
29
|
+
readonly maxColumns?: number;
|
|
30
|
+
readonly onChunk?: (chunk: string) => void;
|
|
31
|
+
readonly chunkThrottleMs?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ByteSlice {
|
|
35
|
+
readonly text: string;
|
|
36
|
+
readonly bytes: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function countNewlines(text: string): number {
|
|
40
|
+
let count = 0;
|
|
41
|
+
let cursor = text.indexOf("\n");
|
|
42
|
+
while (cursor !== -1) {
|
|
43
|
+
count++;
|
|
44
|
+
cursor = text.indexOf("\n", cursor + 1);
|
|
45
|
+
}
|
|
46
|
+
return count;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function lineCount(text: string): number {
|
|
50
|
+
return text.length === 0 ? 0 : countNewlines(text) + 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function truncateHeadBytes(text: string, maxBytes: number): ByteSlice {
|
|
54
|
+
if (maxBytes <= 0) return { text: "", bytes: 0 };
|
|
55
|
+
const buffer = Buffer.from(text, "utf8");
|
|
56
|
+
if (buffer.length <= maxBytes) return { text, bytes: buffer.length };
|
|
57
|
+
let end = maxBytes;
|
|
58
|
+
while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
|
|
59
|
+
const slice = buffer.subarray(0, end);
|
|
60
|
+
return { text: slice.toString("utf8"), bytes: slice.length };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function truncateTailBytes(text: string, maxBytes: number): ByteSlice {
|
|
64
|
+
if (maxBytes <= 0) return { text: "", bytes: 0 };
|
|
65
|
+
const buffer = Buffer.from(text, "utf8");
|
|
66
|
+
if (buffer.length <= maxBytes) return { text, bytes: buffer.length };
|
|
67
|
+
let start = buffer.length - maxBytes;
|
|
68
|
+
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start++;
|
|
69
|
+
const slice = buffer.subarray(start);
|
|
70
|
+
return { text: slice.toString("utf8"), bytes: slice.length };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class TailBuffer {
|
|
74
|
+
readonly #maxBytes: number;
|
|
75
|
+
#text = "";
|
|
76
|
+
#bytes = 0;
|
|
77
|
+
|
|
78
|
+
constructor(maxBytes: number) {
|
|
79
|
+
this.#maxBytes = Math.max(0, Math.floor(maxBytes));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
append(text: string): void {
|
|
83
|
+
if (text.length === 0) return;
|
|
84
|
+
if (this.#maxBytes === 0) {
|
|
85
|
+
this.#text = "";
|
|
86
|
+
this.#bytes = 0;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const incomingBytes = Buffer.byteLength(text, "utf8");
|
|
90
|
+
const next =
|
|
91
|
+
incomingBytes >= this.#maxBytes
|
|
92
|
+
? truncateTailBytes(text, this.#maxBytes)
|
|
93
|
+
: truncateTailBytes(this.#text + text, this.#maxBytes);
|
|
94
|
+
this.#text = next.text;
|
|
95
|
+
this.#bytes = next.bytes;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
text(): string {
|
|
99
|
+
return this.#text;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
bytes(): number {
|
|
103
|
+
return this.#bytes;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class OutputSink {
|
|
108
|
+
readonly #artifactPath: string | undefined;
|
|
109
|
+
readonly #spillThreshold: number;
|
|
110
|
+
readonly #headLimit: number;
|
|
111
|
+
readonly #maxColumns: number;
|
|
112
|
+
readonly #onChunk: ((chunk: string) => void) | undefined;
|
|
113
|
+
readonly #chunkThrottleMs: number;
|
|
114
|
+
readonly #tail: TailBuffer;
|
|
115
|
+
#head = "";
|
|
116
|
+
#headBytes = 0;
|
|
117
|
+
#totalNewlines = 0;
|
|
118
|
+
#totalBytes = 0;
|
|
119
|
+
#sawData = false;
|
|
120
|
+
#truncated = false;
|
|
121
|
+
#currentLineBytes = 0;
|
|
122
|
+
#columnCapped = false;
|
|
123
|
+
#columnDroppedBytes = 0;
|
|
124
|
+
#columnTruncatedLines = 0;
|
|
125
|
+
#lastChunkTime = 0;
|
|
126
|
+
#pendingChunk = "";
|
|
127
|
+
#beforeSpill = "";
|
|
128
|
+
#file: WriteStream | undefined;
|
|
129
|
+
#fileError: Error | undefined;
|
|
130
|
+
#dumpPromise: Promise<OutputSummary> | undefined;
|
|
131
|
+
|
|
132
|
+
constructor(options: OutputSinkOptions = {}) {
|
|
133
|
+
this.#artifactPath = options.artifactPath;
|
|
134
|
+
this.#spillThreshold = Math.max(0, Math.floor(options.spillThreshold ?? DEFAULT_MAX_BYTES));
|
|
135
|
+
this.#headLimit = Math.max(0, Math.floor(options.headBytes ?? 0));
|
|
136
|
+
this.#maxColumns = Math.max(0, Math.floor(options.maxColumns ?? 0));
|
|
137
|
+
this.#onChunk = options.onChunk;
|
|
138
|
+
this.#chunkThrottleMs = Math.max(0, Math.floor(options.chunkThrottleMs ?? 0));
|
|
139
|
+
this.#tail = new TailBuffer(this.#spillThreshold);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
push(chunk: string): void {
|
|
143
|
+
if (chunk.length === 0) return;
|
|
144
|
+
this.#emitPreview(chunk);
|
|
145
|
+
const rawBytes = Buffer.byteLength(chunk, "utf8");
|
|
146
|
+
this.#totalBytes += rawBytes;
|
|
147
|
+
this.#totalNewlines += countNewlines(chunk);
|
|
148
|
+
this.#sawData = true;
|
|
149
|
+
this.#mirrorRaw(chunk);
|
|
150
|
+
this.#retain(this.#maxColumns > 0 ? this.#clampColumns(chunk) : chunk);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
dump(notice?: string): Promise<OutputSummary> {
|
|
154
|
+
this.#dumpPromise ??= this.#finishDump(notice);
|
|
155
|
+
return this.#dumpPromise;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#emitPreview(chunk: string): void {
|
|
159
|
+
if (this.#onChunk === undefined) return;
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
|
|
162
|
+
this.#lastChunkTime = now;
|
|
163
|
+
this.#onChunk(this.#pendingChunk + chunk);
|
|
164
|
+
this.#pendingChunk = "";
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this.#pendingChunk += chunk;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#mirrorRaw(chunk: string): void {
|
|
171
|
+
if (this.#artifactPath === undefined) return;
|
|
172
|
+
if (this.#file !== undefined) {
|
|
173
|
+
this.#file.write(chunk);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (this.#totalBytes <= this.#spillThreshold) {
|
|
177
|
+
this.#beforeSpill += chunk;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
mkdirSync(dirname(this.#artifactPath), { recursive: true });
|
|
181
|
+
const stream = createWriteStream(this.#artifactPath, { encoding: "utf8" });
|
|
182
|
+
stream.on("error", (error) => {
|
|
183
|
+
this.#fileError = error;
|
|
184
|
+
});
|
|
185
|
+
this.#file = stream;
|
|
186
|
+
if (this.#beforeSpill.length > 0) stream.write(this.#beforeSpill);
|
|
187
|
+
this.#beforeSpill = "";
|
|
188
|
+
stream.write(chunk);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
#retain(text: string): void {
|
|
192
|
+
let tailText = text;
|
|
193
|
+
if (this.#headBytes < this.#headLimit) {
|
|
194
|
+
const head = truncateHeadBytes(text, this.#headLimit - this.#headBytes);
|
|
195
|
+
this.#head += head.text;
|
|
196
|
+
this.#headBytes += head.bytes;
|
|
197
|
+
tailText = text.substring(head.text.length);
|
|
198
|
+
}
|
|
199
|
+
this.#tail.append(tailText);
|
|
200
|
+
const effectiveBytes = this.#totalBytes - this.#columnDroppedBytes;
|
|
201
|
+
if (effectiveBytes > this.#headBytes + this.#tail.bytes()) this.#truncated = true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#clampColumns(chunk: string): string {
|
|
205
|
+
const output: string[] = [];
|
|
206
|
+
let cursor = 0;
|
|
207
|
+
while (cursor < chunk.length) {
|
|
208
|
+
const newline = chunk.indexOf("\n", cursor);
|
|
209
|
+
const end = newline === -1 ? chunk.length : newline;
|
|
210
|
+
const segment = chunk.substring(cursor, end);
|
|
211
|
+
if (segment.length > 0) {
|
|
212
|
+
const segmentBytes = Buffer.byteLength(segment, "utf8");
|
|
213
|
+
if (this.#columnCapped) {
|
|
214
|
+
this.#columnDroppedBytes += segmentBytes;
|
|
215
|
+
} else {
|
|
216
|
+
const remaining = Math.max(0, this.#maxColumns - this.#currentLineBytes);
|
|
217
|
+
const kept = truncateHeadBytes(segment, remaining);
|
|
218
|
+
output.push(kept.text);
|
|
219
|
+
this.#currentLineBytes += kept.bytes;
|
|
220
|
+
if (kept.bytes < segmentBytes) {
|
|
221
|
+
output.push("…");
|
|
222
|
+
this.#columnDroppedBytes += segmentBytes - kept.bytes;
|
|
223
|
+
this.#columnTruncatedLines++;
|
|
224
|
+
this.#columnCapped = true;
|
|
225
|
+
this.#truncated = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (newline === -1) break;
|
|
230
|
+
output.push("\n");
|
|
231
|
+
this.#currentLineBytes = 0;
|
|
232
|
+
this.#columnCapped = false;
|
|
233
|
+
cursor = newline + 1;
|
|
234
|
+
}
|
|
235
|
+
return output.join("");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async #finishDump(notice: string | undefined): Promise<OutputSummary> {
|
|
239
|
+
if (this.#onChunk !== undefined && this.#pendingChunk.length > 0) {
|
|
240
|
+
this.#onChunk(this.#pendingChunk);
|
|
241
|
+
this.#pendingChunk = "";
|
|
242
|
+
}
|
|
243
|
+
await this.#closeFile();
|
|
244
|
+
let tail = this.#tail.text();
|
|
245
|
+
if (lineCount(tail) > DEFAULT_MAX_LINES) {
|
|
246
|
+
tail = truncateTail(tail, { maxLines: DEFAULT_MAX_LINES, maxBytes: Number.MAX_SAFE_INTEGER }).content;
|
|
247
|
+
this.#truncated = true;
|
|
248
|
+
}
|
|
249
|
+
const totalLines = this.#sawData ? this.#totalNewlines + 1 : 0;
|
|
250
|
+
const tailBytes = Buffer.byteLength(tail, "utf8");
|
|
251
|
+
const effectiveBytes = Math.max(0, this.#totalBytes - this.#columnDroppedBytes);
|
|
252
|
+
let body = this.#head + tail;
|
|
253
|
+
let elidedBytes: number | undefined;
|
|
254
|
+
let elidedLines: number | undefined;
|
|
255
|
+
if (this.#headBytes > 0 && effectiveBytes > this.#headBytes + tailBytes) {
|
|
256
|
+
elidedBytes = effectiveBytes - this.#headBytes - tailBytes;
|
|
257
|
+
elidedLines = Math.max(0, totalLines - lineCount(this.#head) - lineCount(tail));
|
|
258
|
+
const headSeparator = this.#head.endsWith("\n") ? "" : "\n";
|
|
259
|
+
const tailSeparator = tail.length === 0 || tail.startsWith("\n") ? "" : "\n";
|
|
260
|
+
body = `${this.#head}${headSeparator}${formatMiddleElisionMarker(elidedLines, elidedBytes)}${tailSeparator}${tail}`;
|
|
261
|
+
this.#truncated = true;
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
output: notice === undefined ? body : `[${notice}]\n${body}`,
|
|
265
|
+
truncated: this.#truncated,
|
|
266
|
+
totalLines,
|
|
267
|
+
totalBytes: this.#totalBytes,
|
|
268
|
+
outputLines: lineCount(body),
|
|
269
|
+
outputBytes: Buffer.byteLength(body, "utf8"),
|
|
270
|
+
elidedBytes,
|
|
271
|
+
elidedLines,
|
|
272
|
+
columnDroppedBytes: this.#columnDroppedBytes > 0 ? this.#columnDroppedBytes : undefined,
|
|
273
|
+
columnTruncatedLines: this.#columnTruncatedLines > 0 ? this.#columnTruncatedLines : undefined,
|
|
274
|
+
artifactId: this.#file === undefined ? undefined : this.#artifactPath,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async #closeFile(): Promise<void> {
|
|
279
|
+
const stream = this.#file;
|
|
280
|
+
if (stream === undefined) return;
|
|
281
|
+
if (this.#fileError !== undefined) throw this.#fileError;
|
|
282
|
+
await new Promise<void>((resolve, reject) => {
|
|
283
|
+
const onError = (error: Error) => {
|
|
284
|
+
stream.off("finish", onFinish);
|
|
285
|
+
reject(error);
|
|
286
|
+
};
|
|
287
|
+
const onFinish = () => {
|
|
288
|
+
stream.off("error", onError);
|
|
289
|
+
resolve();
|
|
290
|
+
};
|
|
291
|
+
stream.once("error", onError);
|
|
292
|
+
stream.once("finish", onFinish);
|
|
293
|
+
stream.end();
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|