@ayoxx/kundex 0.1.7 → 1.0.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/README.md +5 -5
- package/package.json +1 -1
- package/src/config.ts +19 -3
- package/src/index.ts +551 -17
- package/src/repl.ts +403 -34
- package/src/sdk.ts +320 -35
- package/src/tools.ts +171 -13
package/src/sdk.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface ChatUsage {
|
|
|
21
21
|
export interface ModelInfo {
|
|
22
22
|
id: string;
|
|
23
23
|
name: string;
|
|
24
|
+
/** Which inference provider serves this model (e.g. "heavstal", "groq", "gemini", "mistral"). */
|
|
25
|
+
provider?: string;
|
|
24
26
|
contextWindow: number;
|
|
25
27
|
inputPricePerMTok: number;
|
|
26
28
|
outputPricePerMTok: number;
|
|
@@ -34,6 +36,33 @@ export interface AgentTurn {
|
|
|
34
36
|
usage: ChatUsage | null;
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
export interface AgentSession {
|
|
40
|
+
id: number;
|
|
41
|
+
cwd: string;
|
|
42
|
+
model: string;
|
|
43
|
+
name: string;
|
|
44
|
+
description: string;
|
|
45
|
+
language: string;
|
|
46
|
+
wsStatus: string;
|
|
47
|
+
pinned: boolean;
|
|
48
|
+
lastOpenedAt: string | null;
|
|
49
|
+
lastModifiedAt: string | null;
|
|
50
|
+
createdAt: string;
|
|
51
|
+
updatedAt: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface WorkspaceFile {
|
|
55
|
+
id: number;
|
|
56
|
+
sessionId: number;
|
|
57
|
+
path: string;
|
|
58
|
+
name: string;
|
|
59
|
+
mimeType: string;
|
|
60
|
+
size: number;
|
|
61
|
+
content?: string;
|
|
62
|
+
createdAt: string;
|
|
63
|
+
updatedAt: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
37
66
|
export interface KundexOptions {
|
|
38
67
|
apiKey: string;
|
|
39
68
|
baseUrl: string;
|
|
@@ -49,7 +78,7 @@ export interface KundexOptions {
|
|
|
49
78
|
*/
|
|
50
79
|
export class Kundex {
|
|
51
80
|
private apiKey: string;
|
|
52
|
-
|
|
81
|
+
readonly baseUrl: string;
|
|
53
82
|
|
|
54
83
|
constructor(opts: KundexOptions) {
|
|
55
84
|
if (!opts.apiKey) throw new Error("Kundex: apiKey is required");
|
|
@@ -76,9 +105,37 @@ export class Kundex {
|
|
|
76
105
|
return (await res.json()) as T;
|
|
77
106
|
}
|
|
78
107
|
|
|
108
|
+
private async requestGet<T>(pathname: string): Promise<T> {
|
|
109
|
+
const res = await fetch(`${this.baseUrl}${pathname}`, {
|
|
110
|
+
method: "GET",
|
|
111
|
+
headers: { authorization: `Bearer ${this.apiKey}` },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
const text = await res.text().catch(() => "");
|
|
116
|
+
throw new Error(`Kundex API error (${res.status}): ${text || res.statusText}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (await res.json()) as T;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async requestDelete(pathname: string): Promise<void> {
|
|
123
|
+
const res = await fetch(`${this.baseUrl}${pathname}`, {
|
|
124
|
+
method: "DELETE",
|
|
125
|
+
headers: { authorization: `Bearer ${this.apiKey}` },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (!res.ok && res.status !== 204) {
|
|
129
|
+
const text = await res.text().catch(() => "");
|
|
130
|
+
throw new Error(`Kundex API error (${res.status}): ${text || res.statusText}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
79
134
|
models = {
|
|
80
135
|
list: async (): Promise<ModelInfo[]> => {
|
|
81
|
-
const res = await fetch(`${this.baseUrl}/v1/models
|
|
136
|
+
const res = await fetch(`${this.baseUrl}/v1/models`, {
|
|
137
|
+
headers: { authorization: `Bearer ${this.apiKey}` },
|
|
138
|
+
});
|
|
82
139
|
if (!res.ok) throw new Error(`Kundex API error (${res.status})`);
|
|
83
140
|
return (await res.json()) as ModelInfo[];
|
|
84
141
|
},
|
|
@@ -109,6 +166,7 @@ export class Kundex {
|
|
|
109
166
|
tools?: KundexToolDefinition[];
|
|
110
167
|
},
|
|
111
168
|
onDelta: (text: string) => void,
|
|
169
|
+
signal?: AbortSignal,
|
|
112
170
|
): Promise<{ finishReason: string; usage: ChatUsage | null }> => {
|
|
113
171
|
const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
|
|
114
172
|
method: "POST",
|
|
@@ -117,6 +175,7 @@ export class Kundex {
|
|
|
117
175
|
authorization: `Bearer ${this.apiKey}`,
|
|
118
176
|
},
|
|
119
177
|
body: JSON.stringify({ ...params, stream: true }),
|
|
178
|
+
signal,
|
|
120
179
|
});
|
|
121
180
|
|
|
122
181
|
if (!res.ok || !res.body) {
|
|
@@ -130,25 +189,32 @@ export class Kundex {
|
|
|
130
189
|
let finishReason = "stop";
|
|
131
190
|
let usage: ChatUsage | null = null;
|
|
132
191
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
192
|
+
try {
|
|
193
|
+
for (;;) {
|
|
194
|
+
const { done, value } = await reader.read();
|
|
195
|
+
if (done) break;
|
|
196
|
+
buffer += decoder.decode(value, { stream: true });
|
|
197
|
+
const lines = buffer.split("\n");
|
|
198
|
+
buffer = lines.pop() ?? "";
|
|
199
|
+
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
const trimmed = line.trim();
|
|
202
|
+
if (trimmed.startsWith(":")) continue; // heartbeat comment
|
|
203
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
204
|
+
const payload = trimmed.slice(5).trim();
|
|
205
|
+
if (!payload || payload === "[DONE]") continue;
|
|
206
|
+
try {
|
|
207
|
+
const json = JSON.parse(payload);
|
|
208
|
+
if (json.delta) onDelta(json.delta);
|
|
209
|
+
if (json.done) {
|
|
210
|
+
finishReason = json.finishReason ?? finishReason;
|
|
211
|
+
usage = json.usage ?? usage;
|
|
212
|
+
}
|
|
213
|
+
} catch { /* ignore bad JSON */ }
|
|
150
214
|
}
|
|
151
215
|
}
|
|
216
|
+
} finally {
|
|
217
|
+
try { reader.cancel(); } catch { /* ignore */ }
|
|
152
218
|
}
|
|
153
219
|
|
|
154
220
|
return { finishReason, usage };
|
|
@@ -156,23 +222,242 @@ export class Kundex {
|
|
|
156
222
|
};
|
|
157
223
|
|
|
158
224
|
agent = {
|
|
159
|
-
createSession: (params: {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
225
|
+
createSession: (params: {
|
|
226
|
+
cwd: string;
|
|
227
|
+
model?: string;
|
|
228
|
+
name?: string;
|
|
229
|
+
description?: string;
|
|
230
|
+
language?: string;
|
|
231
|
+
}) =>
|
|
232
|
+
this.request<AgentSession>("/v1/agent/session", params),
|
|
233
|
+
|
|
234
|
+
listSessions: () =>
|
|
235
|
+
this.requestGet<AgentSession[]>("/v1/agent/sessions"),
|
|
236
|
+
|
|
237
|
+
getSession: (id: number) =>
|
|
238
|
+
this.requestGet<AgentSession>(`/v1/agent/sessions/${id}`),
|
|
239
|
+
|
|
240
|
+
deleteSession: (id: number) =>
|
|
241
|
+
this.requestDelete(`/v1/agent/sessions/${id}`),
|
|
242
|
+
|
|
243
|
+
getMessages: (id: number) =>
|
|
244
|
+
this.requestGet<{ sessionId: number; messages: unknown[] }>(`/v1/agent/sessions/${id}/messages`),
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Send a message to the agent and stream the response.
|
|
248
|
+
*
|
|
249
|
+
* The server sends SSE events — this method handles the typed format
|
|
250
|
+
* emitted by the backend:
|
|
251
|
+
* { type: "delta", delta: string }
|
|
252
|
+
* { type: "done", reply?, toolCalls?, done, usage? }
|
|
253
|
+
* { type: "error", error: string }
|
|
254
|
+
*
|
|
255
|
+
* Invokes onDelta as text tokens arrive so the caller can print them
|
|
256
|
+
* incrementally.
|
|
257
|
+
*
|
|
258
|
+
* Returns the completed AgentTurn including any tool calls.
|
|
259
|
+
*/
|
|
260
|
+
streamMessage: async (
|
|
261
|
+
params: {
|
|
262
|
+
sessionId: number;
|
|
263
|
+
message: string;
|
|
264
|
+
context?: { fileTree?: string; gitStatus?: string; openFiles?: string[] };
|
|
265
|
+
},
|
|
266
|
+
onDelta: (text: string) => void,
|
|
267
|
+
signal?: AbortSignal,
|
|
268
|
+
): Promise<AgentTurn> => {
|
|
269
|
+
const endpoint = "/v1/agent/message";
|
|
270
|
+
const res = await fetch(`${this.baseUrl}${endpoint}`, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: {
|
|
273
|
+
"content-type": "application/json",
|
|
274
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
275
|
+
},
|
|
276
|
+
body: JSON.stringify(params),
|
|
277
|
+
signal,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
if (!res.ok) {
|
|
281
|
+
const text = await res.text().catch(() => "");
|
|
282
|
+
throw new Error(`Kundex API error (${res.status}): ${text || res.statusText}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const reader = res.body?.getReader();
|
|
286
|
+
if (!reader) throw new Error("No response body from server");
|
|
287
|
+
|
|
288
|
+
const decoder = new TextDecoder();
|
|
289
|
+
let buffer = "";
|
|
290
|
+
let reply = "";
|
|
291
|
+
let toolCalls: AgentTurn["toolCalls"] = null;
|
|
292
|
+
let usage: AgentTurn["usage"] = null;
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
for (;;) {
|
|
296
|
+
const { done, value } = await reader.read();
|
|
297
|
+
if (done) break;
|
|
298
|
+
buffer += decoder.decode(value, { stream: true });
|
|
299
|
+
const lines = buffer.split("\n");
|
|
300
|
+
buffer = lines.pop() ?? "";
|
|
301
|
+
|
|
302
|
+
for (const line of lines) {
|
|
303
|
+
const trimmed = line.trim();
|
|
304
|
+
if (trimmed.startsWith(":")) continue; // heartbeat comment
|
|
305
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
306
|
+
const payload = trimmed.slice(5).trim();
|
|
307
|
+
if (!payload || payload === "[DONE]") continue;
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const ev = JSON.parse(payload);
|
|
311
|
+
|
|
312
|
+
if (ev.type === "delta" && typeof ev.delta === "string") {
|
|
313
|
+
reply += ev.delta;
|
|
314
|
+
onDelta(ev.delta);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (ev.type === "done") {
|
|
318
|
+
return {
|
|
319
|
+
sessionId: params.sessionId,
|
|
320
|
+
reply: ev.reply !== undefined ? (ev.reply ?? reply) : reply,
|
|
321
|
+
toolCalls: ev.toolCalls ?? ev.tool_calls ?? null,
|
|
322
|
+
done: ev.done ?? true,
|
|
323
|
+
usage: ev.usage ?? usage,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (ev.type === "error") {
|
|
327
|
+
throw new Error(ev.error ?? "Agent error");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Legacy format fallback
|
|
331
|
+
if (typeof ev.delta === "string") {
|
|
332
|
+
reply += ev.delta;
|
|
333
|
+
onDelta(ev.delta);
|
|
334
|
+
}
|
|
335
|
+
if (ev.done === true) {
|
|
336
|
+
return {
|
|
337
|
+
sessionId: params.sessionId,
|
|
338
|
+
reply: ev.reply ?? reply,
|
|
339
|
+
toolCalls: ev.tool_calls ?? ev.toolCalls ?? null,
|
|
340
|
+
done: true,
|
|
341
|
+
usage: ev.usage ?? usage,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
} catch (parseErr) {
|
|
345
|
+
if (parseErr instanceof SyntaxError) continue;
|
|
346
|
+
throw parseErr;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
} finally {
|
|
351
|
+
try { reader.cancel(); } catch { /* ignore */ }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Stream ended without a done event; return what we have
|
|
355
|
+
return { sessionId: params.sessionId, reply, toolCalls, done: true, usage };
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
submitToolResult: async (
|
|
359
|
+
params: {
|
|
360
|
+
sessionId: number;
|
|
361
|
+
toolCallId: string;
|
|
362
|
+
result: string;
|
|
363
|
+
isError?: boolean;
|
|
364
|
+
},
|
|
365
|
+
onDelta: (text: string) => void,
|
|
366
|
+
signal?: AbortSignal,
|
|
367
|
+
): Promise<AgentTurn> => {
|
|
368
|
+
const res = await fetch(`${this.baseUrl}/v1/agent/tool-result`, {
|
|
369
|
+
method: "POST",
|
|
370
|
+
headers: {
|
|
371
|
+
"content-type": "application/json",
|
|
372
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
373
|
+
},
|
|
374
|
+
body: JSON.stringify(params),
|
|
375
|
+
signal,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
if (!res.ok) {
|
|
379
|
+
const text = await res.text().catch(() => "");
|
|
380
|
+
throw new Error(`Kundex API error (${res.status}): ${text || res.statusText}`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const reader = res.body?.getReader();
|
|
384
|
+
if (!reader) throw new Error("No response body from server");
|
|
385
|
+
|
|
386
|
+
const decoder = new TextDecoder();
|
|
387
|
+
let buffer = "";
|
|
388
|
+
let reply = "";
|
|
389
|
+
let toolCalls: AgentTurn["toolCalls"] = null;
|
|
390
|
+
let usage: AgentTurn["usage"] = null;
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
for (;;) {
|
|
394
|
+
const { done, value } = await reader.read();
|
|
395
|
+
if (done) break;
|
|
396
|
+
buffer += decoder.decode(value, { stream: true });
|
|
397
|
+
const lines = buffer.split("\n");
|
|
398
|
+
buffer = lines.pop() ?? "";
|
|
399
|
+
|
|
400
|
+
for (const line of lines) {
|
|
401
|
+
const trimmed = line.trim();
|
|
402
|
+
if (trimmed.startsWith(":")) continue;
|
|
403
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
404
|
+
const payload = trimmed.slice(5).trim();
|
|
405
|
+
if (!payload || payload === "[DONE]") continue;
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
const ev = JSON.parse(payload);
|
|
409
|
+
if (ev.type === "delta" && typeof ev.delta === "string") {
|
|
410
|
+
reply += ev.delta;
|
|
411
|
+
onDelta(ev.delta);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (ev.type === "done") {
|
|
415
|
+
return {
|
|
416
|
+
sessionId: params.sessionId,
|
|
417
|
+
reply: ev.reply !== undefined ? (ev.reply ?? reply) : reply,
|
|
418
|
+
toolCalls: ev.toolCalls ?? ev.tool_calls ?? null,
|
|
419
|
+
done: ev.done ?? true,
|
|
420
|
+
usage: ev.usage ?? usage,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (ev.type === "error") throw new Error(ev.error ?? "Agent error");
|
|
424
|
+
if (typeof ev.delta === "string") { reply += ev.delta; onDelta(ev.delta); }
|
|
425
|
+
if (ev.done === true) {
|
|
426
|
+
return {
|
|
427
|
+
sessionId: params.sessionId,
|
|
428
|
+
reply: ev.reply ?? reply,
|
|
429
|
+
toolCalls: ev.tool_calls ?? ev.toolCalls ?? null,
|
|
430
|
+
done: true,
|
|
431
|
+
usage: ev.usage ?? usage,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
} catch (parseErr) {
|
|
435
|
+
if (parseErr instanceof SyntaxError) continue;
|
|
436
|
+
throw parseErr;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
} finally {
|
|
441
|
+
try { reader.cancel(); } catch { /* ignore */ }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return { sessionId: params.sessionId, reply, toolCalls, done: true, usage };
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
files = {
|
|
449
|
+
list: (wsId: number) =>
|
|
450
|
+
this.requestGet<{ files: WorkspaceFile[] }>(`/v1/workspace/${wsId}/files`),
|
|
451
|
+
|
|
452
|
+
get: (wsId: number, path: string) =>
|
|
453
|
+
this.requestGet<WorkspaceFile>(
|
|
454
|
+
`/v1/workspace/${wsId}/files/content?path=${encodeURIComponent(path)}`,
|
|
163
455
|
),
|
|
164
456
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
submitToolResult: (params: {
|
|
172
|
-
sessionId: number;
|
|
173
|
-
toolCallId: string;
|
|
174
|
-
result: string;
|
|
175
|
-
isError?: boolean;
|
|
176
|
-
}) => this.request<AgentTurn>("/v1/agent/tool-result", params),
|
|
457
|
+
upsert: (wsId: number, params: { path: string; name: string; content: string; mimeType?: string }) =>
|
|
458
|
+
this.request<{ file: WorkspaceFile }>(`/v1/workspace/${wsId}/files`, params),
|
|
459
|
+
|
|
460
|
+
delete: (wsId: number, path: string) =>
|
|
461
|
+
this.requestDelete(`/v1/workspace/${wsId}/files?path=${encodeURIComponent(path)}`),
|
|
177
462
|
};
|
|
178
463
|
}
|
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { exec } from "node:child_process";
|
|
3
|
+
import { exec, execFile } from "node:child_process";
|
|
4
4
|
import readline from "node:readline/promises";
|
|
5
5
|
|
|
6
6
|
export interface ToolCall {
|
|
@@ -14,10 +14,58 @@ export interface ToolResult {
|
|
|
14
14
|
isError: boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
const c = {
|
|
18
|
+
reset: "\x1b[0m",
|
|
19
|
+
bold: "\x1b[1m",
|
|
20
|
+
dim: "\x1b[2m",
|
|
21
|
+
cyan: "\x1b[36m",
|
|
22
|
+
green: "\x1b[32m",
|
|
23
|
+
yellow: "\x1b[33m",
|
|
24
|
+
red: "\x1b[31m",
|
|
25
|
+
blue: "\x1b[34m",
|
|
26
|
+
magenta: "\x1b[35m",
|
|
27
|
+
white: "\x1b[37m",
|
|
28
|
+
bgDark: "\x1b[48;5;235m",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function header(label: string, value: string) {
|
|
32
|
+
process.stdout.write(
|
|
33
|
+
`${c.bold}${c.cyan} ◆ ${label}${c.reset} ${c.dim}${value}${c.reset}\n`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function printDiff(oldContent: string | null, newContent: string, filePath: string) {
|
|
38
|
+
const oldLines = oldContent ? oldContent.split("\n") : [];
|
|
39
|
+
const newLines = newContent.split("\n");
|
|
40
|
+
const maxLines = Math.max(oldLines.length, newLines.length);
|
|
41
|
+
const LIMIT = 60;
|
|
42
|
+
const shown = Math.min(maxLines, LIMIT);
|
|
43
|
+
|
|
44
|
+
console.log(`\n${c.bold}${c.white} ┌─ ${filePath} ─────────────────────────${c.reset}`);
|
|
45
|
+
for (let i = 0; i < shown; i++) {
|
|
46
|
+
const o = oldLines[i];
|
|
47
|
+
const n = newLines[i];
|
|
48
|
+
if (o === undefined) {
|
|
49
|
+
process.stdout.write(`${c.green} + ${(i + 1).toString().padStart(4)} ${n ?? ""}${c.reset}\n`);
|
|
50
|
+
} else if (n === undefined) {
|
|
51
|
+
process.stdout.write(`${c.red} - ${(i + 1).toString().padStart(4)} ${o ?? ""}${c.reset}\n`);
|
|
52
|
+
} else if (o !== n) {
|
|
53
|
+
process.stdout.write(`${c.red} - ${(i + 1).toString().padStart(4)} ${o}${c.reset}\n`);
|
|
54
|
+
process.stdout.write(`${c.green} + ${(i + 1).toString().padStart(4)} ${n}${c.reset}\n`);
|
|
55
|
+
} else {
|
|
56
|
+
process.stdout.write(`${c.dim} ${(i + 1).toString().padStart(4)} ${o}${c.reset}\n`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (maxLines > LIMIT) {
|
|
60
|
+
console.log(`${c.dim} ... ${maxLines - LIMIT} more lines omitted${c.reset}`);
|
|
61
|
+
}
|
|
62
|
+
console.log(`${c.bold}${c.white} └──────────────────────────────────────${c.reset}\n`);
|
|
63
|
+
}
|
|
64
|
+
|
|
17
65
|
async function confirm(prompt: string): Promise<boolean> {
|
|
18
66
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
19
67
|
try {
|
|
20
|
-
const answer = await rl.question(`${prompt} [y/N] `);
|
|
68
|
+
const answer = await rl.question(`${c.yellow}${c.bold} ? ${c.reset}${prompt} ${c.dim}[y/N]${c.reset} `);
|
|
21
69
|
return answer.trim().toLowerCase().startsWith("y");
|
|
22
70
|
} finally {
|
|
23
71
|
rl.close();
|
|
@@ -34,40 +82,84 @@ function resolveInCwd(cwd: string, relPath: string): string {
|
|
|
34
82
|
|
|
35
83
|
async function readFileTool(cwd: string, args: { path: string }): Promise<string> {
|
|
36
84
|
const full = resolveInCwd(cwd, args.path);
|
|
85
|
+
header("read_file", args.path);
|
|
37
86
|
return fs.readFile(full, "utf-8");
|
|
38
87
|
}
|
|
39
88
|
|
|
40
89
|
async function writeFileTool(cwd: string, args: { path: string; content: string }): Promise<string> {
|
|
41
|
-
const approved = await confirm(`Write ${args.content.length} bytes to "${args.path}"?`);
|
|
42
|
-
if (!approved) return "User declined to write this file.";
|
|
43
90
|
const full = resolveInCwd(cwd, args.path);
|
|
91
|
+
const lines = args.content.split("\n").length;
|
|
92
|
+
const bytes = Buffer.byteLength(args.content, "utf-8");
|
|
93
|
+
|
|
94
|
+
console.log(`\n${c.bold}${c.magenta} ◆ write_file${c.reset} ${c.dim}${args.path}${c.reset}`);
|
|
95
|
+
console.log(`${c.dim} ${lines} lines · ${bytes} bytes${c.reset}`);
|
|
96
|
+
|
|
97
|
+
// Show diff vs existing file
|
|
98
|
+
let oldContent: string | null = null;
|
|
99
|
+
try {
|
|
100
|
+
oldContent = await fs.readFile(full, "utf-8");
|
|
101
|
+
console.log(`${c.dim} (modifying existing file)${c.reset}`);
|
|
102
|
+
} catch {
|
|
103
|
+
console.log(`${c.dim} (creating new file)${c.reset}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
printDiff(oldContent, args.content, args.path);
|
|
107
|
+
|
|
108
|
+
const approved = await confirm(`Write ${bytes} bytes to "${args.path}"?`);
|
|
109
|
+
if (!approved) return "User declined to write this file.";
|
|
110
|
+
|
|
44
111
|
await fs.mkdir(path.dirname(full), { recursive: true });
|
|
45
112
|
await fs.writeFile(full, args.content, "utf-8");
|
|
46
|
-
|
|
113
|
+
|
|
114
|
+
console.log(`${c.green}${c.bold} ✓ Wrote ${args.path}${c.reset} ${c.dim}(${bytes} bytes)${c.reset}\n`);
|
|
115
|
+
return `Wrote ${bytes} bytes to ${args.path}`;
|
|
47
116
|
}
|
|
48
117
|
|
|
49
118
|
async function runCommandTool(cwd: string, args: { command: string }): Promise<string> {
|
|
50
|
-
|
|
119
|
+
console.log(`\n${c.bold}${c.yellow} ◆ run_command${c.reset} ${c.dim}${args.command}${c.reset}`);
|
|
120
|
+
const approved = await confirm(`Run: ${c.bold}${args.command}${c.reset}?`);
|
|
51
121
|
if (!approved) return "User declined to run this command.";
|
|
122
|
+
|
|
52
123
|
return new Promise((resolve) => {
|
|
53
|
-
exec(args.command, { cwd, timeout: 60_000, maxBuffer: 1024 * 1024 }
|
|
54
|
-
|
|
55
|
-
|
|
124
|
+
const child = exec(args.command, { cwd, timeout: 60_000, maxBuffer: 4 * 1024 * 1024 });
|
|
125
|
+
|
|
126
|
+
let stdout = "";
|
|
127
|
+
let stderr = "";
|
|
128
|
+
|
|
129
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
130
|
+
process.stdout.write(`${c.dim} │ ${chunk}${c.reset}`);
|
|
131
|
+
stdout += chunk;
|
|
132
|
+
});
|
|
133
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
134
|
+
process.stderr.write(`${c.red} │ ${chunk}${c.reset}`);
|
|
135
|
+
stderr += chunk;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
child.on("close", (code) => {
|
|
139
|
+
const combined = (stdout + (stderr ? `\nstderr:\n${stderr}` : "")).trim();
|
|
140
|
+
if (code !== 0) {
|
|
141
|
+
console.log(`${c.red} ✗ exited with code ${code}${c.reset}\n`);
|
|
142
|
+
resolve(`Command failed (exit ${code}):\n${combined || "(no output)"}`);
|
|
56
143
|
} else {
|
|
57
|
-
|
|
144
|
+
console.log(`${c.green} ✓ done${c.reset}\n`);
|
|
145
|
+
resolve(combined || "(no output)");
|
|
58
146
|
}
|
|
59
147
|
});
|
|
60
148
|
});
|
|
61
149
|
}
|
|
62
150
|
|
|
63
151
|
async function gitTool(cwd: string, args: { args: string[] }): Promise<string> {
|
|
152
|
+
const cmd = `git ${args.args.join(" ")}`;
|
|
64
153
|
const isMutating = !["status", "diff", "log", "show", "branch"].includes(args.args[0]);
|
|
154
|
+
|
|
155
|
+
header("git", cmd);
|
|
65
156
|
if (isMutating) {
|
|
66
|
-
const approved = await confirm(`Run:
|
|
157
|
+
const approved = await confirm(`Run: ${c.bold}${cmd}${c.reset}?`);
|
|
67
158
|
if (!approved) return "User declined to run this git command.";
|
|
68
159
|
}
|
|
160
|
+
|
|
69
161
|
return new Promise((resolve) => {
|
|
70
|
-
exec(
|
|
162
|
+
exec(cmd, { cwd, timeout: 30_000, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
|
|
71
163
|
if (err) {
|
|
72
164
|
resolve(`git command failed (${err.message}):\n${stdout}\n${stderr}`);
|
|
73
165
|
} else {
|
|
@@ -77,7 +169,69 @@ async function gitTool(cwd: string, args: { args: string[] }): Promise<string> {
|
|
|
77
169
|
});
|
|
78
170
|
}
|
|
79
171
|
|
|
80
|
-
|
|
172
|
+
async function listDirectoryTool(cwd: string, args: { path: string }): Promise<string> {
|
|
173
|
+
const full = resolveInCwd(cwd, args.path);
|
|
174
|
+
header("list_directory", args.path);
|
|
175
|
+
const entries = await fs.readdir(full, { withFileTypes: true });
|
|
176
|
+
const lines = entries.map(e => {
|
|
177
|
+
if (e.isDirectory()) return ` ${c.blue}${c.bold}${e.name}/${c.reset}`;
|
|
178
|
+
if (e.isSymbolicLink()) return ` ${c.cyan}${e.name}@${c.reset}`;
|
|
179
|
+
return ` ${e.name}`;
|
|
180
|
+
});
|
|
181
|
+
// Print inline preview
|
|
182
|
+
lines.forEach(l => process.stdout.write(l + "\n"));
|
|
183
|
+
return entries
|
|
184
|
+
.map(e => (e.isDirectory() ? `${e.name}/` : e.name))
|
|
185
|
+
.join("\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function searchFilesTool(
|
|
189
|
+
cwd: string,
|
|
190
|
+
args: { pattern: string; glob?: string }
|
|
191
|
+
): Promise<string> {
|
|
192
|
+
// Validate inputs: pattern must be a non-empty string; glob (if provided)
|
|
193
|
+
// must only contain safe filename characters to prevent shell expansion.
|
|
194
|
+
if (!args.pattern || typeof args.pattern !== "string") {
|
|
195
|
+
return "Search failed: pattern must be a non-empty string.";
|
|
196
|
+
}
|
|
197
|
+
if (args.glob !== undefined) {
|
|
198
|
+
if (typeof args.glob !== "string" || /[;&|`$(){}[\]<>!]/.test(args.glob)) {
|
|
199
|
+
return "Search failed: glob contains disallowed characters.";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
header("search_files", `"${args.pattern}"${args.glob ? ` in ${args.glob}` : ""}`);
|
|
204
|
+
|
|
205
|
+
// Build the argument array passed directly to execFile — no shell involved.
|
|
206
|
+
const grepArgs: string[] = [
|
|
207
|
+
"-rn",
|
|
208
|
+
"--color=never",
|
|
209
|
+
"-m", "5",
|
|
210
|
+
];
|
|
211
|
+
if (args.glob) {
|
|
212
|
+
grepArgs.push(`--include=${args.glob}`);
|
|
213
|
+
}
|
|
214
|
+
// Pattern and path as positional args (not shell-interpolated)
|
|
215
|
+
grepArgs.push(args.pattern, ".");
|
|
216
|
+
|
|
217
|
+
return new Promise((resolve) => {
|
|
218
|
+
execFile("grep", grepArgs, { cwd, timeout: 15_000, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
|
|
219
|
+
if (err && (err as NodeJS.ErrnoException & { code: number }).code === 1) {
|
|
220
|
+
// grep exits 1 when no matches are found — not an error
|
|
221
|
+
resolve("No matches found.");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (err) {
|
|
225
|
+
resolve(`Search failed: ${err.message}`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const lines = stdout.trim().split("\n").slice(0, 80);
|
|
229
|
+
lines.forEach(l => console.log(` ${c.dim}${l}${c.reset}`));
|
|
230
|
+
resolve(stdout.trim() || "No matches found.");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
81
235
|
export async function executeToolCall(cwd: string, call: ToolCall): Promise<ToolResult> {
|
|
82
236
|
let args: any;
|
|
83
237
|
try {
|
|
@@ -96,6 +250,10 @@ export async function executeToolCall(cwd: string, call: ToolCall): Promise<Tool
|
|
|
96
250
|
return { result: await runCommandTool(cwd, args), isError: false };
|
|
97
251
|
case "git":
|
|
98
252
|
return { result: await gitTool(cwd, args), isError: false };
|
|
253
|
+
case "list_directory":
|
|
254
|
+
return { result: await listDirectoryTool(cwd, args), isError: false };
|
|
255
|
+
case "search_files":
|
|
256
|
+
return { result: await searchFilesTool(cwd, args), isError: false };
|
|
99
257
|
default:
|
|
100
258
|
return { result: `Unknown tool: ${call.name}`, isError: true };
|
|
101
259
|
}
|