@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,119 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { Readable, Writable } from "node:stream";
|
|
3
|
+
|
|
4
|
+
export interface KernelChild {
|
|
5
|
+
readonly stdin: Writable;
|
|
6
|
+
readonly stdout: Readable;
|
|
7
|
+
readonly stderr: Readable;
|
|
8
|
+
readonly pid?: number;
|
|
9
|
+
readonly killed: boolean;
|
|
10
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
11
|
+
on(event: string, listener: (...args: unknown[]) => void): this;
|
|
12
|
+
once(event: string, listener: (...args: unknown[]) => void): this;
|
|
13
|
+
off(event: string, listener: (...args: unknown[]) => void): this;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface KernelSpawnOptions {
|
|
17
|
+
readonly command: string;
|
|
18
|
+
readonly args: readonly string[];
|
|
19
|
+
readonly cwd: string;
|
|
20
|
+
readonly env: NodeJS.ProcessEnv;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type KernelSpawnProcess = (options: KernelSpawnOptions) => KernelChild;
|
|
24
|
+
|
|
25
|
+
export class PythonKernelRetirementError extends Error {
|
|
26
|
+
constructor(pid: number | undefined) {
|
|
27
|
+
super(`Python kernel process${pid === undefined ? "" : ` ${pid}`} did not exit after SIGKILL`);
|
|
28
|
+
this.name = "PythonKernelRetirementError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function defaultSpawn(options: KernelSpawnOptions): KernelChild {
|
|
33
|
+
return spawn(options.command, [...options.args], {
|
|
34
|
+
cwd: options.cwd,
|
|
35
|
+
env: options.env,
|
|
36
|
+
stdio: "pipe",
|
|
37
|
+
detached: process.platform !== "win32",
|
|
38
|
+
windowsHide: true,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function splitCommand(commandLine: string): { readonly command: string; readonly args: readonly string[] } {
|
|
43
|
+
const [command, ...args] = commandLine.split(" ").filter(Boolean);
|
|
44
|
+
if (!command) throw new Error("Python interpreter path is empty");
|
|
45
|
+
return { command, args };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function numberOrNull(value: unknown): number | null {
|
|
49
|
+
return typeof value === "number" ? value : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function signalOrNull(value: unknown): string | null {
|
|
53
|
+
return typeof value === "string" ? value : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
57
|
+
let timer: NodeJS.Timeout | undefined;
|
|
58
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
59
|
+
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
return await Promise.race([promise, timeout]);
|
|
63
|
+
} finally {
|
|
64
|
+
if (timer) clearTimeout(timer);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function waitForExit(child: KernelChild, timeoutMs: number): Promise<boolean> {
|
|
69
|
+
return await new Promise<boolean>((resolve) => {
|
|
70
|
+
let timer: NodeJS.Timeout | undefined;
|
|
71
|
+
const settle = (exited: boolean) => {
|
|
72
|
+
child.off("exit", onExit);
|
|
73
|
+
if (timer) clearTimeout(timer);
|
|
74
|
+
resolve(exited);
|
|
75
|
+
};
|
|
76
|
+
const onExit = () => settle(true);
|
|
77
|
+
child.on("exit", onExit);
|
|
78
|
+
timer = setTimeout(() => settle(false), timeoutMs);
|
|
79
|
+
timer.unref?.();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function hardKill(child: KernelChild, timeoutMs: number): Promise<void> {
|
|
84
|
+
await new Promise<void>((resolve, reject) => {
|
|
85
|
+
let timer: NodeJS.Timeout | undefined;
|
|
86
|
+
let settled = false;
|
|
87
|
+
const settle = (error?: Error) => {
|
|
88
|
+
if (settled) return;
|
|
89
|
+
settled = true;
|
|
90
|
+
child.off("exit", onExit);
|
|
91
|
+
if (timer) clearTimeout(timer);
|
|
92
|
+
if (error) reject(error);
|
|
93
|
+
else resolve();
|
|
94
|
+
};
|
|
95
|
+
const onExit = () => settle();
|
|
96
|
+
child.on("exit", onExit);
|
|
97
|
+
let signalDelivered = true;
|
|
98
|
+
if (child.pid !== undefined && process.platform !== "win32") {
|
|
99
|
+
try {
|
|
100
|
+
process.kill(-child.pid, "SIGKILL");
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (!(error instanceof Error)) {
|
|
103
|
+
settle(new Error(String(error)));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
signalDelivered = child.kill("SIGKILL");
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
signalDelivered = child.kill("SIGKILL");
|
|
110
|
+
}
|
|
111
|
+
if (!signalDelivered) {
|
|
112
|
+
settle();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (settled) return;
|
|
116
|
+
timer = setTimeout(() => settle(new PythonKernelRetirementError(child.pid)), timeoutMs);
|
|
117
|
+
timer.unref?.();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import {
|
|
4
|
+
type BridgeConnectionConfig,
|
|
5
|
+
decodeBridgeFrame,
|
|
6
|
+
encodeBridgeFrame,
|
|
7
|
+
type HostToKernelMessage,
|
|
8
|
+
isKernelToHostMessage,
|
|
9
|
+
type KernelToHostMessage,
|
|
10
|
+
} from "../../bridge/protocol.ts";
|
|
11
|
+
import {
|
|
12
|
+
defaultSpawn,
|
|
13
|
+
hardKill,
|
|
14
|
+
type KernelChild,
|
|
15
|
+
type KernelSpawnOptions,
|
|
16
|
+
type KernelSpawnProcess,
|
|
17
|
+
numberOrNull,
|
|
18
|
+
signalOrNull,
|
|
19
|
+
splitCommand,
|
|
20
|
+
waitForExit,
|
|
21
|
+
withTimeout,
|
|
22
|
+
} from "./process.ts";
|
|
23
|
+
|
|
24
|
+
export type PythonTransportResult = Extract<KernelToHostMessage, { type: "result" }>;
|
|
25
|
+
|
|
26
|
+
export interface PythonTransportRunInput {
|
|
27
|
+
readonly cellId: string;
|
|
28
|
+
readonly code: string;
|
|
29
|
+
readonly timeoutMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PythonTransportOptions {
|
|
33
|
+
readonly interpreterPath: string;
|
|
34
|
+
readonly sessionId: string;
|
|
35
|
+
readonly cwd: string;
|
|
36
|
+
readonly connection: BridgeConnectionConfig;
|
|
37
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
38
|
+
readonly startupTimeoutMs: number;
|
|
39
|
+
readonly onMessage?: (message: KernelToHostMessage) => void;
|
|
40
|
+
readonly spawnProcess?: KernelSpawnProcess;
|
|
41
|
+
readonly isOwned: () => boolean;
|
|
42
|
+
readonly onRetirementFailure: (transport: PythonKernelTransport, error: Error) => void;
|
|
43
|
+
readonly onResult: (transport: PythonKernelTransport, result: PythonTransportResult) => void;
|
|
44
|
+
readonly onError: (transport: PythonKernelTransport, error: Error) => void;
|
|
45
|
+
readonly onExit: (transport: PythonKernelTransport, error: Error) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hardKillWaitMs = 500;
|
|
49
|
+
|
|
50
|
+
export class PythonKernelTransport {
|
|
51
|
+
readonly #options: PythonTransportOptions;
|
|
52
|
+
readonly #child: KernelChild;
|
|
53
|
+
#stdoutBuffer = "";
|
|
54
|
+
#stderrTail = "";
|
|
55
|
+
#settleReady: ((error?: Error) => void) | null = null;
|
|
56
|
+
#detachChildListeners: (() => void) | null = null;
|
|
57
|
+
#active = true;
|
|
58
|
+
#exited = false;
|
|
59
|
+
#retirement: Promise<void> | null = null;
|
|
60
|
+
|
|
61
|
+
private constructor(options: PythonTransportOptions, child: KernelChild) {
|
|
62
|
+
this.#options = options;
|
|
63
|
+
this.#child = child;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static async start(options: PythonTransportOptions): Promise<PythonKernelTransport> {
|
|
67
|
+
const scriptPath = join(dirname(fileURLToPath(import.meta.url)), "prelude.py");
|
|
68
|
+
const invocation = splitCommand(options.interpreterPath);
|
|
69
|
+
const spawnOptions: KernelSpawnOptions = {
|
|
70
|
+
command: invocation.command,
|
|
71
|
+
args: [...invocation.args, "-u", scriptPath],
|
|
72
|
+
cwd: options.cwd,
|
|
73
|
+
env: { ...process.env, ...options.env, PYTHONUNBUFFERED: "1", PYTHONIOENCODING: "utf-8" },
|
|
74
|
+
};
|
|
75
|
+
const child = (options.spawnProcess ?? defaultSpawn)(spawnOptions);
|
|
76
|
+
const transport = new PythonKernelTransport(options, child);
|
|
77
|
+
try {
|
|
78
|
+
await transport.#initialize();
|
|
79
|
+
if (!transport.#active) throw new Error("Python kernel exited during startup");
|
|
80
|
+
if (!options.isOwned()) throw new Error("Python kernel startup was superseded");
|
|
81
|
+
} catch (error) {
|
|
82
|
+
try {
|
|
83
|
+
await transport.retire();
|
|
84
|
+
} catch (retirementError) {
|
|
85
|
+
options.onRetirementFailure(
|
|
86
|
+
transport,
|
|
87
|
+
retirementError instanceof Error ? retirementError : new Error(String(retirementError)),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
return transport;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
run(input: PythonTransportRunInput): void {
|
|
96
|
+
this.#write({ type: "run", cellId: input.cellId, code: input.code, timeoutMs: input.timeoutMs });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interrupt(reason: string): void {
|
|
100
|
+
try {
|
|
101
|
+
this.#write({ type: "interrupt", reason });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
104
|
+
this.#stderrTail = `${this.#stderrTail}Python interrupt frame write failed: ${message}\n`.slice(-4_000);
|
|
105
|
+
}
|
|
106
|
+
if (process.platform === "win32") this.#child.kill();
|
|
107
|
+
else this.#child.kill("SIGINT");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async close(): Promise<void> {
|
|
111
|
+
if (this.#exited) return;
|
|
112
|
+
if (this.#retirement) {
|
|
113
|
+
await this.#retirement;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!this.#active) {
|
|
117
|
+
await hardKill(this.#child, hardKillWaitMs);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.#active = false;
|
|
121
|
+
const exited = waitForExit(this.#child, hardKillWaitMs);
|
|
122
|
+
try {
|
|
123
|
+
this.#write({ type: "close" });
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (!(error instanceof Error)) throw error;
|
|
126
|
+
}
|
|
127
|
+
if (!(await exited)) await hardKill(this.#child, hardKillWaitMs);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
retire(): Promise<void> {
|
|
131
|
+
if (this.#exited) return Promise.resolve();
|
|
132
|
+
if (this.#retirement) return this.#retirement;
|
|
133
|
+
this.#active = false;
|
|
134
|
+
const retirement = hardKill(this.#child, hardKillWaitMs).finally(() => {
|
|
135
|
+
this.#detachListeners();
|
|
136
|
+
if (this.#retirement === retirement) this.#retirement = null;
|
|
137
|
+
});
|
|
138
|
+
this.#retirement = retirement;
|
|
139
|
+
return retirement;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async #initialize(): Promise<void> {
|
|
143
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
144
|
+
this.#settleReady = (error) => (error ? reject(error) : resolve());
|
|
145
|
+
});
|
|
146
|
+
const onStdout = (chunk: unknown) => this.#onStdout(String(chunk));
|
|
147
|
+
const onStderr = (chunk: unknown) => this.#onStderr(String(chunk));
|
|
148
|
+
const onError = (error: unknown) => this.#onError(error instanceof Error ? error : new Error(String(error)));
|
|
149
|
+
const onExit = (code: unknown, signal: unknown) => this.#onExit(numberOrNull(code), signalOrNull(signal));
|
|
150
|
+
this.#detachChildListeners = () => {
|
|
151
|
+
this.#child.stdout.off("data", onStdout);
|
|
152
|
+
this.#child.stderr.off("data", onStderr);
|
|
153
|
+
this.#child.off("error", onError);
|
|
154
|
+
this.#child.off("exit", onExit);
|
|
155
|
+
};
|
|
156
|
+
this.#child.stdout.on("data", onStdout);
|
|
157
|
+
this.#child.stderr.on("data", onStderr);
|
|
158
|
+
this.#child.on("error", onError);
|
|
159
|
+
this.#child.on("exit", onExit);
|
|
160
|
+
this.#write({ type: "init", sessionId: this.#options.sessionId, connection: this.#options.connection });
|
|
161
|
+
await withTimeout(ready, this.#options.startupTimeoutMs, "Python kernel did not become ready");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#write(message: HostToKernelMessage): void {
|
|
165
|
+
this.#child.stdin.write(encodeBridgeFrame(message));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#onStdout(chunk: string): void {
|
|
169
|
+
if (!this.#active) return;
|
|
170
|
+
this.#stdoutBuffer += chunk;
|
|
171
|
+
let newline = this.#stdoutBuffer.indexOf("\n");
|
|
172
|
+
while (newline >= 0) {
|
|
173
|
+
const line = this.#stdoutBuffer.slice(0, newline + 1);
|
|
174
|
+
this.#stdoutBuffer = this.#stdoutBuffer.slice(newline + 1);
|
|
175
|
+
this.#handleLine(line);
|
|
176
|
+
newline = this.#stdoutBuffer.indexOf("\n");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#onStderr(chunk: string): void {
|
|
181
|
+
if (!this.#active) return;
|
|
182
|
+
this.#stderrTail = `${this.#stderrTail}${chunk}`.slice(-4_000);
|
|
183
|
+
this.#options.onMessage?.({ type: "text", stream: "stderr", data: chunk });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#handleLine(line: string): void {
|
|
187
|
+
const decoded = decodeBridgeFrame(line);
|
|
188
|
+
if (!decoded.ok) {
|
|
189
|
+
this.#options.onMessage?.({ type: "text", stream: "stderr", data: `${decoded.error.message}\n` });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!isKernelToHostMessage(decoded.message)) return;
|
|
193
|
+
const message = decoded.message;
|
|
194
|
+
if (message.type === "ready") this.#settleStartup();
|
|
195
|
+
else if (message.type === "init-failed") this.#settleStartup(new Error(message.error.message));
|
|
196
|
+
else if (message.type === "result") this.#options.onResult(this, message);
|
|
197
|
+
this.#options.onMessage?.(message);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#onExit(code: number | null, signal: string | null): void {
|
|
201
|
+
if (this.#exited) return;
|
|
202
|
+
this.#exited = true;
|
|
203
|
+
const active = this.#active;
|
|
204
|
+
this.#active = false;
|
|
205
|
+
const error = new Error(this.#stderrTail.trim() || `Python kernel exited (${code ?? signal ?? "unknown"})`);
|
|
206
|
+
this.#detachListeners();
|
|
207
|
+
if (!active) return;
|
|
208
|
+
if (!this.#settleStartup(error)) this.#options.onExit(this, error);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#onError(error: Error): void {
|
|
212
|
+
if (this.#exited || !this.#active) return;
|
|
213
|
+
this.#active = false;
|
|
214
|
+
if (!this.#settleStartup(error)) this.#options.onError(this, error);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#detachListeners(): void {
|
|
218
|
+
const detach = this.#detachChildListeners;
|
|
219
|
+
if (!detach) return;
|
|
220
|
+
this.#detachChildListeners = null;
|
|
221
|
+
detach();
|
|
222
|
+
this.#stdoutBuffer = "";
|
|
223
|
+
this.#stderrTail = "";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#settleStartup(error?: Error): boolean {
|
|
227
|
+
const settle = this.#settleReady;
|
|
228
|
+
if (!settle) return false;
|
|
229
|
+
this.#settleReady = null;
|
|
230
|
+
settle(error);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function failedPythonResult(cellId: string, message: string, stack?: string): PythonTransportResult {
|
|
236
|
+
return { type: "result", cellId, ok: false, error: stack ? { message, stack } : { message }, durationMs: 0 };
|
|
237
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
|
+
import { SubprocessKernel, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
|
|
4
|
+
|
|
5
|
+
export interface RubyKernelStartOptions {
|
|
6
|
+
readonly cwd: string;
|
|
7
|
+
readonly sessionId: string;
|
|
8
|
+
readonly connection: BridgeConnectionConfig;
|
|
9
|
+
readonly command?: string;
|
|
10
|
+
readonly spawn?: SubprocessSpawn;
|
|
11
|
+
readonly onMessage?: (message: KernelToHostMessage) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class RubyKernel extends SubprocessKernel {
|
|
15
|
+
static start(options: RubyKernelStartOptions): RubyKernel {
|
|
16
|
+
return new RubyKernel({
|
|
17
|
+
command: options.command ?? "ruby",
|
|
18
|
+
args: [join(import.meta.dirname, "runner.rb")],
|
|
19
|
+
cwd: options.cwd,
|
|
20
|
+
sessionId: options.sessionId,
|
|
21
|
+
connection: options.connection,
|
|
22
|
+
spawn: options.spawn,
|
|
23
|
+
onMessage: options.onMessage,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
SENPI_RESERVED_AGENT_TOOL = "__agent__"
|
|
7
|
+
SENPI_RESERVED_OUTPUT_TOOL = "__output__"
|
|
8
|
+
SENPI_INTERNAL_URL = Regexp.new("\\A([a-z][a-z0-9+.\\-]*)://(.*)\\z", Regexp::IGNORECASE)
|
|
9
|
+
|
|
10
|
+
def __senpi_status_enabled?
|
|
11
|
+
connection = $__senpi_connection
|
|
12
|
+
return true unless connection.is_a?(Hash)
|
|
13
|
+
connection["statusEvents"] != false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def __senpi_emit_status(op, fields = {}, force: false)
|
|
17
|
+
return unless force || __senpi_status_enabled?
|
|
18
|
+
__senpi_emit({ "type" => "status", "event" => { "op" => op }.merge(fields.transform_keys(&:to_s)) })
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def __senpi_resolve_path(value)
|
|
22
|
+
raw = value.to_s
|
|
23
|
+
match = SENPI_INTERNAL_URL.match(raw)
|
|
24
|
+
return File.expand_path(raw) unless match
|
|
25
|
+
|
|
26
|
+
scheme = match[1].downcase
|
|
27
|
+
roots = $__senpi_connection.is_a?(Hash) ? $__senpi_connection["localRoots"] : nil
|
|
28
|
+
root = roots[scheme] if roots.is_a?(Hash)
|
|
29
|
+
raise "Protocol paths are not supported by this helper: #{raw}" unless root.is_a?(String) && !root.empty?
|
|
30
|
+
|
|
31
|
+
relative = URI::DEFAULT_PARSER.unescape(match[2].tr("\\", "/"))
|
|
32
|
+
root_path = File.expand_path(root)
|
|
33
|
+
return root_path if relative.empty?
|
|
34
|
+
if relative.start_with?("/") || relative.split("/").include?("..")
|
|
35
|
+
raise "Unsafe #{scheme}:// path (absolute or traversal): #{raw}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
resolved = File.expand_path(relative, root_path)
|
|
39
|
+
unless resolved == root_path || resolved.start_with?(root_path + File::SEPARATOR)
|
|
40
|
+
raise "#{scheme}:// path escapes its root: #{raw}"
|
|
41
|
+
end
|
|
42
|
+
resolved
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def __senpi_display_payload(value)
|
|
46
|
+
if value.is_a?(Hash)
|
|
47
|
+
kind = value["type"] || value[:type]
|
|
48
|
+
text_value = value["text"] || value[:text]
|
|
49
|
+
return ["text/markdown", text_value.to_s] if kind == "markdown" && !text_value.nil?
|
|
50
|
+
return ["image/png", value["data"].to_s] if kind == "image" && value["mimeType"] == "image/png"
|
|
51
|
+
return ["image/jpeg", value["data"].to_s] if kind == "image" && value["mimeType"] == "image/jpeg"
|
|
52
|
+
return ["application/json", JSON.generate(value)]
|
|
53
|
+
end
|
|
54
|
+
return ["application/json", JSON.generate(value)] if value.is_a?(Array)
|
|
55
|
+
["text/plain", value.to_s]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def display(value)
|
|
59
|
+
mime_type, payload = __senpi_display_payload(value)
|
|
60
|
+
data_base64 = mime_type.start_with?("image/") ? payload : Base64.strict_encode64(payload)
|
|
61
|
+
__senpi_emit({ "type" => "display", "mimeType" => mime_type, "dataBase64" => data_base64 })
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def display_image(base64, mime_type: "image/png")
|
|
66
|
+
__senpi_emit({ "type" => "display", "mimeType" => mime_type.to_s, "dataBase64" => base64.to_s })
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def text(data)
|
|
71
|
+
__senpi_emit({ "type" => "text", "stream" => "stdout", "data" => data.to_s })
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def print(*values)
|
|
76
|
+
text(values.join)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def read(path, offset = 1, limit = nil)
|
|
80
|
+
resolved = __senpi_resolve_path(path)
|
|
81
|
+
data = File.read(resolved, encoding: Encoding::UTF_8)
|
|
82
|
+
if offset > 1 || !limit.nil?
|
|
83
|
+
lines = data.lines
|
|
84
|
+
start = [offset.to_i - 1, 0].max
|
|
85
|
+
data = lines[start, limit || lines.length].to_a.join
|
|
86
|
+
end
|
|
87
|
+
__senpi_emit_status("read", { "path" => resolved, "chars" => data.length, "preview" => data[0, 500].to_s })
|
|
88
|
+
data
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def write(path, content)
|
|
92
|
+
resolved = __senpi_resolve_path(path)
|
|
93
|
+
FileUtils.mkdir_p(File.dirname(resolved))
|
|
94
|
+
data = content.to_s
|
|
95
|
+
File.write(resolved, data)
|
|
96
|
+
__senpi_emit_status("write", { "path" => resolved, "chars" => data.length })
|
|
97
|
+
resolved
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def env(key = nil, value = nil)
|
|
101
|
+
if key.nil?
|
|
102
|
+
entries = ENV.to_h.sort.to_h
|
|
103
|
+
__senpi_emit_status("env", { "count" => entries.length, "keys" => entries.keys.first(20) })
|
|
104
|
+
return entries
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
name = key.to_s
|
|
108
|
+
if value.nil?
|
|
109
|
+
resolved = ENV[name]
|
|
110
|
+
__senpi_emit_status("env", { "key" => name, "value" => resolved, "action" => "get" })
|
|
111
|
+
return resolved
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
resolved = value.to_s
|
|
115
|
+
ENV[name] = resolved
|
|
116
|
+
__senpi_emit_status("env", { "key" => name, "value" => resolved, "action" => "set" })
|
|
117
|
+
resolved
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def __senpi_bridge_request(path, payload)
|
|
121
|
+
connection = $__senpi_connection
|
|
122
|
+
raise "Ruby tool bridge is not initialized" unless connection.is_a?(Hash)
|
|
123
|
+
port = connection["port"]
|
|
124
|
+
token = connection["token"]
|
|
125
|
+
raise "Ruby tool bridge is not initialized" unless port.is_a?(Integer) && token.is_a?(String)
|
|
126
|
+
|
|
127
|
+
uri = URI("http://127.0.0.1:#{port}#{path}")
|
|
128
|
+
request = Net::HTTP::Post.new(uri)
|
|
129
|
+
request["authorization"] = "Bearer #{token}"
|
|
130
|
+
request["content-type"] = "application/json"
|
|
131
|
+
request.body = JSON.generate(payload)
|
|
132
|
+
__senpi_emit_status("timeout-pause", force: true)
|
|
133
|
+
begin
|
|
134
|
+
response = Net::HTTP.start(uri.hostname, uri.port, open_timeout: 10, read_timeout: 60) { |http| http.request(request) }
|
|
135
|
+
ensure
|
|
136
|
+
__senpi_emit_status("timeout-resume", force: true)
|
|
137
|
+
end
|
|
138
|
+
body = JSON.parse(response.body.to_s)
|
|
139
|
+
return body["value"] if body.is_a?(Hash) && body["ok"] == true
|
|
140
|
+
|
|
141
|
+
error = body.is_a?(Hash) ? body["error"] : body
|
|
142
|
+
raise(error.is_a?(Hash) ? error["message"].to_s : error.to_s)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def __senpi_call_tool(name, args)
|
|
146
|
+
__senpi_bridge_request("/call", { "callId" => "rb-#{Process.pid}-#{rand(1_000_000)}", "toolName" => name, "args" => args })
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
class SenpiToolCallable
|
|
150
|
+
def initialize(name)
|
|
151
|
+
@name = name
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def call(args = nil, **kwargs)
|
|
155
|
+
merged = args.nil? ? {} : args.is_a?(Hash) ? args.transform_keys(&:to_s) : raise(ArgumentError, "tool.#{@name}(...) expects a Hash of arguments")
|
|
156
|
+
kwargs.each { |key, value| merged[key.to_s] = value }
|
|
157
|
+
__senpi_call_tool(@name, merged)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
class SenpiToolProxy < BasicObject
|
|
162
|
+
def method_missing(name, args = nil, **kwargs)
|
|
163
|
+
::SenpiToolCallable.new(name.to_s).call(args, **kwargs)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def [](name)
|
|
167
|
+
::SenpiToolCallable.new(name.to_s)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def respond_to_missing?(_name, _private = false)
|
|
171
|
+
true
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def tool
|
|
176
|
+
$__senpi_tool_proxy ||= SenpiToolProxy.new
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def completion(prompt, model: "default", system: nil, schema: nil, **kwargs)
|
|
180
|
+
options = { "model" => model }.merge(kwargs.transform_keys(&:to_s))
|
|
181
|
+
options["system"] = system unless system.nil?
|
|
182
|
+
options["schema"] = schema unless schema.nil?
|
|
183
|
+
result = __senpi_bridge_request("/completion", { "prompt" => prompt.to_s, "opts" => options })
|
|
184
|
+
return result unless result.is_a?(Hash)
|
|
185
|
+
return result["value"] if result.key?("value")
|
|
186
|
+
|
|
187
|
+
result.fetch("text", result)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def output(*ids, format: "raw", offset: nil, limit: nil)
|
|
191
|
+
raise ArgumentError, "At least one output ID is required" if ids.empty?
|
|
192
|
+
raise ArgumentError, "output() format must be 'raw' or 'tail'" unless ["raw", "tail"].include?(format)
|
|
193
|
+
args = { "ids" => ids.map(&:to_s), "format" => format }
|
|
194
|
+
args["offset"] = offset unless offset.nil?
|
|
195
|
+
args["limit"] = limit unless limit.nil?
|
|
196
|
+
__senpi_call_tool(SENPI_RESERVED_OUTPUT_TOOL, args)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, isolated: nil, apply: nil, merge: nil, handle: false)
|
|
200
|
+
args = { "prompt" => prompt.to_s, "agent" => agent }
|
|
201
|
+
{ "model" => model, "label" => label, "schema" => schema, "isolated" => isolated, "apply" => apply, "merge" => merge }.each do |key, value|
|
|
202
|
+
args[key] = value unless value.nil?
|
|
203
|
+
end
|
|
204
|
+
args["handle"] = true if handle
|
|
205
|
+
response = __senpi_call_tool(SENPI_RESERVED_AGENT_TOOL, args)
|
|
206
|
+
record = response.is_a?(Hash) ? response : {}
|
|
207
|
+
text_value = record.fetch("text", response)
|
|
208
|
+
result = schema.nil? ? text_value : record.key?("data") ? record["data"] : JSON.parse(text_value.to_s)
|
|
209
|
+
return result unless handle
|
|
210
|
+
{ "text" => text_value, "output" => text_value, "handle" => record["handle"] || (record["id"] && "agent://#{record["id"]}"), "id" => record["id"], "agent" => record.fetch("agent", agent) }.tap do |node|
|
|
211
|
+
node["data"] = result unless schema.nil?
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def __senpi_pool_map(items)
|
|
216
|
+
values = items.to_a
|
|
217
|
+
return [] if values.empty?
|
|
218
|
+
connection = $__senpi_connection
|
|
219
|
+
configured_width = connection.is_a?(Hash) ? connection["parallelPoolWidth"] : nil
|
|
220
|
+
width = configured_width.is_a?(Numeric) ? configured_width.to_i : 4
|
|
221
|
+
workers = [[width, 1].max, values.length].min
|
|
222
|
+
results = Array.new(values.length)
|
|
223
|
+
failures = {}
|
|
224
|
+
failure_mutex = Mutex.new
|
|
225
|
+
queue = Queue.new
|
|
226
|
+
values.each_index { |index| queue << index }
|
|
227
|
+
threads = workers.times.map do
|
|
228
|
+
Thread.new do
|
|
229
|
+
loop do
|
|
230
|
+
index = queue.pop(true) rescue nil
|
|
231
|
+
break if index.nil?
|
|
232
|
+
begin
|
|
233
|
+
results[index] = yield(values[index])
|
|
234
|
+
rescue Exception => error
|
|
235
|
+
failure_mutex.synchronize { failures[index] = error }
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
threads.each(&:join)
|
|
241
|
+
raise failures[failures.keys.min] unless failures.empty?
|
|
242
|
+
results
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def parallel(thunks)
|
|
246
|
+
values = thunks.to_a
|
|
247
|
+
values.each do |thunk|
|
|
248
|
+
raise TypeError, "parallel() expects an iterable of zero-arg callables" unless thunk.respond_to?(:call)
|
|
249
|
+
end
|
|
250
|
+
__senpi_pool_map(values) { |thunk| thunk.call }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def pipeline(items, *stages)
|
|
254
|
+
values = items.to_a
|
|
255
|
+
stages.each do |stage|
|
|
256
|
+
raise TypeError, "pipeline() stages must be callables" unless stage.respond_to?(:call)
|
|
257
|
+
values = __senpi_pool_map(values) { |value| stage.call(value) }
|
|
258
|
+
end
|
|
259
|
+
values
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def log(message)
|
|
263
|
+
__senpi_emit({ "type" => "log", "message" => message.to_s })
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def phase(title)
|
|
268
|
+
__senpi_emit({ "type" => "phase", "title" => title.to_s })
|
|
269
|
+
nil
|
|
270
|
+
end
|