wraptc 1.0.2 → 1.0.4
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/bin/wraptc +4 -4
- package/package.json +2 -2
- package/src/cli/__tests__/cli.test.ts +337 -0
- package/src/cli/index.ts +149 -0
- package/src/core/__tests__/fixtures/configs/project-config.json +14 -0
- package/src/core/__tests__/fixtures/configs/system-config.json +14 -0
- package/src/core/__tests__/fixtures/configs/user-config.json +15 -0
- package/src/core/__tests__/integration/integration.test.ts +241 -0
- package/src/core/__tests__/integration/mock-coder-adapter.test.ts +243 -0
- package/src/core/__tests__/test-utils.ts +136 -0
- package/src/core/__tests__/unit/adapters/runner.test.ts +302 -0
- package/src/core/__tests__/unit/basic-test.test.ts +44 -0
- package/src/core/__tests__/unit/basic.test.ts +12 -0
- package/src/core/__tests__/unit/config.test.ts +244 -0
- package/src/core/__tests__/unit/error-patterns.test.ts +181 -0
- package/src/core/__tests__/unit/memory-monitor.test.ts +354 -0
- package/src/core/__tests__/unit/plugin/registry.test.ts +356 -0
- package/src/core/__tests__/unit/providers/codex.test.ts +173 -0
- package/src/core/__tests__/unit/providers/configurable.test.ts +429 -0
- package/src/core/__tests__/unit/providers/gemini.test.ts +251 -0
- package/src/core/__tests__/unit/providers/opencode.test.ts +258 -0
- package/src/core/__tests__/unit/providers/qwen-code.test.ts +195 -0
- package/src/core/__tests__/unit/providers/simple-codex.test.ts +18 -0
- package/src/core/__tests__/unit/router.test.ts +967 -0
- package/src/core/__tests__/unit/state.test.ts +1079 -0
- package/src/core/__tests__/unit/unified/capabilities.test.ts +186 -0
- package/src/core/__tests__/unit/wrap-terminalcoder.test.ts +32 -0
- package/src/core/adapters/builtin/codex.ts +35 -0
- package/src/core/adapters/builtin/gemini.ts +34 -0
- package/src/core/adapters/builtin/index.ts +31 -0
- package/src/core/adapters/builtin/mock-coder.ts +148 -0
- package/src/core/adapters/builtin/qwen.ts +34 -0
- package/src/core/adapters/define.ts +48 -0
- package/src/core/adapters/index.ts +43 -0
- package/src/core/adapters/loader.ts +143 -0
- package/src/core/adapters/provider-bridge.ts +190 -0
- package/src/core/adapters/runner.ts +437 -0
- package/src/core/adapters/types.ts +172 -0
- package/src/core/config.ts +290 -0
- package/src/core/define-provider.ts +212 -0
- package/src/core/error-patterns.ts +147 -0
- package/src/core/index.ts +130 -0
- package/src/core/memory-monitor.ts +171 -0
- package/src/core/plugin/builtin.ts +87 -0
- package/src/core/plugin/index.ts +34 -0
- package/src/core/plugin/registry.ts +350 -0
- package/src/core/plugin/types.ts +209 -0
- package/src/core/provider-factory.ts +397 -0
- package/src/core/provider-loader.ts +171 -0
- package/src/core/providers/codex.ts +56 -0
- package/src/core/providers/configurable.ts +637 -0
- package/src/core/providers/custom.ts +261 -0
- package/src/core/providers/gemini.ts +41 -0
- package/src/core/providers/index.ts +383 -0
- package/src/core/providers/opencode.ts +168 -0
- package/src/core/providers/qwen-code.ts +41 -0
- package/src/core/router.ts +370 -0
- package/src/core/state.ts +258 -0
- package/src/core/types.ts +206 -0
- package/src/core/unified/capabilities.ts +184 -0
- package/src/core/unified/errors.ts +141 -0
- package/src/core/unified/index.ts +29 -0
- package/src/core/unified/output.ts +189 -0
- package/src/core/wrap-terminalcoder.ts +245 -0
- package/src/mcp/__tests__/server.test.ts +295 -0
- package/src/mcp/server.ts +284 -0
- package/src/test-fixtures/mock-coder.sh +194 -0
- package/dist/cli/index.js +0 -16501
- package/dist/core/index.js +0 -7531
- package/dist/mcp/server.js +0 -14568
- package/dist/wraptc-1.0.2.tgz +0 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { classifyErrorDefault } from "../error-patterns";
|
|
2
|
+
import type {
|
|
3
|
+
CodingEvent,
|
|
4
|
+
CodingRequest,
|
|
5
|
+
ProviderConfig,
|
|
6
|
+
ProviderErrorContext,
|
|
7
|
+
ProviderErrorKind,
|
|
8
|
+
} from "../types";
|
|
9
|
+
import { BaseProvider, type ProviderInvokeOptions } from "./index";
|
|
10
|
+
|
|
11
|
+
export class CustomProvider extends BaseProvider {
|
|
12
|
+
// Configurable output size limit (default 100MB)
|
|
13
|
+
private readonly maxOutputSize: number = 100 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
constructor(id: string, config: ProviderConfig) {
|
|
16
|
+
super(id, id, config);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async runOnce(
|
|
20
|
+
req: CodingRequest,
|
|
21
|
+
opts: ProviderInvokeOptions,
|
|
22
|
+
): Promise<{
|
|
23
|
+
text: string;
|
|
24
|
+
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
|
|
25
|
+
}> {
|
|
26
|
+
const args = this.buildArgs(req, opts);
|
|
27
|
+
const needsStdin = !this.config.argsTemplate || !args.some((arg) => arg.includes(req.prompt));
|
|
28
|
+
|
|
29
|
+
// Use Bun.spawn() for better performance
|
|
30
|
+
const proc = Bun.spawn([this.config.binary, ...args], {
|
|
31
|
+
cwd: opts?.cwd,
|
|
32
|
+
stdin: needsStdin && req.prompt ? "pipe" : "ignore",
|
|
33
|
+
stdout: "pipe",
|
|
34
|
+
stderr: "pipe",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Send prompt to stdin if not using template
|
|
38
|
+
if (needsStdin && req.prompt && proc.stdin) {
|
|
39
|
+
proc.stdin.write(req.prompt);
|
|
40
|
+
proc.stdin.end();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Set up abort handler
|
|
44
|
+
if (opts?.signal) {
|
|
45
|
+
opts.signal.addEventListener("abort", () => {
|
|
46
|
+
proc.kill();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Use array buffering instead of string concatenation
|
|
51
|
+
const stdoutChunks: string[] = [];
|
|
52
|
+
const stderrChunks: string[] = [];
|
|
53
|
+
let totalSize = 0;
|
|
54
|
+
|
|
55
|
+
// Read stdout with size limit
|
|
56
|
+
if (proc.stdout) {
|
|
57
|
+
const reader = proc.stdout.getReader();
|
|
58
|
+
const decoder = new TextDecoder();
|
|
59
|
+
try {
|
|
60
|
+
while (true) {
|
|
61
|
+
const { done, value } = await reader.read();
|
|
62
|
+
if (done) break;
|
|
63
|
+
const text = decoder.decode(value, { stream: true });
|
|
64
|
+
stdoutChunks.push(text);
|
|
65
|
+
totalSize += text.length;
|
|
66
|
+
if (totalSize > this.maxOutputSize) {
|
|
67
|
+
proc.kill();
|
|
68
|
+
throw new Error(`Output exceeded maximum size limit of ${this.maxOutputSize} bytes`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
reader.releaseLock();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Read stderr
|
|
77
|
+
if (proc.stderr) {
|
|
78
|
+
const reader = proc.stderr.getReader();
|
|
79
|
+
const decoder = new TextDecoder();
|
|
80
|
+
try {
|
|
81
|
+
while (true) {
|
|
82
|
+
const { done, value } = await reader.read();
|
|
83
|
+
if (done) break;
|
|
84
|
+
stderrChunks.push(decoder.decode(value, { stream: true }));
|
|
85
|
+
}
|
|
86
|
+
} finally {
|
|
87
|
+
reader.releaseLock();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Wait for process to exit
|
|
92
|
+
const exitCode = await proc.exited;
|
|
93
|
+
|
|
94
|
+
// Check if aborted
|
|
95
|
+
if (opts?.signal?.aborted) {
|
|
96
|
+
throw new Error("Aborted");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const stdout = stdoutChunks.join("");
|
|
100
|
+
const stderr = stderrChunks.join("");
|
|
101
|
+
|
|
102
|
+
if (exitCode === 0) {
|
|
103
|
+
if (this.config.jsonMode !== "none") {
|
|
104
|
+
return this.parseJsonOutput(stdout);
|
|
105
|
+
}
|
|
106
|
+
return { text: stdout };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
throw new Error(`${this.displayName} CLI failed with code ${exitCode}: ${stderr}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async *runStream(req: CodingRequest, opts: ProviderInvokeOptions): AsyncGenerator<CodingEvent> {
|
|
113
|
+
const requestId = crypto.randomUUID();
|
|
114
|
+
yield { type: "start", provider: this.id, requestId };
|
|
115
|
+
|
|
116
|
+
const args = this.buildArgs(req, opts);
|
|
117
|
+
const needsStdin =
|
|
118
|
+
!this.config.argsTemplate ||
|
|
119
|
+
!this.config.argsTemplate.some((arg) => arg.includes("{{prompt}}"));
|
|
120
|
+
|
|
121
|
+
// Use Bun.spawn() for streaming
|
|
122
|
+
const proc = Bun.spawn([this.config.binary, ...args], {
|
|
123
|
+
cwd: opts?.cwd,
|
|
124
|
+
stdin: needsStdin && req.prompt ? "pipe" : "ignore",
|
|
125
|
+
stdout: "pipe",
|
|
126
|
+
stderr: "pipe",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Send prompt to stdin if not using template
|
|
130
|
+
if (needsStdin && req.prompt && proc.stdin) {
|
|
131
|
+
proc.stdin.write(req.prompt);
|
|
132
|
+
proc.stdin.end();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Use array buffering for fullText instead of string concatenation
|
|
136
|
+
const fullTextChunks: string[] = [];
|
|
137
|
+
// Line buffer for handling partial lines in JSONL mode
|
|
138
|
+
let lineBuffer = "";
|
|
139
|
+
|
|
140
|
+
// Stream stdout directly without accumulating
|
|
141
|
+
if (proc.stdout) {
|
|
142
|
+
const reader = proc.stdout.getReader();
|
|
143
|
+
const decoder = new TextDecoder();
|
|
144
|
+
try {
|
|
145
|
+
while (true) {
|
|
146
|
+
const { done, value } = await reader.read();
|
|
147
|
+
if (done) break;
|
|
148
|
+
const text = decoder.decode(value, { stream: true });
|
|
149
|
+
fullTextChunks.push(text);
|
|
150
|
+
|
|
151
|
+
if (this.config.streamingMode === "jsonl") {
|
|
152
|
+
// Buffer partial lines and emit complete lines
|
|
153
|
+
lineBuffer += text;
|
|
154
|
+
const lines = lineBuffer.split("\n");
|
|
155
|
+
// Keep the last (potentially incomplete) line in the buffer
|
|
156
|
+
lineBuffer = lines.pop() ?? "";
|
|
157
|
+
|
|
158
|
+
for (const line of lines.filter(Boolean)) {
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(line);
|
|
161
|
+
yield { type: "chunk", data: parsed };
|
|
162
|
+
} catch {
|
|
163
|
+
yield { type: "text_delta", text: line };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} else if (this.config.streamingMode === "line") {
|
|
167
|
+
// Emit each line as a text_delta
|
|
168
|
+
const lines = text.split("\n");
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
if (line.trim()) {
|
|
171
|
+
yield { type: "text_delta", text: `${line}\n` };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
yield { type: "text_delta", text };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
reader.releaseLock();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Emit any remaining buffered line
|
|
184
|
+
if (lineBuffer.trim()) {
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(lineBuffer);
|
|
187
|
+
yield { type: "chunk", data: parsed };
|
|
188
|
+
} catch {
|
|
189
|
+
yield { type: "text_delta", text: lineBuffer };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Collect stderr with array buffering (with 1MB limit)
|
|
194
|
+
const stderrChunks: string[] = [];
|
|
195
|
+
let stderrSize = 0;
|
|
196
|
+
const maxStderrSize = 1024 * 1024; // 1MB limit
|
|
197
|
+
|
|
198
|
+
if (proc.stderr) {
|
|
199
|
+
const reader = proc.stderr.getReader();
|
|
200
|
+
const decoder = new TextDecoder();
|
|
201
|
+
try {
|
|
202
|
+
while (true) {
|
|
203
|
+
const { done, value } = await reader.read();
|
|
204
|
+
if (done) break;
|
|
205
|
+
const text = decoder.decode(value, { stream: true });
|
|
206
|
+
if (stderrSize + text.length <= maxStderrSize) {
|
|
207
|
+
stderrChunks.push(text);
|
|
208
|
+
stderrSize += text.length;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} finally {
|
|
212
|
+
reader.releaseLock();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Wait for process to exit
|
|
217
|
+
const exitCode = await proc.exited;
|
|
218
|
+
const fullText = fullTextChunks.join("");
|
|
219
|
+
const stderr = stderrChunks.join("");
|
|
220
|
+
|
|
221
|
+
if (exitCode === 0) {
|
|
222
|
+
const result =
|
|
223
|
+
this.config.jsonMode !== "none" ? this.parseJsonOutput(fullText) : { text: fullText };
|
|
224
|
+
yield { type: "complete", provider: this.id, text: result.text, usage: result.usage };
|
|
225
|
+
} else {
|
|
226
|
+
yield {
|
|
227
|
+
type: "error",
|
|
228
|
+
provider: this.id,
|
|
229
|
+
code: this.classifyError({ stderr, exitCode: exitCode || undefined }),
|
|
230
|
+
message: stderr || `Process exited with code ${exitCode}`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
classifyError(error: ProviderErrorContext): ProviderErrorKind {
|
|
236
|
+
// Use the shared error classifier from error-patterns.ts
|
|
237
|
+
return classifyErrorDefault(error.stderr, error.stdout, error.exitCode, error.httpStatus);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
protected buildArgs(req: CodingRequest, opts: ProviderInvokeOptions): string[] {
|
|
241
|
+
// If argsTemplate is provided, use it with variable substitution
|
|
242
|
+
if (this.config.argsTemplate && this.config.argsTemplate.length > 0) {
|
|
243
|
+
return this.config.argsTemplate.map((arg) => {
|
|
244
|
+
return arg
|
|
245
|
+
.replace("{{prompt}}", req.prompt)
|
|
246
|
+
.replace("{{mode}}", req.mode || "generate")
|
|
247
|
+
.replace("{{language}}", req.language || "")
|
|
248
|
+
.replace("{{temperature}}", req.temperature?.toString() || "0.7");
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Otherwise use standard args with optional JSON flag
|
|
253
|
+
const args = [...this.config.args];
|
|
254
|
+
|
|
255
|
+
if (this.config.jsonMode === "flag" && this.config.jsonFlag) {
|
|
256
|
+
args.push(this.config.jsonFlag);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return args;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GeminiProvider - Simplified provider using ProcessProvider defaults
|
|
3
|
+
*
|
|
4
|
+
* Gemini CLI uses:
|
|
5
|
+
* - Positional prompt: gemini [options] <prompt>
|
|
6
|
+
* - JSON output flag: -o json
|
|
7
|
+
*/
|
|
8
|
+
import type { CodingRequest, ProviderConfig, ProviderInvokeOptions } from "../types";
|
|
9
|
+
import { ProcessProvider } from "./index";
|
|
10
|
+
|
|
11
|
+
export class GeminiProvider extends ProcessProvider {
|
|
12
|
+
constructor(config: ProviderConfig) {
|
|
13
|
+
super("gemini", "Gemini CLI", {
|
|
14
|
+
...config,
|
|
15
|
+
binary: config.binary || "gemini",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Gemini uses positional prompt (not stdin), so override getStdinInput
|
|
21
|
+
*/
|
|
22
|
+
protected getStdinInput(): string | undefined {
|
|
23
|
+
return undefined; // Gemini uses positional prompt
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build args: gemini -o json [...args] <prompt>
|
|
28
|
+
*/
|
|
29
|
+
protected buildArgs(req: CodingRequest, _opts: ProviderInvokeOptions): string[] {
|
|
30
|
+
const args: string[] = [];
|
|
31
|
+
|
|
32
|
+
if (this.config.jsonMode === "flag") {
|
|
33
|
+
args.push("-o", "json");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
args.push(...this.config.args);
|
|
37
|
+
args.push(req.prompt);
|
|
38
|
+
|
|
39
|
+
return args;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { DEFAULT_ERROR_PATTERNS, classifyErrorDefault } from "../error-patterns";
|
|
3
|
+
import type {
|
|
4
|
+
CodingEvent,
|
|
5
|
+
CodingRequest,
|
|
6
|
+
CodingResponse,
|
|
7
|
+
ProviderConfig,
|
|
8
|
+
ProviderErrorContext,
|
|
9
|
+
ProviderErrorKind,
|
|
10
|
+
ProviderInfo,
|
|
11
|
+
ProviderInvokeOptions,
|
|
12
|
+
TokenUsage,
|
|
13
|
+
} from "../types";
|
|
14
|
+
|
|
15
|
+
// Re-export ProviderInvokeOptions for convenience
|
|
16
|
+
export type { ProviderInvokeOptions };
|
|
17
|
+
|
|
18
|
+
export interface Provider {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly displayName: string;
|
|
21
|
+
readonly supportsStreaming: boolean;
|
|
22
|
+
readonly prefersJson: boolean;
|
|
23
|
+
readonly capabilities?: string[];
|
|
24
|
+
|
|
25
|
+
runOnce(
|
|
26
|
+
req: CodingRequest,
|
|
27
|
+
opts: ProviderInvokeOptions,
|
|
28
|
+
): Promise<{
|
|
29
|
+
text: string;
|
|
30
|
+
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
|
|
31
|
+
}>;
|
|
32
|
+
|
|
33
|
+
runStream(req: CodingRequest, opts: ProviderInvokeOptions): AsyncGenerator<CodingEvent>;
|
|
34
|
+
|
|
35
|
+
classifyError(error: ProviderErrorContext): ProviderErrorKind;
|
|
36
|
+
|
|
37
|
+
getInfo(): ProviderInfo;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export abstract class BaseProvider implements Provider {
|
|
41
|
+
public readonly id: string;
|
|
42
|
+
public readonly displayName: string;
|
|
43
|
+
public readonly supportsStreaming: boolean;
|
|
44
|
+
public readonly prefersJson: boolean;
|
|
45
|
+
public readonly capabilities?: string[];
|
|
46
|
+
protected config: ProviderConfig;
|
|
47
|
+
|
|
48
|
+
constructor(id: string, displayName: string, config: ProviderConfig) {
|
|
49
|
+
this.id = id;
|
|
50
|
+
this.displayName = displayName;
|
|
51
|
+
this.config = config;
|
|
52
|
+
this.supportsStreaming = config.streamingMode !== "none";
|
|
53
|
+
this.prefersJson = config.jsonMode !== "none";
|
|
54
|
+
this.capabilities = config.capabilities;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// These are implemented in ProcessProvider with defaults
|
|
58
|
+
// Subclasses can override for custom behavior
|
|
59
|
+
abstract runOnce(
|
|
60
|
+
req: CodingRequest,
|
|
61
|
+
opts: ProviderInvokeOptions,
|
|
62
|
+
): Promise<{ text: string; usage?: TokenUsage }>;
|
|
63
|
+
|
|
64
|
+
abstract runStream(req: CodingRequest, opts: ProviderInvokeOptions): AsyncGenerator<CodingEvent>;
|
|
65
|
+
|
|
66
|
+
abstract classifyError(error: ProviderErrorContext): ProviderErrorKind;
|
|
67
|
+
|
|
68
|
+
getInfo(): ProviderInfo {
|
|
69
|
+
return {
|
|
70
|
+
id: this.id,
|
|
71
|
+
displayName: this.displayName,
|
|
72
|
+
supportsStreaming: this.supportsStreaming,
|
|
73
|
+
prefersJson: this.prefersJson,
|
|
74
|
+
capabilities: this.capabilities,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
protected async ensureConfigDir(): Promise<string> {
|
|
79
|
+
const configDir = join(process.env.HOME || "~", ".config", "wrap-terminalcoder");
|
|
80
|
+
// Use Bun's built-in mkdir via shell
|
|
81
|
+
await Bun.$`mkdir -p ${configDir}`.quiet();
|
|
82
|
+
return configDir;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
protected buildArgs(req: CodingRequest, opts: ProviderInvokeOptions): string[] {
|
|
86
|
+
const args = [...this.config.args];
|
|
87
|
+
|
|
88
|
+
if (this.config.argsTemplate) {
|
|
89
|
+
return this.config.argsTemplate.map((arg) => {
|
|
90
|
+
return arg.replace("{{prompt}}", req.prompt);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (this.config.jsonMode === "flag" && this.config.jsonFlag) {
|
|
95
|
+
args.push(this.config.jsonFlag);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return args;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
protected parseJsonOutput(stdout: string): {
|
|
102
|
+
text: string;
|
|
103
|
+
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
|
|
104
|
+
} {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(stdout);
|
|
107
|
+
return {
|
|
108
|
+
text: parsed.text || parsed.response || parsed.output || stdout,
|
|
109
|
+
usage: parsed.usage,
|
|
110
|
+
};
|
|
111
|
+
} catch {
|
|
112
|
+
return { text: stdout };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export abstract class ProcessProvider extends BaseProvider {
|
|
118
|
+
protected readonly binaryPath: string;
|
|
119
|
+
// Configurable output size limit (default 100MB)
|
|
120
|
+
protected readonly maxOutputSize: number = 100 * 1024 * 1024;
|
|
121
|
+
|
|
122
|
+
constructor(id: string, displayName: string, config: ProviderConfig) {
|
|
123
|
+
super(id, displayName, config);
|
|
124
|
+
this.binaryPath = config.binary;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check if the binary exists before attempting to spawn a process.
|
|
129
|
+
* This prevents hanging when the binary is not found.
|
|
130
|
+
*/
|
|
131
|
+
protected async checkBinaryExists(): Promise<void> {
|
|
132
|
+
try {
|
|
133
|
+
await Bun.$`which ${this.binaryPath}`.quiet();
|
|
134
|
+
} catch {
|
|
135
|
+
throw new Error(`Binary not found: ${this.binaryPath}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
protected async executeProcess(
|
|
140
|
+
args: string[],
|
|
141
|
+
input?: string,
|
|
142
|
+
opts?: ProviderInvokeOptions,
|
|
143
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
|
144
|
+
// Check if binary exists first to avoid hanging on non-existent binaries
|
|
145
|
+
await this.checkBinaryExists();
|
|
146
|
+
|
|
147
|
+
// Use Bun.spawn() for better performance
|
|
148
|
+
const proc = Bun.spawn([this.binaryPath, ...args], {
|
|
149
|
+
cwd: opts?.cwd,
|
|
150
|
+
stdin: input ? "pipe" : "ignore",
|
|
151
|
+
stdout: "pipe",
|
|
152
|
+
stderr: "pipe",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Write input to stdin if provided
|
|
156
|
+
if (input && proc.stdin) {
|
|
157
|
+
proc.stdin.write(input);
|
|
158
|
+
proc.stdin.end();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Set up abort handler
|
|
162
|
+
if (opts?.signal) {
|
|
163
|
+
opts.signal.addEventListener("abort", () => {
|
|
164
|
+
proc.kill();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Read stdout and stderr using Bun's streaming APIs
|
|
169
|
+
const stdoutChunks: string[] = [];
|
|
170
|
+
const stderrChunks: string[] = [];
|
|
171
|
+
let totalSize = 0;
|
|
172
|
+
|
|
173
|
+
// Read stdout
|
|
174
|
+
if (proc.stdout) {
|
|
175
|
+
const reader = proc.stdout.getReader();
|
|
176
|
+
const decoder = new TextDecoder();
|
|
177
|
+
try {
|
|
178
|
+
while (true) {
|
|
179
|
+
const { done, value } = await reader.read();
|
|
180
|
+
if (done) break;
|
|
181
|
+
const text = decoder.decode(value, { stream: true });
|
|
182
|
+
stdoutChunks.push(text);
|
|
183
|
+
totalSize += text.length;
|
|
184
|
+
if (totalSize > this.maxOutputSize) {
|
|
185
|
+
proc.kill();
|
|
186
|
+
throw new Error(`Output exceeded maximum size limit of ${this.maxOutputSize} bytes`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
reader.releaseLock();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Read stderr
|
|
195
|
+
if (proc.stderr) {
|
|
196
|
+
const reader = proc.stderr.getReader();
|
|
197
|
+
const decoder = new TextDecoder();
|
|
198
|
+
try {
|
|
199
|
+
while (true) {
|
|
200
|
+
const { done, value } = await reader.read();
|
|
201
|
+
if (done) break;
|
|
202
|
+
stderrChunks.push(decoder.decode(value, { stream: true }));
|
|
203
|
+
}
|
|
204
|
+
} finally {
|
|
205
|
+
reader.releaseLock();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Wait for process to exit
|
|
210
|
+
const exitCode = await proc.exited;
|
|
211
|
+
|
|
212
|
+
// Check if aborted
|
|
213
|
+
if (opts?.signal?.aborted) {
|
|
214
|
+
throw new Error("Aborted");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
stdout: stdoutChunks.join(""),
|
|
219
|
+
stderr: stderrChunks.join(""),
|
|
220
|
+
exitCode,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
protected async *streamProcess(
|
|
225
|
+
args: string[],
|
|
226
|
+
input?: string,
|
|
227
|
+
opts?: ProviderInvokeOptions,
|
|
228
|
+
): AsyncGenerator<{ type: "stdout" | "stderr"; data: string }> {
|
|
229
|
+
// Check if binary exists first to avoid hanging on non-existent binaries
|
|
230
|
+
await this.checkBinaryExists();
|
|
231
|
+
|
|
232
|
+
// Use Bun.spawn() for streaming
|
|
233
|
+
const proc = Bun.spawn([this.binaryPath, ...args], {
|
|
234
|
+
cwd: opts?.cwd,
|
|
235
|
+
stdin: input ? "pipe" : "ignore",
|
|
236
|
+
stdout: "pipe",
|
|
237
|
+
stderr: "pipe",
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Write input to stdin if provided
|
|
241
|
+
if (input && proc.stdin) {
|
|
242
|
+
proc.stdin.write(input);
|
|
243
|
+
proc.stdin.end();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Stream stdout chunks directly without accumulation
|
|
247
|
+
if (proc.stdout) {
|
|
248
|
+
const reader = proc.stdout.getReader();
|
|
249
|
+
const decoder = new TextDecoder();
|
|
250
|
+
try {
|
|
251
|
+
while (true) {
|
|
252
|
+
const { done, value } = await reader.read();
|
|
253
|
+
if (done) break;
|
|
254
|
+
yield { type: "stdout", data: decoder.decode(value, { stream: true }) };
|
|
255
|
+
}
|
|
256
|
+
} finally {
|
|
257
|
+
reader.releaseLock();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Collect stderr for error reporting (with 1MB limit)
|
|
262
|
+
const stderrChunks: string[] = [];
|
|
263
|
+
let stderrSize = 0;
|
|
264
|
+
const maxStderrSize = 1024 * 1024; // 1MB limit
|
|
265
|
+
|
|
266
|
+
if (proc.stderr) {
|
|
267
|
+
const reader = proc.stderr.getReader();
|
|
268
|
+
const decoder = new TextDecoder();
|
|
269
|
+
try {
|
|
270
|
+
while (true) {
|
|
271
|
+
const { done, value } = await reader.read();
|
|
272
|
+
if (done) break;
|
|
273
|
+
const text = decoder.decode(value, { stream: true });
|
|
274
|
+
if (stderrSize + text.length <= maxStderrSize) {
|
|
275
|
+
stderrChunks.push(text);
|
|
276
|
+
stderrSize += text.length;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} finally {
|
|
280
|
+
reader.releaseLock();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (stderrChunks.length > 0) {
|
|
285
|
+
yield { type: "stderr", data: stderrChunks.join("") };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Wait for process to complete
|
|
289
|
+
await proc.exited;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Default runOnce implementation.
|
|
294
|
+
* Subclasses can override for custom behavior.
|
|
295
|
+
*/
|
|
296
|
+
async runOnce(
|
|
297
|
+
req: CodingRequest,
|
|
298
|
+
opts: ProviderInvokeOptions,
|
|
299
|
+
): Promise<{ text: string; usage?: TokenUsage }> {
|
|
300
|
+
const args = this.buildArgs(req, opts);
|
|
301
|
+
const input = this.getStdinInput(req);
|
|
302
|
+
const result = await this.executeProcess(args, input, opts);
|
|
303
|
+
|
|
304
|
+
if (result.exitCode !== 0) {
|
|
305
|
+
throw new Error(`${this.displayName} failed with code ${result.exitCode}: ${result.stderr}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return this.parseOutput(result.stdout);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Default runStream implementation.
|
|
313
|
+
* Subclasses can override for custom behavior.
|
|
314
|
+
*/
|
|
315
|
+
async *runStream(req: CodingRequest, opts: ProviderInvokeOptions): AsyncGenerator<CodingEvent> {
|
|
316
|
+
const requestId = crypto.randomUUID();
|
|
317
|
+
yield { type: "start", provider: this.id, requestId };
|
|
318
|
+
|
|
319
|
+
const args = this.buildArgs(req, opts);
|
|
320
|
+
const input = this.getStdinInput(req);
|
|
321
|
+
const fullTextChunks: string[] = [];
|
|
322
|
+
|
|
323
|
+
for await (const chunk of this.streamProcess(args, input, opts)) {
|
|
324
|
+
if (chunk.type === "stdout") {
|
|
325
|
+
const text = chunk.data;
|
|
326
|
+
fullTextChunks.push(text);
|
|
327
|
+
|
|
328
|
+
if (this.config.streamingMode === "jsonl") {
|
|
329
|
+
// Try to parse as JSONL
|
|
330
|
+
for (const line of text.split("\n").filter(Boolean)) {
|
|
331
|
+
try {
|
|
332
|
+
const parsed = JSON.parse(line);
|
|
333
|
+
yield { type: "chunk", data: parsed };
|
|
334
|
+
} catch {
|
|
335
|
+
yield { type: "text_delta", text: line };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
yield { type: "text_delta", text };
|
|
340
|
+
}
|
|
341
|
+
} else if (chunk.type === "stderr") {
|
|
342
|
+
yield {
|
|
343
|
+
type: "error",
|
|
344
|
+
provider: this.id,
|
|
345
|
+
code: this.classifyError({ stderr: chunk.data }),
|
|
346
|
+
message: chunk.data,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const fullText = fullTextChunks.join("");
|
|
352
|
+
const result = this.parseOutput(fullText);
|
|
353
|
+
yield { type: "complete", provider: this.id, text: result.text, usage: result.usage };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Default error classification using shared classifyErrorDefault.
|
|
358
|
+
* Subclasses can override for provider-specific patterns.
|
|
359
|
+
*/
|
|
360
|
+
classifyError(error: ProviderErrorContext): ProviderErrorKind {
|
|
361
|
+
return classifyErrorDefault(error.stderr, error.stdout, error.exitCode, error.httpStatus);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Get stdin input for the process.
|
|
366
|
+
* Override in subclass if needed.
|
|
367
|
+
*/
|
|
368
|
+
protected getStdinInput(req: CodingRequest): string | undefined {
|
|
369
|
+
// Default: pipe prompt to stdin
|
|
370
|
+
return req.prompt;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Parse output from the process.
|
|
375
|
+
* Override in subclass if needed.
|
|
376
|
+
*/
|
|
377
|
+
protected parseOutput(stdout: string): { text: string; usage?: TokenUsage } {
|
|
378
|
+
if (this.config.jsonMode !== "none") {
|
|
379
|
+
return this.parseJsonOutput(stdout);
|
|
380
|
+
}
|
|
381
|
+
return { text: stdout.trim() };
|
|
382
|
+
}
|
|
383
|
+
}
|