@twinklerg/coden 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/LICENSE +21 -0
- package/README.md +219 -0
- package/dist/index.js +21269 -0
- package/package.json +48 -0
- package/src/cli/agent-command.ts +497 -0
- package/src/cli/format.ts +42 -0
- package/src/cli/index.ts +149 -0
- package/src/cli/plugin-command.ts +217 -0
- package/src/config/config.ts +96 -0
- package/src/config/trust.ts +35 -0
- package/src/context/manager.ts +186 -0
- package/src/context/truncate.ts +9 -0
- package/src/core/events.ts +32 -0
- package/src/core/runtime.ts +402 -0
- package/src/core/types.ts +97 -0
- package/src/index.ts +14 -0
- package/src/observability/terminal.ts +201 -0
- package/src/observability/trace.ts +30 -0
- package/src/permissions/policy.ts +56 -0
- package/src/permissions/workspace.ts +139 -0
- package/src/plugins/api.ts +68 -0
- package/src/plugins/bun-package-manager.ts +35 -0
- package/src/plugins/installed-loader.ts +144 -0
- package/src/plugins/installer.ts +314 -0
- package/src/plugins/manifest.ts +89 -0
- package/src/plugins/package-manager.ts +10 -0
- package/src/plugins/package-metadata.ts +95 -0
- package/src/plugins/paths.ts +43 -0
- package/src/plugins/specifier.ts +63 -0
- package/src/plugins/transaction.ts +403 -0
- package/src/process/runner.ts +134 -0
- package/src/providers/anthropic.ts +117 -0
- package/src/providers/openai.ts +96 -0
- package/src/providers/scripted.ts +28 -0
- package/src/sessions/store.ts +278 -0
- package/src/tools/builtin/bash.ts +56 -0
- package/src/tools/builtin/edit.ts +42 -0
- package/src/tools/builtin/index.ts +9 -0
- package/src/tools/builtin/read.ts +91 -0
- package/src/tools/builtin/write.ts +34 -0
- package/src/tools/executor.ts +90 -0
- package/src/tools/plugin-loader.ts +122 -0
- package/src/tools/registry.ts +97 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import type { AgentMessage, ModelEvent, ModelProvider, ModelRequest } from "../core/types.js";
|
|
3
|
+
|
|
4
|
+
export interface OpenAIProviderOptions {
|
|
5
|
+
apiKey: string;
|
|
6
|
+
baseURL?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type ReasoningDelta = {
|
|
10
|
+
reasoning_content?: string | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export class OpenAICompatibleProvider implements ModelProvider {
|
|
14
|
+
private readonly client: OpenAI;
|
|
15
|
+
constructor(options: OpenAIProviderOptions) {
|
|
16
|
+
this.client = new OpenAI({ ...options, maxRetries: 0 });
|
|
17
|
+
}
|
|
18
|
+
async *stream(request: ModelRequest): AsyncIterable<ModelEvent> {
|
|
19
|
+
const response = await this.client.chat.completions.create(
|
|
20
|
+
{
|
|
21
|
+
model: request.model,
|
|
22
|
+
messages: toOpenAIMessages(request.messages),
|
|
23
|
+
...(request.tools.length
|
|
24
|
+
? {
|
|
25
|
+
tools: request.tools.map((tool) => ({
|
|
26
|
+
type: "function" as const,
|
|
27
|
+
function: {
|
|
28
|
+
name: tool.name,
|
|
29
|
+
description: tool.description,
|
|
30
|
+
parameters: tool.inputSchema,
|
|
31
|
+
},
|
|
32
|
+
})),
|
|
33
|
+
}
|
|
34
|
+
: {}),
|
|
35
|
+
max_completion_tokens: request.maxOutputTokens,
|
|
36
|
+
stream: true,
|
|
37
|
+
stream_options: { include_usage: true },
|
|
38
|
+
},
|
|
39
|
+
request.signal ? { signal: request.signal } : undefined,
|
|
40
|
+
);
|
|
41
|
+
const started = new Set<number>();
|
|
42
|
+
for await (const chunk of response) {
|
|
43
|
+
const delta = chunk.choices[0]?.delta;
|
|
44
|
+
const reasoning = (delta as (typeof delta & ReasoningDelta) | undefined)?.reasoning_content;
|
|
45
|
+
if (reasoning) yield { type: "reasoning_delta", text: reasoning };
|
|
46
|
+
if (delta?.content) yield { type: "text_delta", text: delta.content };
|
|
47
|
+
for (const call of delta?.tool_calls ?? []) {
|
|
48
|
+
const index = call.index;
|
|
49
|
+
if (!started.has(index)) {
|
|
50
|
+
started.add(index);
|
|
51
|
+
yield {
|
|
52
|
+
type: "tool_call_start",
|
|
53
|
+
index,
|
|
54
|
+
callId: call.id ?? `call_${index}`,
|
|
55
|
+
name: call.function?.name ?? "",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (call.function?.arguments)
|
|
59
|
+
yield { type: "tool_call_delta", index, argumentsDelta: call.function.arguments };
|
|
60
|
+
}
|
|
61
|
+
if (chunk.usage)
|
|
62
|
+
yield {
|
|
63
|
+
type: "usage",
|
|
64
|
+
usage: {
|
|
65
|
+
inputTokens: chunk.usage.prompt_tokens,
|
|
66
|
+
outputTokens: chunk.usage.completion_tokens,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
for (const index of started) yield { type: "tool_call_end", index };
|
|
71
|
+
yield { type: "done" };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function toOpenAIMessages(
|
|
76
|
+
messages: AgentMessage[],
|
|
77
|
+
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
|
|
78
|
+
return messages.map((message) => {
|
|
79
|
+
if (message.role === "system" || message.role === "user")
|
|
80
|
+
return { role: message.role, content: message.content };
|
|
81
|
+
if (message.role === "tool")
|
|
82
|
+
return { role: "tool", tool_call_id: message.callId, content: message.content };
|
|
83
|
+
const assistant: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam = {
|
|
84
|
+
role: "assistant",
|
|
85
|
+
content: message.content || null,
|
|
86
|
+
};
|
|
87
|
+
if (message.toolCalls.length) {
|
|
88
|
+
assistant.tool_calls = message.toolCalls.map((call) => ({
|
|
89
|
+
id: call.callId,
|
|
90
|
+
type: "function" as const,
|
|
91
|
+
function: { name: call.name, arguments: JSON.stringify(call.input) },
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
return assistant;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ModelEvent, ModelProvider, ModelRequest } from "../core/types.js";
|
|
2
|
+
|
|
3
|
+
export type ScriptStep = ModelEvent[] | Error | ((request: ModelRequest) => ModelEvent[] | Error);
|
|
4
|
+
export class ScriptedProvider implements ModelProvider {
|
|
5
|
+
readonly requests: ModelRequest[] = [];
|
|
6
|
+
constructor(private readonly steps: ScriptStep[]) {}
|
|
7
|
+
async *stream(request: ModelRequest): AsyncIterable<ModelEvent> {
|
|
8
|
+
this.requests.push(request);
|
|
9
|
+
const step = this.steps.shift();
|
|
10
|
+
if (!step) throw new Error("ScriptedProvider exhausted");
|
|
11
|
+
const result = typeof step === "function" ? step(request) : step;
|
|
12
|
+
if (result instanceof Error) throw result;
|
|
13
|
+
for (const event of result) yield event;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function scriptedText(text: string): ModelEvent[] {
|
|
18
|
+
return [{ type: "text_delta", text }, { type: "done" }];
|
|
19
|
+
}
|
|
20
|
+
export function scriptedTool(callId: string, name: string, input: unknown): ModelEvent[] {
|
|
21
|
+
const json = JSON.stringify(input);
|
|
22
|
+
return [
|
|
23
|
+
{ type: "tool_call_start", index: 0, callId, name },
|
|
24
|
+
{ type: "tool_call_delta", index: 0, argumentsDelta: json },
|
|
25
|
+
{ type: "tool_call_end", index: 0 },
|
|
26
|
+
{ type: "done" },
|
|
27
|
+
];
|
|
28
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFile, chmod, mkdir, readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { AgentMessage } from "../core/types.js";
|
|
5
|
+
|
|
6
|
+
interface SessionRecord {
|
|
7
|
+
version: 1;
|
|
8
|
+
id: string;
|
|
9
|
+
timestamp: string;
|
|
10
|
+
type: string;
|
|
11
|
+
data: unknown;
|
|
12
|
+
}
|
|
13
|
+
export interface RecoveredSession {
|
|
14
|
+
messages: AgentMessage[];
|
|
15
|
+
summary?: string;
|
|
16
|
+
compactionRange?: { start: number; end: number };
|
|
17
|
+
warnings: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface SessionMeta {
|
|
20
|
+
id: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
messageCount: number;
|
|
23
|
+
lastActivity: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
27
|
+
export function isValidSessionId(id: string): boolean {
|
|
28
|
+
return SESSION_ID_RE.test(id);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function workspaceHash(workspace: string): string {
|
|
32
|
+
return createHash("sha256").update(path.resolve(workspace)).digest("hex").slice(0, 16);
|
|
33
|
+
}
|
|
34
|
+
export class SessionStore {
|
|
35
|
+
readonly sessionPath: string;
|
|
36
|
+
readonly tracePath: string;
|
|
37
|
+
#queue = Promise.resolve();
|
|
38
|
+
constructor(
|
|
39
|
+
dataDir: string,
|
|
40
|
+
workspace: string,
|
|
41
|
+
readonly sessionId: string = randomUUID(),
|
|
42
|
+
) {
|
|
43
|
+
if (!isValidSessionId(sessionId)) throw new Error("Invalid session ID");
|
|
44
|
+
const directory = path.join(dataDir, "sessions", workspaceHash(workspace));
|
|
45
|
+
this.sessionPath = path.join(directory, `${sessionId}.jsonl`);
|
|
46
|
+
this.tracePath = path.join(directory, `${sessionId}.trace.jsonl`);
|
|
47
|
+
}
|
|
48
|
+
async create(workspace: string): Promise<void> {
|
|
49
|
+
await this.append("session.created", { workspace, sessionId: this.sessionId });
|
|
50
|
+
}
|
|
51
|
+
append(type: string, data: unknown): Promise<void> {
|
|
52
|
+
const record: SessionRecord = {
|
|
53
|
+
version: 1,
|
|
54
|
+
id: randomUUID(),
|
|
55
|
+
timestamp: new Date().toISOString(),
|
|
56
|
+
type,
|
|
57
|
+
data,
|
|
58
|
+
};
|
|
59
|
+
const operation = this.#queue.then(async () => {
|
|
60
|
+
const directory = path.dirname(this.sessionPath);
|
|
61
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
62
|
+
await chmod(directory, 0o700);
|
|
63
|
+
await appendFile(this.sessionPath, `${JSON.stringify(record)}\n`, {
|
|
64
|
+
encoding: "utf8",
|
|
65
|
+
mode: 0o600,
|
|
66
|
+
});
|
|
67
|
+
await chmod(this.sessionPath, 0o600);
|
|
68
|
+
});
|
|
69
|
+
this.#queue = operation.catch(() => {});
|
|
70
|
+
return operation;
|
|
71
|
+
}
|
|
72
|
+
appendMessage(message: AgentMessage): Promise<void> {
|
|
73
|
+
return this.append("message", message);
|
|
74
|
+
}
|
|
75
|
+
appendCompaction(summary: string, sourceRange?: { start: number; end: number }): Promise<void> {
|
|
76
|
+
return this.append("context.compacted", sourceRange ? { summary, sourceRange } : { summary });
|
|
77
|
+
}
|
|
78
|
+
setTitle(title: string): Promise<void> {
|
|
79
|
+
return this.append("session.title", { title });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async list(): Promise<SessionMeta[]> {
|
|
83
|
+
const directory = path.dirname(this.sessionPath);
|
|
84
|
+
let names: string[];
|
|
85
|
+
try {
|
|
86
|
+
names = await readdir(directory);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
const metas: SessionMeta[] = [];
|
|
92
|
+
for (const name of names) {
|
|
93
|
+
// 会话文件形如 <id>.jsonl;trace 文件形如 <id>.trace.jsonl(同样以 .jsonl 结尾),须排除。
|
|
94
|
+
if (!name.endsWith(".jsonl") || name.endsWith(".trace.jsonl")) continue;
|
|
95
|
+
const id = name.slice(0, -".jsonl".length);
|
|
96
|
+
if (!isValidSessionId(id)) continue;
|
|
97
|
+
try {
|
|
98
|
+
metas.push(await this.#readMeta(path.join(directory, name), id));
|
|
99
|
+
} catch {
|
|
100
|
+
// Skip a session file that cannot be parsed.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
metas.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
104
|
+
return metas;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async #readMeta(filePath: string, id: string): Promise<SessionMeta> {
|
|
108
|
+
const text = await readFile(filePath, "utf8");
|
|
109
|
+
let messageCount = 0;
|
|
110
|
+
let title: string | undefined;
|
|
111
|
+
let firstUserPrompt: string | undefined;
|
|
112
|
+
let lastActivity = "";
|
|
113
|
+
for (const line of text.split("\n")) {
|
|
114
|
+
if (!line) continue;
|
|
115
|
+
let record: SessionRecord;
|
|
116
|
+
try {
|
|
117
|
+
record = JSON.parse(line) as SessionRecord;
|
|
118
|
+
} catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (record.version !== 1) continue;
|
|
122
|
+
if (record.timestamp) lastActivity = record.timestamp;
|
|
123
|
+
switch (record.type) {
|
|
124
|
+
case "session.reset":
|
|
125
|
+
messageCount = 0;
|
|
126
|
+
firstUserPrompt = undefined;
|
|
127
|
+
title = undefined;
|
|
128
|
+
break;
|
|
129
|
+
case "session.title": {
|
|
130
|
+
const data = record.data as { title?: unknown };
|
|
131
|
+
if (typeof data?.title === "string") title = data.title;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case "message":
|
|
135
|
+
messageCount++;
|
|
136
|
+
if (firstUserPrompt === undefined) {
|
|
137
|
+
const data = record.data as { role?: unknown; content?: unknown };
|
|
138
|
+
if (data?.role === "user" && typeof data.content === "string") {
|
|
139
|
+
firstUserPrompt = data.content;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const resolvedTitle = title ?? firstUserPrompt;
|
|
146
|
+
const meta: SessionMeta = { id, messageCount, lastActivity };
|
|
147
|
+
if (resolvedTitle !== undefined) meta.title = resolvedTitle;
|
|
148
|
+
return meta;
|
|
149
|
+
}
|
|
150
|
+
async recover(): Promise<RecoveredSession> {
|
|
151
|
+
const messages: AgentMessage[] = [];
|
|
152
|
+
const warnings: string[] = [];
|
|
153
|
+
let summary: string | undefined;
|
|
154
|
+
let compactionRange: { start: number; end: number } | undefined;
|
|
155
|
+
const text = await readFile(this.sessionPath, "utf8");
|
|
156
|
+
const lines = text.split("\n");
|
|
157
|
+
let lastRecordIndex = -1;
|
|
158
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
159
|
+
if (lines[index]) {
|
|
160
|
+
lastRecordIndex = index;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (let index = 0; index < lines.length; index++) {
|
|
165
|
+
const line = lines[index];
|
|
166
|
+
if (!line) continue;
|
|
167
|
+
let record: SessionRecord;
|
|
168
|
+
try {
|
|
169
|
+
record = JSON.parse(line) as SessionRecord;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (index === lastRecordIndex) {
|
|
172
|
+
warnings.push("Ignored incomplete final JSONL record");
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
throw invalidRecord(index, error);
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
if (record.version !== 1) throw new Error("unsupported schema version");
|
|
179
|
+
if (record.type === "session.reset") {
|
|
180
|
+
messages.length = 0;
|
|
181
|
+
summary = undefined;
|
|
182
|
+
compactionRange = undefined;
|
|
183
|
+
}
|
|
184
|
+
if (record.type === "message") {
|
|
185
|
+
if (!isMessage(record.data)) throw new Error("invalid message structure");
|
|
186
|
+
messages.push(record.data);
|
|
187
|
+
}
|
|
188
|
+
if (record.type === "context.compacted") {
|
|
189
|
+
const data = record.data as {
|
|
190
|
+
summary?: unknown;
|
|
191
|
+
sourceRange?: { start?: unknown; end?: unknown };
|
|
192
|
+
};
|
|
193
|
+
if (typeof data?.summary !== "string") throw new Error("invalid compaction record");
|
|
194
|
+
summary = data.summary;
|
|
195
|
+
compactionRange =
|
|
196
|
+
typeof data.sourceRange?.start === "number" && typeof data.sourceRange.end === "number"
|
|
197
|
+
? { start: data.sourceRange.start, end: data.sourceRange.end }
|
|
198
|
+
: undefined;
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw invalidRecord(index, error);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const repairs = repairTrailingToolCalls(messages, warnings);
|
|
205
|
+
for (const repair of repairs) await this.appendMessage(repair);
|
|
206
|
+
const recovered: RecoveredSession = { messages, warnings };
|
|
207
|
+
if (summary !== undefined) recovered.summary = summary;
|
|
208
|
+
if (compactionRange !== undefined) recovered.compactionRange = compactionRange;
|
|
209
|
+
return recovered;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function isMessage(value: unknown): value is AgentMessage {
|
|
213
|
+
if (!value || typeof value !== "object") return false;
|
|
214
|
+
const message = value as Record<string, unknown>;
|
|
215
|
+
if ((message.role === "system" || message.role === "user") && typeof message.content === "string")
|
|
216
|
+
return true;
|
|
217
|
+
if (message.role === "assistant" && typeof message.content === "string") {
|
|
218
|
+
return (
|
|
219
|
+
Array.isArray(message.toolCalls) &&
|
|
220
|
+
message.toolCalls.every(
|
|
221
|
+
(call) =>
|
|
222
|
+
!!call &&
|
|
223
|
+
typeof call === "object" &&
|
|
224
|
+
typeof (call as Record<string, unknown>).callId === "string" &&
|
|
225
|
+
typeof (call as Record<string, unknown>).name === "string" &&
|
|
226
|
+
"input" in (call as Record<string, unknown>),
|
|
227
|
+
)
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return (
|
|
231
|
+
message.role === "tool" &&
|
|
232
|
+
typeof message.callId === "string" &&
|
|
233
|
+
typeof message.name === "string" &&
|
|
234
|
+
typeof message.content === "string" &&
|
|
235
|
+
typeof message.isError === "boolean"
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
function invalidRecord(index: number, error: unknown): Error {
|
|
239
|
+
return new Error(
|
|
240
|
+
`Invalid session record at line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function repairTrailingToolCalls(messages: AgentMessage[], warnings: string[]): AgentMessage[] {
|
|
245
|
+
const pending = new Map<string, string>();
|
|
246
|
+
const seen = new Set<string>();
|
|
247
|
+
for (const message of messages) {
|
|
248
|
+
if (message.role === "assistant") {
|
|
249
|
+
if (pending.size > 0)
|
|
250
|
+
throw new Error("Assistant message appeared before pending tool results");
|
|
251
|
+
for (const call of message.toolCalls) {
|
|
252
|
+
if (seen.has(call.callId)) throw new Error(`Duplicate tool call ID: ${call.callId}`);
|
|
253
|
+
seen.add(call.callId);
|
|
254
|
+
pending.set(call.callId, call.name);
|
|
255
|
+
}
|
|
256
|
+
} else if (message.role === "tool") {
|
|
257
|
+
if (!pending.delete(message.callId)) throw new Error(`Orphan tool result: ${message.callId}`);
|
|
258
|
+
} else if (pending.size > 0) {
|
|
259
|
+
throw new Error(`${message.role} message appeared before pending tool results`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (pending.size === 0) return [];
|
|
263
|
+
const repairs: AgentMessage[] = [];
|
|
264
|
+
for (const [callId, name] of pending) {
|
|
265
|
+
const repair: AgentMessage = {
|
|
266
|
+
role: "tool",
|
|
267
|
+
callId,
|
|
268
|
+
name,
|
|
269
|
+
content:
|
|
270
|
+
"runtime.interrupted: tool execution did not complete before the previous process exited",
|
|
271
|
+
isError: true,
|
|
272
|
+
};
|
|
273
|
+
messages.push(repair);
|
|
274
|
+
repairs.push(repair);
|
|
275
|
+
}
|
|
276
|
+
warnings.push(`Recovered ${pending.size} interrupted tool call(s)`);
|
|
277
|
+
return repairs;
|
|
278
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { truncateOutput } from "../../context/truncate.js";
|
|
2
|
+
import type { ToolDefinition } from "../../core/types.js";
|
|
3
|
+
import { runProcess } from "../../process/runner.js";
|
|
4
|
+
|
|
5
|
+
export const bashTool: ToolDefinition = {
|
|
6
|
+
name: "bash",
|
|
7
|
+
description: "Run a bash command in the workspace with timeout and bounded output.",
|
|
8
|
+
risk: "modify",
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: "object",
|
|
11
|
+
additionalProperties: false,
|
|
12
|
+
required: ["command"],
|
|
13
|
+
properties: {
|
|
14
|
+
command: { type: "string", minLength: 1 },
|
|
15
|
+
timeout: { type: "integer", minimum: 100, maximum: 60000, default: 30000 },
|
|
16
|
+
maxOutput: { type: "integer", minimum: 1000, maximum: 100000, default: 30000 },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
async execute(input, context) {
|
|
20
|
+
const {
|
|
21
|
+
command,
|
|
22
|
+
timeout = 30000,
|
|
23
|
+
maxOutput = 30000,
|
|
24
|
+
} = input as { command: string; timeout?: number; maxOutput?: number };
|
|
25
|
+
const result = await runProcess("bash", ["-lc", command], {
|
|
26
|
+
cwd: context.workspace,
|
|
27
|
+
env: process.env,
|
|
28
|
+
signal: context.signal,
|
|
29
|
+
timeoutMs: timeout,
|
|
30
|
+
maxOutputChars: maxOutput,
|
|
31
|
+
});
|
|
32
|
+
const combined = [
|
|
33
|
+
result.stdout && `stdout:\n${result.stdout}`,
|
|
34
|
+
result.stderr && `stderr:\n${result.stderr}`,
|
|
35
|
+
]
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.join("\n");
|
|
38
|
+
if (result.exitCode === null && !result.timedOut && !result.cancelled && result.signal === null)
|
|
39
|
+
return { content: `bash.spawn_error: ${result.stderr || "unknown error"}`, isError: true };
|
|
40
|
+
const status = result.timedOut
|
|
41
|
+
? `Timed out after ${timeout}ms`
|
|
42
|
+
: result.cancelled
|
|
43
|
+
? "Cancelled"
|
|
44
|
+
: `Exit code: ${result.exitCode ?? "null"}${result.signal ? ` (signal ${result.signal})` : ""}`;
|
|
45
|
+
return {
|
|
46
|
+
content: truncateOutput(`${status}\n${combined}`.trimEnd(), maxOutput),
|
|
47
|
+
isError: result.timedOut || result.cancelled || result.exitCode !== 0,
|
|
48
|
+
metadata: {
|
|
49
|
+
exitCode: result.exitCode,
|
|
50
|
+
signal: result.signal,
|
|
51
|
+
timedOut: result.timedOut,
|
|
52
|
+
cancelled: result.cancelled,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import type { ToolDefinition } from "../../core/types.js";
|
|
4
|
+
import { resolveWorkspacePath } from "../../permissions/workspace.js";
|
|
5
|
+
|
|
6
|
+
export const editTool: ToolDefinition = {
|
|
7
|
+
name: "edit",
|
|
8
|
+
description: "Replace one uniquely matching exact text block in a UTF-8 file.",
|
|
9
|
+
risk: "modify",
|
|
10
|
+
inputSchema: {
|
|
11
|
+
type: "object",
|
|
12
|
+
additionalProperties: false,
|
|
13
|
+
required: ["path", "oldText", "newText"],
|
|
14
|
+
properties: {
|
|
15
|
+
path: { type: "string" },
|
|
16
|
+
oldText: { type: "string", minLength: 1 },
|
|
17
|
+
newText: { type: "string" },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
async execute(input, context) {
|
|
21
|
+
const {
|
|
22
|
+
path: requested,
|
|
23
|
+
oldText,
|
|
24
|
+
newText,
|
|
25
|
+
} = input as { path: string; oldText: string; newText: string };
|
|
26
|
+
const target = await resolveWorkspacePath(context.workspace, requested);
|
|
27
|
+
const handle = await open(target, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
28
|
+
try {
|
|
29
|
+
const content = await handle.readFile("utf8");
|
|
30
|
+
const first = content.indexOf(oldText);
|
|
31
|
+
if (first < 0) return { content: "edit.no_match: oldText was not found", isError: true };
|
|
32
|
+
if (content.indexOf(oldText, first + 1) >= 0)
|
|
33
|
+
return { content: "edit.multiple_matches: oldText is not unique", isError: true };
|
|
34
|
+
const updated = `${content.slice(0, first)}${newText}${content.slice(first + oldText.length)}`;
|
|
35
|
+
await handle.truncate(0);
|
|
36
|
+
await handle.write(updated, 0, "utf8");
|
|
37
|
+
} finally {
|
|
38
|
+
await handle.close();
|
|
39
|
+
}
|
|
40
|
+
return { content: `Edited ${requested}` };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ToolDefinition } from "../../core/types.js";
|
|
2
|
+
import { bashTool } from "./bash.js";
|
|
3
|
+
import { editTool } from "./edit.js";
|
|
4
|
+
import { readTool } from "./read.js";
|
|
5
|
+
import { writeTool } from "./write.js";
|
|
6
|
+
|
|
7
|
+
export function builtinTools(): ToolDefinition[] {
|
|
8
|
+
return [readTool, writeTool, editTool, bashTool];
|
|
9
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import type { ToolDefinition } from "../../core/types.js";
|
|
4
|
+
import { resolveWorkspacePath } from "../../permissions/workspace.js";
|
|
5
|
+
|
|
6
|
+
class BoundedText {
|
|
7
|
+
private text = "";
|
|
8
|
+
private omitted = 0;
|
|
9
|
+
constructor(private readonly limit: number) {}
|
|
10
|
+
add(value: string): void {
|
|
11
|
+
const available = Math.max(0, this.limit - this.text.length);
|
|
12
|
+
this.text += value.slice(0, available);
|
|
13
|
+
this.omitted += Math.max(0, value.length - available);
|
|
14
|
+
}
|
|
15
|
+
value(): string {
|
|
16
|
+
return this.omitted > 0
|
|
17
|
+
? `${this.text}\n... [${this.omitted} selected characters omitted]`
|
|
18
|
+
: this.text;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const readTool: ToolDefinition = {
|
|
23
|
+
name: "read",
|
|
24
|
+
description: "Read a UTF-8 text file by 1-based line offset and limit.",
|
|
25
|
+
risk: "read",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
required: ["path"],
|
|
30
|
+
properties: {
|
|
31
|
+
path: { type: "string" },
|
|
32
|
+
offset: { type: "integer", minimum: 1, default: 1 },
|
|
33
|
+
limit: { type: "integer", minimum: 1, maximum: 2000, default: 500 },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
async execute(input, context) {
|
|
37
|
+
const {
|
|
38
|
+
path,
|
|
39
|
+
offset = 1,
|
|
40
|
+
limit = 500,
|
|
41
|
+
} = input as { path: string; offset?: number; limit?: number };
|
|
42
|
+
const target = await resolveWorkspacePath(context.workspace, path);
|
|
43
|
+
const output = new BoundedText(50_000);
|
|
44
|
+
let lineNumber = 1;
|
|
45
|
+
let selectedLines = 0;
|
|
46
|
+
let selectedStarted = false;
|
|
47
|
+
const selected = () => lineNumber >= offset && lineNumber < offset + limit;
|
|
48
|
+
const consume = (segment: string) => {
|
|
49
|
+
if (!selected()) return;
|
|
50
|
+
if (!selectedStarted) {
|
|
51
|
+
if (selectedLines > 0) output.add("\n");
|
|
52
|
+
selectedLines++;
|
|
53
|
+
selectedStarted = true;
|
|
54
|
+
}
|
|
55
|
+
output.add(segment);
|
|
56
|
+
};
|
|
57
|
+
const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
58
|
+
try {
|
|
59
|
+
const stream = handle.createReadStream({
|
|
60
|
+
encoding: "utf8",
|
|
61
|
+
autoClose: false,
|
|
62
|
+
highWaterMark: 64 * 1024,
|
|
63
|
+
signal: context.signal,
|
|
64
|
+
});
|
|
65
|
+
for await (const raw of stream) {
|
|
66
|
+
const chunk = String(raw);
|
|
67
|
+
let start = 0;
|
|
68
|
+
for (
|
|
69
|
+
let newline = chunk.indexOf("\n", start);
|
|
70
|
+
newline >= 0;
|
|
71
|
+
newline = chunk.indexOf("\n", start)
|
|
72
|
+
) {
|
|
73
|
+
consume(chunk.slice(start, newline));
|
|
74
|
+
lineNumber++;
|
|
75
|
+
selectedStarted = false;
|
|
76
|
+
start = newline + 1;
|
|
77
|
+
}
|
|
78
|
+
consume(chunk.slice(start));
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
await handle.close();
|
|
82
|
+
}
|
|
83
|
+
consume("");
|
|
84
|
+
const totalLines = lineNumber;
|
|
85
|
+
const omittedLines = Math.max(0, totalLines - (offset - 1 + selectedLines));
|
|
86
|
+
return {
|
|
87
|
+
content: output.value() + (omittedLines > 0 ? `\n... [${omittedLines} lines omitted]` : ""),
|
|
88
|
+
metadata: { totalLines, offset, limit },
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { mkdir, open } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { ToolDefinition } from "../../core/types.js";
|
|
5
|
+
import { resolveWorkspacePath } from "../../permissions/workspace.js";
|
|
6
|
+
|
|
7
|
+
export const writeTool: ToolDefinition = {
|
|
8
|
+
name: "write",
|
|
9
|
+
description: "Create or overwrite a UTF-8 file inside the workspace.",
|
|
10
|
+
risk: "modify",
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
required: ["path", "content"],
|
|
15
|
+
properties: { path: { type: "string" }, content: { type: "string" } },
|
|
16
|
+
},
|
|
17
|
+
async execute(input, context) {
|
|
18
|
+
const { path: requested, content } = input as { path: string; content: string };
|
|
19
|
+
let target = await resolveWorkspacePath(context.workspace, requested);
|
|
20
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
21
|
+
target = await resolveWorkspacePath(context.workspace, requested);
|
|
22
|
+
const handle = await open(
|
|
23
|
+
target,
|
|
24
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW,
|
|
25
|
+
0o600,
|
|
26
|
+
);
|
|
27
|
+
try {
|
|
28
|
+
await handle.writeFile(content, "utf8");
|
|
29
|
+
} finally {
|
|
30
|
+
await handle.close();
|
|
31
|
+
}
|
|
32
|
+
return { content: `Wrote ${Buffer.byteLength(content)} bytes to ${requested}` };
|
|
33
|
+
},
|
|
34
|
+
};
|