multiarena 0.1.0
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/dist/config/loader.d.ts +6 -0
- package/dist/config/loader.js +69 -0
- package/dist/config/types.d.ts +15 -0
- package/dist/config/types.js +6 -0
- package/dist/core/session.d.ts +40 -0
- package/dist/core/session.js +155 -0
- package/dist/core/turn.d.ts +31 -0
- package/dist/core/turn.js +112 -0
- package/dist/core/types.d.ts +25 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +76 -0
- package/dist/isolation/worktree.d.ts +11 -0
- package/dist/isolation/worktree.js +117 -0
- package/dist/persistence/session.d.ts +17 -0
- package/dist/persistence/session.js +27 -0
- package/dist/provider/adapters/anthropic.d.ts +11 -0
- package/dist/provider/adapters/anthropic.js +146 -0
- package/dist/provider/adapters/google.d.ts +11 -0
- package/dist/provider/adapters/google.js +177 -0
- package/dist/provider/adapters/ollama.d.ts +11 -0
- package/dist/provider/adapters/ollama.js +147 -0
- package/dist/provider/adapters/openai.d.ts +11 -0
- package/dist/provider/adapters/openai.js +167 -0
- package/dist/provider/provider.d.ts +7 -0
- package/dist/provider/provider.js +21 -0
- package/dist/provider/types.d.ts +41 -0
- package/dist/provider/types.js +1 -0
- package/dist/tools/builtin/bash.d.ts +2 -0
- package/dist/tools/builtin/bash.js +34 -0
- package/dist/tools/builtin/editFile.d.ts +2 -0
- package/dist/tools/builtin/editFile.js +40 -0
- package/dist/tools/builtin/glob.d.ts +2 -0
- package/dist/tools/builtin/glob.js +77 -0
- package/dist/tools/builtin/grep.d.ts +2 -0
- package/dist/tools/builtin/grep.js +120 -0
- package/dist/tools/builtin/readFile.d.ts +2 -0
- package/dist/tools/builtin/readFile.js +27 -0
- package/dist/tools/builtin/writeFile.d.ts +2 -0
- package/dist/tools/builtin/writeFile.js +29 -0
- package/dist/tools/permission.d.ts +7 -0
- package/dist/tools/permission.js +31 -0
- package/dist/tools/registry.d.ts +9 -0
- package/dist/tools/registry.js +37 -0
- package/dist/tools/types.d.ts +11 -0
- package/dist/tools/types.js +1 -0
- package/dist/ui/app.d.ts +4 -0
- package/dist/ui/app.js +343 -0
- package/dist/ui/components/BroadcastSummary.d.ts +7 -0
- package/dist/ui/components/BroadcastSummary.js +18 -0
- package/dist/ui/components/InputBar.d.ts +9 -0
- package/dist/ui/components/InputBar.js +11 -0
- package/dist/ui/components/ModelDetail.d.ts +8 -0
- package/dist/ui/components/ModelDetail.js +13 -0
- package/dist/ui/components/OutputArea.d.ts +15 -0
- package/dist/ui/components/OutputArea.js +29 -0
- package/dist/ui/components/StatusBar.d.ts +9 -0
- package/dist/ui/components/StatusBar.js +51 -0
- package/package.json +60 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
const DEFAULT_MODEL = "gpt-4o";
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
4
|
+
export class OpenAIProvider {
|
|
5
|
+
client;
|
|
6
|
+
activeController = null;
|
|
7
|
+
constructor(apiKey, baseURL) {
|
|
8
|
+
this.client = new OpenAI({ apiKey, baseURL, timeout: DEFAULT_TIMEOUT_MS, maxRetries: 2 });
|
|
9
|
+
}
|
|
10
|
+
async *chat(request) {
|
|
11
|
+
const abortController = new AbortController();
|
|
12
|
+
this.activeController = abortController;
|
|
13
|
+
let inputTokens = 0;
|
|
14
|
+
let outputTokens = 0;
|
|
15
|
+
try {
|
|
16
|
+
const stream = await this.client.chat.completions.create({
|
|
17
|
+
model: request.model || DEFAULT_MODEL,
|
|
18
|
+
messages: this.convertMessages(request.messages, request.system),
|
|
19
|
+
tools: this.convertTools(request.tools),
|
|
20
|
+
stream: true,
|
|
21
|
+
stream_options: { include_usage: true },
|
|
22
|
+
}, { signal: abortController.signal });
|
|
23
|
+
// Track pending tool calls across stream chunks
|
|
24
|
+
const pendingToolCalls = new Map();
|
|
25
|
+
let doneYielded = false;
|
|
26
|
+
for await (const chunk of stream) {
|
|
27
|
+
// Token usage chunk (when stream_options.include_usage is true)
|
|
28
|
+
if (chunk.usage) {
|
|
29
|
+
inputTokens = chunk.usage.prompt_tokens;
|
|
30
|
+
outputTokens = chunk.usage.completion_tokens;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const choice = chunk.choices?.[0];
|
|
34
|
+
if (!choice)
|
|
35
|
+
continue;
|
|
36
|
+
const delta = choice.delta;
|
|
37
|
+
// Accumulate tool call deltas
|
|
38
|
+
if (delta.tool_calls) {
|
|
39
|
+
for (const tc of delta.tool_calls) {
|
|
40
|
+
const idx = tc.index;
|
|
41
|
+
const existing = pendingToolCalls.get(idx) ?? {
|
|
42
|
+
id: "",
|
|
43
|
+
name: "",
|
|
44
|
+
args: "",
|
|
45
|
+
};
|
|
46
|
+
if (tc.id)
|
|
47
|
+
existing.id = tc.id;
|
|
48
|
+
if (tc.function?.name)
|
|
49
|
+
existing.name += tc.function.name;
|
|
50
|
+
if (tc.function?.arguments)
|
|
51
|
+
existing.args += tc.function.arguments;
|
|
52
|
+
pendingToolCalls.set(idx, existing);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Text delta
|
|
56
|
+
if (delta.content) {
|
|
57
|
+
yield { type: "text", content: delta.content };
|
|
58
|
+
}
|
|
59
|
+
// On finish_reason === "tool_calls", emit all pending tool_calls
|
|
60
|
+
if (choice.finish_reason === "tool_calls") {
|
|
61
|
+
for (const [, tc] of pendingToolCalls) {
|
|
62
|
+
yield {
|
|
63
|
+
type: "tool_call",
|
|
64
|
+
id: tc.id,
|
|
65
|
+
name: tc.name,
|
|
66
|
+
args: tc.args,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
pendingToolCalls.clear();
|
|
70
|
+
yield {
|
|
71
|
+
type: "done",
|
|
72
|
+
usage: { input: inputTokens, output: outputTokens },
|
|
73
|
+
};
|
|
74
|
+
doneYielded = true;
|
|
75
|
+
}
|
|
76
|
+
// handle stop
|
|
77
|
+
if (choice.finish_reason === "stop") {
|
|
78
|
+
yield {
|
|
79
|
+
type: "done",
|
|
80
|
+
usage: { input: inputTokens, output: outputTokens },
|
|
81
|
+
};
|
|
82
|
+
doneYielded = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Ensure done is always yielded (e.g. when stream ends on usage chunk)
|
|
86
|
+
if (!doneYielded) {
|
|
87
|
+
yield {
|
|
88
|
+
type: "done",
|
|
89
|
+
usage: { input: inputTokens, output: outputTokens },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error instanceof Error &&
|
|
95
|
+
(error.name === "AbortError" || error.name === "APIUserAbortError")) {
|
|
96
|
+
return; // Aborted silently
|
|
97
|
+
}
|
|
98
|
+
yield {
|
|
99
|
+
type: "error",
|
|
100
|
+
message: error instanceof Error ? error.message : String(error),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
this.activeController = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
abort() {
|
|
108
|
+
this.activeController?.abort();
|
|
109
|
+
}
|
|
110
|
+
// ── Message conversion ──────────────────────────────────────────────
|
|
111
|
+
convertMessages(messages, system) {
|
|
112
|
+
const result = [];
|
|
113
|
+
// Prepend system message if provided
|
|
114
|
+
if (system) {
|
|
115
|
+
result.push({ role: "system", content: system });
|
|
116
|
+
}
|
|
117
|
+
for (const msg of messages) {
|
|
118
|
+
switch (msg.role) {
|
|
119
|
+
case "user":
|
|
120
|
+
result.push({ role: "user", content: msg.content });
|
|
121
|
+
break;
|
|
122
|
+
case "assistant": {
|
|
123
|
+
const toolCalls = msg.tool_calls?.map((tc) => ({
|
|
124
|
+
id: tc.id,
|
|
125
|
+
type: "function",
|
|
126
|
+
function: {
|
|
127
|
+
name: tc.name,
|
|
128
|
+
arguments: tc.arguments,
|
|
129
|
+
},
|
|
130
|
+
}));
|
|
131
|
+
if (toolCalls && toolCalls.length > 0) {
|
|
132
|
+
result.push({
|
|
133
|
+
role: "assistant",
|
|
134
|
+
content: msg.content || null,
|
|
135
|
+
tool_calls: toolCalls,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
result.push({ role: "assistant", content: msg.content });
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "tool":
|
|
144
|
+
result.push({
|
|
145
|
+
role: "tool",
|
|
146
|
+
tool_call_id: msg.tool_call_id ?? "",
|
|
147
|
+
content: msg.content,
|
|
148
|
+
});
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
// ── Tool conversion ─────────────────────────────────────────────────
|
|
155
|
+
convertTools(tools) {
|
|
156
|
+
if (!tools || tools.length === 0)
|
|
157
|
+
return undefined;
|
|
158
|
+
return tools.map((t) => ({
|
|
159
|
+
type: "function",
|
|
160
|
+
function: {
|
|
161
|
+
name: t.name,
|
|
162
|
+
description: t.description,
|
|
163
|
+
parameters: t.parameters,
|
|
164
|
+
},
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ChatRequest, StreamEvent } from "./types.js";
|
|
2
|
+
import { ModelConfig } from "../config/types.js";
|
|
3
|
+
export interface Provider {
|
|
4
|
+
chat(request: ChatRequest): AsyncGenerator<StreamEvent>;
|
|
5
|
+
abort(): void;
|
|
6
|
+
}
|
|
7
|
+
export declare function createProvider(config: ModelConfig): Provider;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { AnthropicProvider } from "./adapters/anthropic.js";
|
|
2
|
+
import { OpenAIProvider } from "./adapters/openai.js";
|
|
3
|
+
import { GoogleProvider } from "./adapters/google.js";
|
|
4
|
+
import { OllamaProvider } from "./adapters/ollama.js";
|
|
5
|
+
export function createProvider(config) {
|
|
6
|
+
const key = config.api_key ?? "";
|
|
7
|
+
switch (config.provider) {
|
|
8
|
+
case "anthropic":
|
|
9
|
+
return new AnthropicProvider(key);
|
|
10
|
+
case "openai":
|
|
11
|
+
return new OpenAIProvider(key, config.endpoint);
|
|
12
|
+
case "google":
|
|
13
|
+
return new GoogleProvider(key);
|
|
14
|
+
case "ollama": {
|
|
15
|
+
const baseURL = config.endpoint?.replace(/\/v1\/?$/, "") ?? "http://localhost:11434";
|
|
16
|
+
return new OllamaProvider(baseURL);
|
|
17
|
+
}
|
|
18
|
+
default:
|
|
19
|
+
throw new Error(`Unknown provider: ${config.provider}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface Message {
|
|
2
|
+
role: "user" | "assistant" | "tool";
|
|
3
|
+
content: string;
|
|
4
|
+
tool_call_id?: string;
|
|
5
|
+
tool_calls?: ToolCall[];
|
|
6
|
+
}
|
|
7
|
+
export interface ToolCall {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
arguments: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ToolDef {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
parameters: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
export interface TokenUsage {
|
|
18
|
+
input: number;
|
|
19
|
+
output: number;
|
|
20
|
+
}
|
|
21
|
+
export type StreamEvent = {
|
|
22
|
+
type: "text";
|
|
23
|
+
content: string;
|
|
24
|
+
} | {
|
|
25
|
+
type: "tool_call";
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
args: string;
|
|
29
|
+
} | {
|
|
30
|
+
type: "done";
|
|
31
|
+
usage: TokenUsage;
|
|
32
|
+
} | {
|
|
33
|
+
type: "error";
|
|
34
|
+
message: string;
|
|
35
|
+
};
|
|
36
|
+
export interface ChatRequest {
|
|
37
|
+
messages: Message[];
|
|
38
|
+
tools?: ToolDef[];
|
|
39
|
+
system?: string;
|
|
40
|
+
model?: string;
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
export const bashTool = {
|
|
3
|
+
definition: {
|
|
4
|
+
name: "bash",
|
|
5
|
+
description: "Execute a shell command",
|
|
6
|
+
parameters: {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {
|
|
9
|
+
command: { type: "string", description: "Shell command to execute" },
|
|
10
|
+
},
|
|
11
|
+
required: ["command"],
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
async execute(args, worktreePath) {
|
|
15
|
+
const command = args.command;
|
|
16
|
+
const dangerous = ["rm -rf /", "sudo ", "mkfs.", "dd if=", "> /dev/sda"];
|
|
17
|
+
for (const d of dangerous) {
|
|
18
|
+
if (command.includes(d))
|
|
19
|
+
return `Blocked: dangerous command pattern "${d}"`;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const output = execSync(command, {
|
|
23
|
+
cwd: worktreePath,
|
|
24
|
+
encoding: "utf-8",
|
|
25
|
+
timeout: 30000,
|
|
26
|
+
maxBuffer: 1024 * 1024,
|
|
27
|
+
});
|
|
28
|
+
return output || "(no output)";
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
return `Command failed (exit ${err.status}): ${err.stderr ?? err.message}`;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
export const editFileTool = {
|
|
4
|
+
definition: {
|
|
5
|
+
name: "editFile",
|
|
6
|
+
description: "Replace a string in a file with a new string",
|
|
7
|
+
parameters: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
filePath: { type: "string", description: "Path to the file to edit" },
|
|
11
|
+
oldString: { type: "string", description: "Exact text to replace" },
|
|
12
|
+
newString: { type: "string", description: "Replacement text" },
|
|
13
|
+
replaceAll: { type: "boolean", description: "Replace all occurrences (default: false)" },
|
|
14
|
+
},
|
|
15
|
+
required: ["filePath", "oldString", "newString"],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
async execute(args, worktreePath) {
|
|
19
|
+
const filePath = path.resolve(worktreePath, args.filePath);
|
|
20
|
+
if (!fs.existsSync(filePath))
|
|
21
|
+
return `File not found: ${args.filePath}`;
|
|
22
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
23
|
+
const oldStr = args.oldString;
|
|
24
|
+
const newStr = args.newString;
|
|
25
|
+
const replaceAll = args.replaceAll;
|
|
26
|
+
if (!content.includes(oldStr)) {
|
|
27
|
+
return `Error: oldString not found in ${args.filePath}`;
|
|
28
|
+
}
|
|
29
|
+
const occurrences = content.split(oldStr).length - 1;
|
|
30
|
+
if (occurrences > 1 && !replaceAll) {
|
|
31
|
+
return `Error: oldString appears ${occurrences} times. Use replaceAll: true or provide more context.`;
|
|
32
|
+
}
|
|
33
|
+
const result = replaceAll
|
|
34
|
+
? content.replaceAll(oldStr, newStr)
|
|
35
|
+
: content.replace(oldStr, newStr);
|
|
36
|
+
fs.writeFileSync(filePath, result, "utf-8");
|
|
37
|
+
const replaced = replaceAll ? occurrences : 1;
|
|
38
|
+
return `Replaced ${replaced} occurrence(s) in ${args.filePath}`;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
function globToRegex(pattern) {
|
|
4
|
+
let escaped = "";
|
|
5
|
+
let i = 0;
|
|
6
|
+
while (i < pattern.length) {
|
|
7
|
+
const ch = pattern[i];
|
|
8
|
+
if (ch === "*" && pattern[i + 1] === "*" && pattern[i + 2] === "/") {
|
|
9
|
+
escaped += ".*";
|
|
10
|
+
i += 3;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (ch === "*" && pattern[i + 1] === "*") {
|
|
14
|
+
escaped += ".*";
|
|
15
|
+
i += 2;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (ch === "*") {
|
|
19
|
+
escaped += "[^/]*";
|
|
20
|
+
i++;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (ch === "?") {
|
|
24
|
+
escaped += ".";
|
|
25
|
+
i++;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (".+^$(){}[]|\\".includes(ch)) {
|
|
29
|
+
escaped += "\\" + ch;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
escaped += ch;
|
|
33
|
+
}
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
return new RegExp(`^${escaped}$`);
|
|
37
|
+
}
|
|
38
|
+
function collectFiles(dir, base, results) {
|
|
39
|
+
if (!fs.existsSync(dir))
|
|
40
|
+
return;
|
|
41
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
const full = path.join(dir, e.name);
|
|
44
|
+
if (e.isDirectory()) {
|
|
45
|
+
collectFiles(full, base, results);
|
|
46
|
+
}
|
|
47
|
+
else if (e.isFile()) {
|
|
48
|
+
results.push(path.relative(base, full));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export const globTool = {
|
|
53
|
+
definition: {
|
|
54
|
+
name: "glob",
|
|
55
|
+
description: "Find files matching a glob pattern",
|
|
56
|
+
parameters: {
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: {
|
|
59
|
+
pattern: { type: "string", description: "Glob pattern (e.g. **/*.ts, src/**/*.tsx)" },
|
|
60
|
+
path: { type: "string", description: "Base directory to search in" },
|
|
61
|
+
},
|
|
62
|
+
required: ["pattern"],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
async execute(args, worktreePath) {
|
|
66
|
+
const pattern = args.pattern;
|
|
67
|
+
const base = path.resolve(worktreePath, args.path ?? ".");
|
|
68
|
+
const regex = globToRegex(pattern);
|
|
69
|
+
const files = [];
|
|
70
|
+
collectFiles(base, base, files);
|
|
71
|
+
const matches = files
|
|
72
|
+
.filter((f) => regex.test(f))
|
|
73
|
+
.sort()
|
|
74
|
+
.slice(0, 500);
|
|
75
|
+
return matches.length > 0 ? matches.join("\n") : "No files matched";
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
const MAX_RESULTS = 500;
|
|
5
|
+
export const grepTool = {
|
|
6
|
+
definition: {
|
|
7
|
+
name: "grep",
|
|
8
|
+
description: "Search for a pattern in files using regex",
|
|
9
|
+
parameters: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
pattern: { type: "string", description: "Regex pattern to search for" },
|
|
13
|
+
path: { type: "string", description: "Directory or file to search in" },
|
|
14
|
+
include: { type: "string", description: "File glob pattern to include" },
|
|
15
|
+
},
|
|
16
|
+
required: ["pattern"],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
async execute(args, worktreePath) {
|
|
20
|
+
const cwd = args.path
|
|
21
|
+
? path.resolve(worktreePath, args.path)
|
|
22
|
+
: worktreePath;
|
|
23
|
+
const pattern = args.pattern;
|
|
24
|
+
const include = args.include;
|
|
25
|
+
// Check if rg is available; use Node.js fallback if not
|
|
26
|
+
const rgAvailable = (() => {
|
|
27
|
+
try {
|
|
28
|
+
execSync("rg --version", { stdio: "ignore" });
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
if (rgAvailable) {
|
|
36
|
+
try {
|
|
37
|
+
return tryRg(cwd, pattern, include);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
// rg found no matches (exit code 1)
|
|
41
|
+
if (err.status === 1)
|
|
42
|
+
return "No matches found";
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return nodeGrep(cwd, pattern, include);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
function tryRg(cwd, pattern, include) {
|
|
50
|
+
const inc = include ? `--glob="${include}"` : "";
|
|
51
|
+
const result = execSync(`rg -n --no-heading ${inc} "${pattern}" .`, {
|
|
52
|
+
cwd,
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
55
|
+
});
|
|
56
|
+
return result || "No matches found";
|
|
57
|
+
}
|
|
58
|
+
function nodeGrep(cwd, pattern, include) {
|
|
59
|
+
let regex;
|
|
60
|
+
try {
|
|
61
|
+
// Don't use 'g' flag — it makes .test() stateful across calls
|
|
62
|
+
regex = new RegExp(pattern);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return `Error: invalid regex pattern "${pattern}"`;
|
|
66
|
+
}
|
|
67
|
+
const includeRegex = include ? globToRegex(include) : null;
|
|
68
|
+
const results = [];
|
|
69
|
+
walkDir(cwd, cwd, (filePath) => {
|
|
70
|
+
if (includeRegex && !includeRegex.test(filePath))
|
|
71
|
+
return;
|
|
72
|
+
let content;
|
|
73
|
+
try {
|
|
74
|
+
content = fs.readFileSync(path.join(cwd, filePath), "utf-8");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return; // skip binary/unreadable
|
|
78
|
+
}
|
|
79
|
+
const lines = content.split("\n");
|
|
80
|
+
for (let i = 0; i < lines.length && results.length < MAX_RESULTS; i++) {
|
|
81
|
+
if (regex.test(lines[i])) {
|
|
82
|
+
// Normalize path separator for output
|
|
83
|
+
const displayPath = filePath.replace(/\\/g, "/");
|
|
84
|
+
results.push(`${displayPath}:${i + 1}:${lines[i].trimEnd()}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return results.length > 0 ? results.join("\n") : "No matches found";
|
|
89
|
+
}
|
|
90
|
+
function walkDir(base, dir, callback) {
|
|
91
|
+
let entries;
|
|
92
|
+
try {
|
|
93
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
const fullPath = path.join(dir, entry.name);
|
|
100
|
+
const relPath = path.relative(base, fullPath);
|
|
101
|
+
if (entry.isDirectory()) {
|
|
102
|
+
if (entry.name === "node_modules" || entry.name === ".git")
|
|
103
|
+
continue;
|
|
104
|
+
walkDir(base, fullPath, callback);
|
|
105
|
+
}
|
|
106
|
+
else if (entry.isFile()) {
|
|
107
|
+
// Skip binary-looking files
|
|
108
|
+
if (entry.name.endsWith(".exe") || entry.name.endsWith(".dll"))
|
|
109
|
+
continue;
|
|
110
|
+
callback(relPath);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function globToRegex(glob) {
|
|
115
|
+
let pattern = glob
|
|
116
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
117
|
+
.replace(/\*/g, ".*")
|
|
118
|
+
.replace(/\?/g, ".");
|
|
119
|
+
return new RegExp(`^${pattern}$`, "i");
|
|
120
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
export const readFileTool = {
|
|
4
|
+
definition: {
|
|
5
|
+
name: "readFile",
|
|
6
|
+
description: "Read the contents of a file",
|
|
7
|
+
parameters: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
filePath: { type: "string", description: "Path to the file" },
|
|
11
|
+
offset: { type: "number", description: "Line offset to start reading from" },
|
|
12
|
+
limit: { type: "number", description: "Maximum number of lines to read" },
|
|
13
|
+
},
|
|
14
|
+
required: ["filePath"],
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
async execute(args, worktreePath) {
|
|
18
|
+
const filePath = path.resolve(worktreePath, args.filePath);
|
|
19
|
+
if (!fs.existsSync(filePath))
|
|
20
|
+
return `File not found: ${args.filePath}`;
|
|
21
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
22
|
+
const lines = content.split("\n");
|
|
23
|
+
const offset = args.offset ?? 0;
|
|
24
|
+
const limit = args.limit ?? lines.length;
|
|
25
|
+
return lines.slice(offset, offset + limit).join("\n");
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
export const writeFileTool = {
|
|
4
|
+
definition: {
|
|
5
|
+
name: "writeFile",
|
|
6
|
+
description: "Write content to a file (creates or overwrites)",
|
|
7
|
+
parameters: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
filePath: { type: "string", description: "Path to the file to write" },
|
|
11
|
+
content: { type: "string", description: "Content to write to the file" },
|
|
12
|
+
},
|
|
13
|
+
required: ["filePath", "content"],
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
async execute(args, worktreePath) {
|
|
17
|
+
const filePath = path.resolve(worktreePath, args.filePath);
|
|
18
|
+
// Prevent writing outside the worktree
|
|
19
|
+
if (!filePath.startsWith(path.resolve(worktreePath))) {
|
|
20
|
+
return "Error: file path escapes the worktree";
|
|
21
|
+
}
|
|
22
|
+
const dir = path.dirname(filePath);
|
|
23
|
+
if (!fs.existsSync(dir)) {
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
fs.writeFileSync(filePath, args.content, "utf-8");
|
|
27
|
+
return `Wrote ${fs.statSync(filePath).size} bytes to ${args.filePath}`;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PermissionDecision } from "./types.js";
|
|
2
|
+
export declare class PermissionManager {
|
|
3
|
+
private entries;
|
|
4
|
+
check(toolName: string, args: Record<string, unknown>): PermissionDecision;
|
|
5
|
+
remember(toolName: string, args: Record<string, unknown>, decision: "allow_always" | "deny_always"): void;
|
|
6
|
+
clear(): void;
|
|
7
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export class PermissionManager {
|
|
2
|
+
entries = [];
|
|
3
|
+
check(toolName, args) {
|
|
4
|
+
// Hard-coded safety rules
|
|
5
|
+
if (toolName === "bash") {
|
|
6
|
+
const cmd = String(args.command ?? "");
|
|
7
|
+
if (cmd.includes("rm -rf /") || cmd.includes("sudo ")) {
|
|
8
|
+
return "deny";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
if (toolName === "readFile" || toolName === "grep") {
|
|
12
|
+
const path = String(args.path ?? args.filePath ?? "");
|
|
13
|
+
if (path.includes(".env") || path.includes(".git-credentials")) {
|
|
14
|
+
return "deny";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// Session memory
|
|
18
|
+
for (const entry of this.entries) {
|
|
19
|
+
if (entry.toolName === toolName) {
|
|
20
|
+
return entry.decision;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return "allow";
|
|
24
|
+
}
|
|
25
|
+
remember(toolName, args, decision) {
|
|
26
|
+
this.entries.push({ toolName, args, decision });
|
|
27
|
+
}
|
|
28
|
+
clear() {
|
|
29
|
+
this.entries = [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ToolDef } from "../provider/types.js";
|
|
2
|
+
import type { ToolHandler } from "./types.js";
|
|
3
|
+
export declare class ToolRegistry {
|
|
4
|
+
private tools;
|
|
5
|
+
register(handler: ToolHandler): void;
|
|
6
|
+
getDefinitions(): ToolDef[];
|
|
7
|
+
execute(name: string, args: Record<string, unknown>, worktreePath: string): Promise<string>;
|
|
8
|
+
}
|
|
9
|
+
export declare function createDefaultRegistry(): ToolRegistry;
|