@ayoxx/kundex 0.1.9 → 2.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/package.json +2 -2
- package/src/config.ts +19 -3
- package/src/index.ts +318 -19
- package/src/repl.ts +127 -50
- package/src/sdk.ts +320 -35
- package/src/tools.ts +0 -72
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ayoxx/kundex",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Kundex — a terminal-based AI coding agent CLI and SDK, powered by
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Kundex — a terminal-based AI coding agent CLI and SDK, powered by Mistral and Heavstal AI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"kundex": "./bin/kundex.mjs"
|
package/src/config.ts
CHANGED
|
@@ -33,8 +33,24 @@ export function saveConfig(config: Partial<KundexConfig>): KundexConfig {
|
|
|
33
33
|
return merged;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export const KUNDEX_BASE_URL = "https://
|
|
36
|
+
export const KUNDEX_BASE_URL = "https://api.kundex.com.ng";
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Resolves the base URL to use for API calls.
|
|
40
|
+
*
|
|
41
|
+
* Priority:
|
|
42
|
+
* 1. KUNDEX_BASE_URL environment variable (useful for local dev)
|
|
43
|
+
* 2. Stored baseUrl from config (~/.kundex/config.json)
|
|
44
|
+
* 3. Hard-coded default: https://api.kundex.com.ng
|
|
45
|
+
*
|
|
46
|
+
* Previously, this function ignored the stored baseUrl completely.
|
|
47
|
+
* That made `kundex config set baseUrl <url>` a no-op, which broke
|
|
48
|
+
* local development and self-hosted deployments.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveBaseUrl(config: KundexConfig): string | null {
|
|
51
|
+
return (
|
|
52
|
+
process.env.KUNDEX_BASE_URL?.trim() ||
|
|
53
|
+
config.baseUrl?.trim() ||
|
|
54
|
+
KUNDEX_BASE_URL
|
|
55
|
+
);
|
|
40
56
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,41 +1,340 @@
|
|
|
1
|
-
import { loadConfig, resolveBaseUrl } from "./config";
|
|
1
|
+
import { loadConfig, saveConfig, resolveBaseUrl } from "./config";
|
|
2
2
|
import { runLogin } from "./login";
|
|
3
3
|
import { runRepl } from "./repl";
|
|
4
|
+
import { Kundex } from "./sdk";
|
|
5
|
+
|
|
6
|
+
const VERSION = "1.0.0";
|
|
7
|
+
|
|
8
|
+
// ─── Help text ────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const HELP = `
|
|
11
|
+
\x1b[1m\x1b[36mkundex\x1b[0m — AI coding agent CLI v${VERSION}
|
|
12
|
+
|
|
13
|
+
\x1b[1mUsage:\x1b[0m
|
|
14
|
+
kundex [command] [options]
|
|
15
|
+
|
|
16
|
+
\x1b[1mAuthentication:\x1b[0m
|
|
17
|
+
login Save your Kundex API key
|
|
18
|
+
logout Remove saved credentials
|
|
19
|
+
|
|
20
|
+
\x1b[1mAgent:\x1b[0m
|
|
21
|
+
(no command) Start the interactive AI coding agent REPL
|
|
22
|
+
run <message> Run a single agent turn (non-interactive)
|
|
23
|
+
|
|
24
|
+
\x1b[1mModels:\x1b[0m
|
|
25
|
+
models List available models
|
|
26
|
+
|
|
27
|
+
\x1b[1mConfiguration:\x1b[0m
|
|
28
|
+
config show Show current configuration
|
|
29
|
+
config set <key> <val> Set a config value
|
|
30
|
+
Keys: model, baseUrl
|
|
31
|
+
|
|
32
|
+
\x1b[1mOther:\x1b[0m
|
|
33
|
+
help, --help, -h Show this help
|
|
34
|
+
version, --version, -v Show version
|
|
35
|
+
|
|
36
|
+
\x1b[1mExamples:\x1b[0m
|
|
37
|
+
kundex login
|
|
38
|
+
kundex # start REPL in current directory
|
|
39
|
+
kundex run "Fix all TypeScript errors"
|
|
40
|
+
kundex models
|
|
41
|
+
kundex config set model codestral-latest
|
|
42
|
+
kundex config set baseUrl https://api.kundex.com.ng
|
|
43
|
+
`.trim();
|
|
44
|
+
|
|
45
|
+
// ─── Colour helpers ───────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const c = {
|
|
48
|
+
reset: "\x1b[0m",
|
|
49
|
+
bold: "\x1b[1m",
|
|
50
|
+
dim: "\x1b[2m",
|
|
51
|
+
cyan: "\x1b[36m",
|
|
52
|
+
green: "\x1b[32m",
|
|
53
|
+
yellow: "\x1b[33m",
|
|
54
|
+
red: "\x1b[31m",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function ok(msg: string) { console.log(`${c.green}${c.bold} ✓${c.reset} ${msg}`); }
|
|
58
|
+
function errMsg(msg: string) { console.error(`${c.red}${c.bold} ✗${c.reset} ${msg}`); }
|
|
59
|
+
function dim(msg: string) { console.log(`${c.dim} ${msg}${c.reset}`); }
|
|
60
|
+
function header(msg: string) { console.log(`\n${c.bold}${c.cyan} ${msg}${c.reset}`); }
|
|
61
|
+
|
|
62
|
+
// ─── Commands ─────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
const PROVIDER_DISPLAY: Record<string, string> = {
|
|
65
|
+
heavstal: "Heavstal",
|
|
66
|
+
mistral: "Mistral",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
async function cmdModels(config: ReturnType<typeof loadConfig>, baseUrl: string) {
|
|
70
|
+
const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
|
|
71
|
+
header("Available models");
|
|
72
|
+
try {
|
|
73
|
+
const models = await kundex.models.list();
|
|
74
|
+
if (!models.length) { dim("No models found."); return; }
|
|
75
|
+
const nameWidth = Math.max(...models.map((m) => m.name.length), 4);
|
|
76
|
+
const idWidth = Math.max(...models.map((m) => m.id.length), 2);
|
|
77
|
+
const providerWidth = Math.max(...models.map((m) => (m.provider?.length ?? 0)), 8);
|
|
78
|
+
console.log(`\n ${c.dim}${"Name".padEnd(nameWidth)} ${"ID".padEnd(idWidth)} ${"Provider".padEnd(providerWidth)} Context${c.reset}`);
|
|
79
|
+
console.log(` ${c.dim}${"─".repeat(nameWidth + idWidth + providerWidth + 18)}${c.reset}`);
|
|
80
|
+
|
|
81
|
+
const grouped: Record<string, typeof models> = {};
|
|
82
|
+
const order: string[] = [];
|
|
83
|
+
for (const m of models) {
|
|
84
|
+
const p = m.provider ?? "other";
|
|
85
|
+
if (!grouped[p]) { grouped[p] = []; order.push(p); }
|
|
86
|
+
grouped[p].push(m);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const provider of order) {
|
|
90
|
+
for (const m of grouped[provider]) {
|
|
91
|
+
const ctx = m.contextWindow >= 1_000_000
|
|
92
|
+
? `${(m.contextWindow / 1_000_000).toFixed(1)}M`
|
|
93
|
+
: m.contextWindow >= 1000
|
|
94
|
+
? `${(m.contextWindow / 1000).toFixed(0)}k`
|
|
95
|
+
: String(m.contextWindow);
|
|
96
|
+
const providerLabel = (PROVIDER_DISPLAY[m.provider ?? ""] ?? m.provider ?? "").padEnd(providerWidth);
|
|
97
|
+
console.log(
|
|
98
|
+
` ${c.bold}${m.name.padEnd(nameWidth)}${c.reset}` +
|
|
99
|
+
` ${c.dim}${m.id.padEnd(idWidth)}${c.reset}` +
|
|
100
|
+
` ${c.cyan}${providerLabel}${c.reset}` +
|
|
101
|
+
` ${c.dim}${ctx}${c.reset}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
console.log();
|
|
106
|
+
} catch (e) {
|
|
107
|
+
errMsg(`Failed to list models: ${(e as Error).message}`);
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Run (single-turn non-interactive) ─────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
async function cmdRun(
|
|
115
|
+
config: ReturnType<typeof loadConfig>,
|
|
116
|
+
baseUrl: string,
|
|
117
|
+
message: string,
|
|
118
|
+
args: string[],
|
|
119
|
+
) {
|
|
120
|
+
if (!message) {
|
|
121
|
+
errMsg("Usage: kundex run <message>");
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let model = config.defaultModel ?? "codestral-latest";
|
|
127
|
+
let cwd = process.cwd();
|
|
128
|
+
for (let i = 0; i < args.length; i++) {
|
|
129
|
+
if (args[i] === "--model" && args[i + 1]) model = args[++i];
|
|
130
|
+
else if (args[i] === "--cwd" && args[i + 1]) cwd = args[++i];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const session = await kundex.agent.createSession({ cwd, model });
|
|
137
|
+
const sessionId = session.id;
|
|
138
|
+
|
|
139
|
+
process.stdout.write(`\n`);
|
|
140
|
+
const { executeToolCall } = await import("./tools");
|
|
141
|
+
|
|
142
|
+
let turn = await kundex.agent.streamMessage(
|
|
143
|
+
{ sessionId, message },
|
|
144
|
+
(delta) => process.stdout.write(delta),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
while (!turn.done && turn.toolCalls && turn.toolCalls.length > 0) {
|
|
148
|
+
for (const call of turn.toolCalls) {
|
|
149
|
+
const { result, isError } = await executeToolCall(cwd, call);
|
|
150
|
+
turn = await kundex.agent.submitToolResult(
|
|
151
|
+
{ sessionId, toolCallId: call.id, result, isError },
|
|
152
|
+
(delta) => process.stdout.write(delta),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
process.stdout.write("\n");
|
|
158
|
+
} catch (e) {
|
|
159
|
+
errMsg(`Agent error: ${(e as Error).message}`);
|
|
160
|
+
process.exitCode = 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Config commands ──────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
function cmdConfigShow(config: ReturnType<typeof loadConfig>, baseUrl: string) {
|
|
167
|
+
header("Current configuration");
|
|
168
|
+
console.log();
|
|
169
|
+
const rows = [
|
|
170
|
+
["API key", config.apiKey ? `${config.apiKey.slice(0, 8)}…` : "(not set)"],
|
|
171
|
+
["Base URL", baseUrl],
|
|
172
|
+
["Model", config.defaultModel ?? "(default)"],
|
|
173
|
+
];
|
|
174
|
+
for (const [k, v] of rows) {
|
|
175
|
+
console.log(` ${c.dim}${k.padEnd(12)}${c.reset} ${c.bold}${v}${c.reset}`);
|
|
176
|
+
}
|
|
177
|
+
console.log();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function cmdConfigSet(key: string, value: string) {
|
|
181
|
+
const allowed = ["model", "baseUrl"];
|
|
182
|
+
if (!allowed.includes(key)) {
|
|
183
|
+
errMsg(`Unknown config key "${key}". Allowed keys: ${allowed.join(", ")}`);
|
|
184
|
+
process.exitCode = 1;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const map: Record<string, keyof ReturnType<typeof loadConfig>> = {
|
|
189
|
+
model: "defaultModel",
|
|
190
|
+
baseUrl: "baseUrl",
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
saveConfig({ [map[key]]: value });
|
|
194
|
+
ok(`Set ${key} = ${value}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function cmdLogout() {
|
|
198
|
+
saveConfig({ apiKey: null, baseUrl: null, defaultModel: null });
|
|
199
|
+
ok("Logged out. Run `kundex login` to authenticate again.");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── API key prompt ──────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
async function promptForApiKey(): Promise<string | null> {
|
|
205
|
+
const readline = await import("node:readline/promises");
|
|
206
|
+
const rl = readline.default.createInterface({ input: process.stdin, output: process.stdout });
|
|
207
|
+
try {
|
|
208
|
+
console.log(`\n${c.bold}${c.cyan} Welcome to Kundex!${c.reset}`);
|
|
209
|
+
console.log(`${c.dim} You need an API key to use the agent.${c.reset}`);
|
|
210
|
+
console.log(`${c.dim} Get one at: https://kundex.com.ng/dashboard/api-keys${c.reset}\n`);
|
|
211
|
+
const answer = await rl.question(`${c.bold} Enter your API key: ${c.reset}`);
|
|
212
|
+
const apiKey = answer.trim();
|
|
213
|
+
if (!apiKey) {
|
|
214
|
+
console.log(`${c.dim} No key entered. Run \`kundex login\` later to set one.${c.reset}\n`);
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
saveConfig({ apiKey });
|
|
218
|
+
ok("API key saved.");
|
|
219
|
+
return apiKey;
|
|
220
|
+
} finally {
|
|
221
|
+
rl.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
4
226
|
|
|
5
227
|
async function main(): Promise<void> {
|
|
6
|
-
const
|
|
228
|
+
const args = process.argv.slice(2);
|
|
229
|
+
const [command, sub, ...rest] = args;
|
|
7
230
|
|
|
8
|
-
|
|
9
|
-
|
|
231
|
+
// Version
|
|
232
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
233
|
+
console.log(`kundex v${VERSION}`);
|
|
10
234
|
return;
|
|
11
235
|
}
|
|
12
236
|
|
|
237
|
+
// Help
|
|
13
238
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
14
|
-
console.log(
|
|
15
|
-
[
|
|
16
|
-
"kundeX — terminal AI coding agent.",
|
|
17
|
-
"",
|
|
18
|
-
"Usage:",
|
|
19
|
-
" kundex login Save your API key and API base URL",
|
|
20
|
-
" kundex Start the agent REPL in the current directory",
|
|
21
|
-
].join("\n"),
|
|
22
|
-
);
|
|
239
|
+
console.log(HELP);
|
|
23
240
|
return;
|
|
24
241
|
}
|
|
25
242
|
|
|
26
|
-
|
|
243
|
+
// No command — start REPL, prompting for API key if needed
|
|
244
|
+
if (!command) {
|
|
245
|
+
let config = loadConfig();
|
|
246
|
+
const baseUrl = resolveBaseUrl(config);
|
|
247
|
+
|
|
248
|
+
if (!config.apiKey) {
|
|
249
|
+
const key = await promptForApiKey();
|
|
250
|
+
if (!key) {
|
|
251
|
+
console.log(HELP);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
config = loadConfig();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (config.apiKey && baseUrl) {
|
|
258
|
+
await runRepl(config, config.apiKey, baseUrl);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
console.log(HELP);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Login / logout
|
|
266
|
+
if (command === "login") {
|
|
267
|
+
await runLogin();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (command === "logout") {
|
|
271
|
+
cmdLogout();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Load config — required for all remaining commands
|
|
276
|
+
let config = loadConfig();
|
|
27
277
|
const baseUrl = resolveBaseUrl(config);
|
|
28
278
|
|
|
29
279
|
if (!config.apiKey || !baseUrl) {
|
|
30
|
-
|
|
31
|
-
|
|
280
|
+
if (!config.apiKey) {
|
|
281
|
+
const key = await promptForApiKey();
|
|
282
|
+
if (!key) {
|
|
283
|
+
process.exitCode = 1;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
config = loadConfig();
|
|
287
|
+
}
|
|
288
|
+
if (!config.apiKey || !resolveBaseUrl(config)) {
|
|
289
|
+
errMsg('Not logged in. Run "kundex login" first.');
|
|
290
|
+
process.exitCode = 1;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Models
|
|
296
|
+
if (command === "models") {
|
|
297
|
+
await cmdModels(config, resolveBaseUrl(config)!);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Config
|
|
302
|
+
if (command === "config") {
|
|
303
|
+
if (sub === "show" || !sub) {
|
|
304
|
+
cmdConfigShow(config, resolveBaseUrl(config)!);
|
|
305
|
+
} else if (sub === "set") {
|
|
306
|
+
const [key, value] = rest;
|
|
307
|
+
if (!key || !value) {
|
|
308
|
+
errMsg("Usage: kundex config set <key> <value>");
|
|
309
|
+
process.exitCode = 1;
|
|
310
|
+
} else {
|
|
311
|
+
cmdConfigSet(key, value);
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
errMsg(`Unknown config subcommand "${sub}". Use: show, set`);
|
|
315
|
+
process.exitCode = 1;
|
|
316
|
+
}
|
|
32
317
|
return;
|
|
33
318
|
}
|
|
34
319
|
|
|
35
|
-
|
|
320
|
+
// Run (single-turn agent)
|
|
321
|
+
if (command === "run") {
|
|
322
|
+
const allArgs = [sub, ...rest].filter(Boolean);
|
|
323
|
+
const flagStart = allArgs.findIndex((a) => a.startsWith("--"));
|
|
324
|
+
const messageParts = flagStart >= 0 ? allArgs.slice(0, flagStart) : allArgs;
|
|
325
|
+
const flagArgs = flagStart >= 0 ? allArgs.slice(flagStart) : [];
|
|
326
|
+
const message = messageParts.join(" ").trim();
|
|
327
|
+
|
|
328
|
+
await cmdRun(config, resolveBaseUrl(config)!, message, flagArgs);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Unknown command — suggest help
|
|
333
|
+
errMsg(`Unknown command "${command}". Run \`kundex help\` to see available commands.`);
|
|
334
|
+
process.exitCode = 1;
|
|
36
335
|
}
|
|
37
336
|
|
|
38
|
-
main().catch((
|
|
39
|
-
console.error(
|
|
337
|
+
main().catch((e) => {
|
|
338
|
+
console.error(`${c.red} Fatal: ${e instanceof Error ? e.message : e}${c.reset}`);
|
|
40
339
|
process.exitCode = 1;
|
|
41
340
|
});
|
package/src/repl.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import readline from "node:readline/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { execSync } from "node:child_process";
|
|
4
|
-
import { Kundex, type AgentTurn } from "./sdk";
|
|
4
|
+
import { Kundex, type AgentTurn, type AgentSession } from "./sdk";
|
|
5
5
|
import { executeToolCall } from "./tools";
|
|
6
6
|
import type { KundexConfig } from "./config";
|
|
7
7
|
|
|
@@ -18,14 +18,13 @@ const c = {
|
|
|
18
18
|
blue: "\x1b[34m",
|
|
19
19
|
magenta: "\x1b[35m",
|
|
20
20
|
white: "\x1b[37m",
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
punct: "\x1b[38;5;250m", // punctuation – light grey
|
|
21
|
+
kw: "\x1b[38;5;204m",
|
|
22
|
+
str: "\x1b[38;5;150m",
|
|
23
|
+
num: "\x1b[38;5;141m",
|
|
24
|
+
comment: "\x1b[38;5;244m",
|
|
25
|
+
fn: "\x1b[38;5;117m",
|
|
26
|
+
type_: "\x1b[38;5;215m",
|
|
27
|
+
punct: "\x1b[38;5;250m",
|
|
29
28
|
};
|
|
30
29
|
|
|
31
30
|
// ─── Per-language syntax highlighter (ANSI, no deps) ─────────────────────────
|
|
@@ -56,33 +55,26 @@ const KEYWORDS: Record<string, string[]> = {
|
|
|
56
55
|
function buildRules(lang: string): LangRule[] {
|
|
57
56
|
const kws = KEYWORDS[lang] || KEYWORDS["javascript"];
|
|
58
57
|
return [
|
|
59
|
-
// Comments
|
|
60
58
|
{ pattern: /(#.*$|\/\/.*$|\/\*[\s\S]*?\*\/|"""[\s\S]*?"""|'''[\s\S]*?''')/gm, color: c.comment },
|
|
61
|
-
// Strings
|
|
62
59
|
{ pattern: /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g, color: c.str },
|
|
63
|
-
// Numbers
|
|
64
60
|
{ pattern: /\b(\d+\.?\d*(?:e[+-]?\d+)?)\b/g, color: c.num },
|
|
65
|
-
// Keywords (built from per-language list)
|
|
66
61
|
{ pattern: new RegExp(`\\b(${kws.join("|")})\\b`, "g"), color: c.kw },
|
|
67
|
-
// Function calls
|
|
68
62
|
{ pattern: /\b([a-zA-Z_]\w*)\s*(?=\()/g, color: c.fn },
|
|
69
|
-
// Type annotations (CamelCase identifiers)
|
|
70
63
|
{ pattern: /\b([A-Z][a-zA-Z0-9_]*)\b/g, color: c.type_ },
|
|
71
64
|
];
|
|
72
65
|
}
|
|
73
66
|
|
|
74
|
-
/** Apply regex-based ANSI syntax highlighting to a code string. */
|
|
75
67
|
function syntaxHighlight(code: string, lang: string): string {
|
|
76
|
-
const l = lang.toLowerCase()
|
|
77
|
-
const normalised = ["
|
|
78
|
-
: ["
|
|
68
|
+
const l = lang.toLowerCase();
|
|
69
|
+
const normalised = ["tsx","ts"].includes(l) ? "typescript"
|
|
70
|
+
: ["jsx","js","mjs"].includes(l) ? "javascript"
|
|
71
|
+
: ["python","py"].includes(l) ? "python"
|
|
72
|
+
: ["sh","shell","zsh","bash"].includes(l) ? "bash"
|
|
79
73
|
: ["rs"].includes(l) ? "rust"
|
|
80
74
|
: l;
|
|
81
75
|
|
|
82
76
|
const rules = buildRules(normalised in KEYWORDS ? normalised : "javascript");
|
|
83
77
|
|
|
84
|
-
// We work character-by-character with a simple placeholder approach:
|
|
85
|
-
// collect all matches with their positions, sort them, then reconstruct.
|
|
86
78
|
type Span = { start: number; end: number; color: string };
|
|
87
79
|
const spans: Span[] = [];
|
|
88
80
|
|
|
@@ -97,7 +89,6 @@ function syntaxHighlight(code: string, lang: string): string {
|
|
|
97
89
|
|
|
98
90
|
if (spans.length === 0) return code;
|
|
99
91
|
|
|
100
|
-
// Sort by start; discard overlapping spans (first wins)
|
|
101
92
|
spans.sort((a, b) => a.start - b.start);
|
|
102
93
|
const merged: Span[] = [];
|
|
103
94
|
let cursor = 0;
|
|
@@ -144,7 +135,6 @@ function printReply(text: string) {
|
|
|
144
135
|
while ((match = codeBlockRe.exec(text)) !== null) {
|
|
145
136
|
const before = text.slice(last, match.index);
|
|
146
137
|
if (before.trim()) {
|
|
147
|
-
// Wrap plain text at ~100 chars
|
|
148
138
|
before.split("\n").forEach(line => {
|
|
149
139
|
process.stdout.write(` ${line}\n`);
|
|
150
140
|
});
|
|
@@ -166,15 +156,17 @@ function printReply(text: string) {
|
|
|
166
156
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
167
157
|
const HELP = `
|
|
168
158
|
${c.bold}Commands:${c.reset}
|
|
169
|
-
/help
|
|
170
|
-
/model
|
|
171
|
-
/clear
|
|
172
|
-
/exit
|
|
159
|
+
/help Show this help
|
|
160
|
+
/model Show the model for this session
|
|
161
|
+
/clear Start a fresh agent session
|
|
162
|
+
/exit Quit kundex
|
|
173
163
|
|
|
174
164
|
${c.bold}Tips:${c.reset}
|
|
175
|
-
• The agent can read,
|
|
176
|
-
• You will be asked to confirm any
|
|
165
|
+
• The agent can read files, search code, run commands, and use git.
|
|
166
|
+
• You will be asked to confirm any shell commands before they run.
|
|
167
|
+
• Responses stream in real-time — text appears as the model generates it.
|
|
177
168
|
• Code blocks are syntax-highlighted in the terminal.
|
|
169
|
+
• Press Ctrl+C to abort a running request.
|
|
178
170
|
`;
|
|
179
171
|
|
|
180
172
|
function safeGitStatus(cwd: string): string | undefined {
|
|
@@ -196,11 +188,27 @@ function safeFileTree(cwd: string): string | undefined {
|
|
|
196
188
|
}
|
|
197
189
|
}
|
|
198
190
|
|
|
191
|
+
function createSpinner(label: string) {
|
|
192
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
193
|
+
let frame = 0;
|
|
194
|
+
const timer = setInterval(() => {
|
|
195
|
+
process.stdout.write(`\r${c.dim} ${frames[frame++ % frames.length]} ${label}${c.reset} `);
|
|
196
|
+
}, 80);
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
clear() {
|
|
200
|
+
clearInterval(timer);
|
|
201
|
+
process.stdout.write("\r\x1b[2K");
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
199
206
|
async function handleTurn(
|
|
200
207
|
kundex: Kundex,
|
|
201
208
|
cwd: string,
|
|
202
209
|
sessionId: number,
|
|
203
210
|
turn: AgentTurn,
|
|
211
|
+
signal?: AbortSignal,
|
|
204
212
|
): Promise<void> {
|
|
205
213
|
let current = turn;
|
|
206
214
|
|
|
@@ -210,16 +218,36 @@ async function handleTurn(
|
|
|
210
218
|
if (isError) {
|
|
211
219
|
process.stdout.write(`${c.red} ✗ ${call.name}: ${result}${c.reset}\n`);
|
|
212
220
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
221
|
+
|
|
222
|
+
const sp = createSpinner("processing tool result…");
|
|
223
|
+
let followUpText = "";
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
current = await kundex.agent.submitToolResult(
|
|
227
|
+
{ sessionId, toolCallId: call.id, result, isError },
|
|
228
|
+
(delta) => {
|
|
229
|
+
if (!followUpText) {
|
|
230
|
+
sp.clear();
|
|
231
|
+
process.stdout.write("\n");
|
|
232
|
+
}
|
|
233
|
+
followUpText += delta;
|
|
234
|
+
process.stdout.write(delta);
|
|
235
|
+
},
|
|
236
|
+
signal,
|
|
237
|
+
);
|
|
238
|
+
} finally {
|
|
239
|
+
sp.clear();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (followUpText && !followUpText.endsWith("\n")) {
|
|
243
|
+
process.stdout.write("\n");
|
|
244
|
+
}
|
|
219
245
|
}
|
|
220
246
|
}
|
|
221
247
|
|
|
222
|
-
if (current.reply) {
|
|
248
|
+
if (current.reply && !turn.toolCalls?.length) {
|
|
249
|
+
process.stdout.write(`\n${c.dim}${"─".repeat(60)}${c.reset}\n\n`);
|
|
250
|
+
} else if (current.reply) {
|
|
223
251
|
printReply(current.reply);
|
|
224
252
|
}
|
|
225
253
|
}
|
|
@@ -238,7 +266,7 @@ ${c.bold}${c.cyan} ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═
|
|
|
238
266
|
${c.dim}cwd:${c.reset} ${c.bold}${shortCwd}${c.reset}
|
|
239
267
|
${c.dim}model:${c.reset} ${c.bold}${model}${c.reset}
|
|
240
268
|
|
|
241
|
-
Type ${c.cyan}/help${c.reset} for commands.
|
|
269
|
+
Type ${c.cyan}/help${c.reset} for commands, ${c.cyan}Ctrl+C${c.reset} to stop a running request.
|
|
242
270
|
`);
|
|
243
271
|
}
|
|
244
272
|
|
|
@@ -246,20 +274,36 @@ ${c.bold}${c.cyan} ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═
|
|
|
246
274
|
export async function runRepl(config: KundexConfig, apiKey: string, baseUrl: string): Promise<void> {
|
|
247
275
|
const cwd = process.cwd();
|
|
248
276
|
const kundex = new Kundex({ apiKey, baseUrl });
|
|
249
|
-
const model = config.defaultModel ?? "
|
|
277
|
+
const model = config.defaultModel ?? "codestral-latest";
|
|
250
278
|
|
|
251
279
|
printBanner(cwd, model);
|
|
252
280
|
|
|
253
281
|
let session = await kundex.agent.createSession({ cwd, model });
|
|
254
282
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
255
283
|
|
|
284
|
+
let currentAbort: AbortController | null = null;
|
|
285
|
+
|
|
286
|
+
const sigintHandler = () => {
|
|
287
|
+
if (currentAbort && !currentAbort.signal.aborted) {
|
|
288
|
+
currentAbort.abort();
|
|
289
|
+
process.stdout.write(`\n${c.yellow} ⚡ Stopped.${c.reset}\n\n`);
|
|
290
|
+
currentAbort = null;
|
|
291
|
+
} else {
|
|
292
|
+
console.log(`\n${c.dim}Goodbye!${c.reset}\n`);
|
|
293
|
+
rl.close();
|
|
294
|
+
process.exit(0);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
process.on("SIGINT", sigintHandler);
|
|
299
|
+
|
|
256
300
|
try {
|
|
257
301
|
for (;;) {
|
|
258
302
|
const raw = await rl.question(`${c.bold}${c.cyan}kundex${c.reset} ${c.dim}▸${c.reset} `);
|
|
259
303
|
const input = raw.trim();
|
|
260
304
|
if (!input) continue;
|
|
261
305
|
|
|
262
|
-
if (input === "/exit") {
|
|
306
|
+
if (input === "/exit" || input === "/quit") {
|
|
263
307
|
console.log(`\n${c.dim}Goodbye!${c.reset}\n`);
|
|
264
308
|
break;
|
|
265
309
|
}
|
|
@@ -274,23 +318,56 @@ export async function runRepl(config: KundexConfig, apiKey: string, baseUrl: str
|
|
|
274
318
|
continue;
|
|
275
319
|
}
|
|
276
320
|
|
|
321
|
+
// ── Agent message ────────────────────────────────────────────────────────
|
|
322
|
+
currentAbort = new AbortController();
|
|
323
|
+
const { signal } = currentAbort;
|
|
324
|
+
const spinner = createSpinner("thinking…");
|
|
325
|
+
let streamedAnyText = false;
|
|
326
|
+
|
|
277
327
|
try {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
328
|
+
const turn = await kundex.agent.streamMessage(
|
|
329
|
+
{
|
|
330
|
+
sessionId: session.id,
|
|
331
|
+
message: input,
|
|
332
|
+
context: {
|
|
333
|
+
gitStatus: safeGitStatus(cwd),
|
|
334
|
+
fileTree: safeFileTree(cwd),
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
(delta) => {
|
|
338
|
+
if (!streamedAnyText) {
|
|
339
|
+
spinner.clear();
|
|
340
|
+
process.stdout.write("\n");
|
|
341
|
+
streamedAnyText = true;
|
|
342
|
+
}
|
|
343
|
+
process.stdout.write(delta);
|
|
286
344
|
},
|
|
287
|
-
|
|
288
|
-
|
|
345
|
+
signal,
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
spinner.clear();
|
|
349
|
+
|
|
350
|
+
if (turn.toolCalls?.length) {
|
|
351
|
+
await handleTurn(kundex, cwd, session.id, turn, signal);
|
|
352
|
+
} else if (streamedAnyText) {
|
|
353
|
+
process.stdout.write(`\n${c.dim}${"─".repeat(60)}${c.reset}\n\n`);
|
|
354
|
+
} else if (turn.reply) {
|
|
355
|
+
printReply(turn.reply);
|
|
356
|
+
}
|
|
289
357
|
} catch (err) {
|
|
290
|
-
|
|
358
|
+
spinner.clear();
|
|
359
|
+
const e = err as Error;
|
|
360
|
+
if (e.name === "AbortError") {
|
|
361
|
+
// Already printed "⚡ Stopped" via sigintHandler
|
|
362
|
+
} else {
|
|
363
|
+
console.error(`\n${c.red} error: ${e.message}${c.reset}\n`);
|
|
364
|
+
}
|
|
365
|
+
} finally {
|
|
366
|
+
currentAbort = null;
|
|
291
367
|
}
|
|
292
368
|
}
|
|
293
369
|
} finally {
|
|
370
|
+
process.off("SIGINT", sigintHandler);
|
|
294
371
|
rl.close();
|
|
295
372
|
}
|
|
296
373
|
}
|
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", "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
|
@@ -25,7 +25,6 @@ const c = {
|
|
|
25
25
|
blue: "\x1b[34m",
|
|
26
26
|
magenta: "\x1b[35m",
|
|
27
27
|
white: "\x1b[37m",
|
|
28
|
-
bgDark: "\x1b[48;5;235m",
|
|
29
28
|
};
|
|
30
29
|
|
|
31
30
|
function header(label: string, value: string) {
|
|
@@ -34,34 +33,6 @@ function header(label: string, value: string) {
|
|
|
34
33
|
);
|
|
35
34
|
}
|
|
36
35
|
|
|
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
|
-
|
|
65
36
|
async function confirm(prompt: string): Promise<boolean> {
|
|
66
37
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
67
38
|
try {
|
|
@@ -86,35 +57,6 @@ async function readFileTool(cwd: string, args: { path: string }): Promise<string
|
|
|
86
57
|
return fs.readFile(full, "utf-8");
|
|
87
58
|
}
|
|
88
59
|
|
|
89
|
-
async function writeFileTool(cwd: string, args: { path: string; content: string }): Promise<string> {
|
|
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
|
-
|
|
111
|
-
await fs.mkdir(path.dirname(full), { recursive: true });
|
|
112
|
-
await fs.writeFile(full, args.content, "utf-8");
|
|
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}`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
60
|
async function runCommandTool(cwd: string, args: { command: string }): Promise<string> {
|
|
119
61
|
console.log(`\n${c.bold}${c.yellow} ◆ run_command${c.reset} ${c.dim}${args.command}${c.reset}`);
|
|
120
62
|
const approved = await confirm(`Run: ${c.bold}${args.command}${c.reset}?`);
|
|
@@ -150,13 +92,7 @@ async function runCommandTool(cwd: string, args: { command: string }): Promise<s
|
|
|
150
92
|
|
|
151
93
|
async function gitTool(cwd: string, args: { args: string[] }): Promise<string> {
|
|
152
94
|
const cmd = `git ${args.args.join(" ")}`;
|
|
153
|
-
const isMutating = !["status", "diff", "log", "show", "branch"].includes(args.args[0]);
|
|
154
|
-
|
|
155
95
|
header("git", cmd);
|
|
156
|
-
if (isMutating) {
|
|
157
|
-
const approved = await confirm(`Run: ${c.bold}${cmd}${c.reset}?`);
|
|
158
|
-
if (!approved) return "User declined to run this git command.";
|
|
159
|
-
}
|
|
160
96
|
|
|
161
97
|
return new Promise((resolve) => {
|
|
162
98
|
exec(cmd, { cwd, timeout: 30_000, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
|
|
@@ -178,7 +114,6 @@ async function listDirectoryTool(cwd: string, args: { path: string }): Promise<s
|
|
|
178
114
|
if (e.isSymbolicLink()) return ` ${c.cyan}${e.name}@${c.reset}`;
|
|
179
115
|
return ` ${e.name}`;
|
|
180
116
|
});
|
|
181
|
-
// Print inline preview
|
|
182
117
|
lines.forEach(l => process.stdout.write(l + "\n"));
|
|
183
118
|
return entries
|
|
184
119
|
.map(e => (e.isDirectory() ? `${e.name}/` : e.name))
|
|
@@ -189,8 +124,6 @@ async function searchFilesTool(
|
|
|
189
124
|
cwd: string,
|
|
190
125
|
args: { pattern: string; glob?: string }
|
|
191
126
|
): 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
127
|
if (!args.pattern || typeof args.pattern !== "string") {
|
|
195
128
|
return "Search failed: pattern must be a non-empty string.";
|
|
196
129
|
}
|
|
@@ -202,7 +135,6 @@ async function searchFilesTool(
|
|
|
202
135
|
|
|
203
136
|
header("search_files", `"${args.pattern}"${args.glob ? ` in ${args.glob}` : ""}`);
|
|
204
137
|
|
|
205
|
-
// Build the argument array passed directly to execFile — no shell involved.
|
|
206
138
|
const grepArgs: string[] = [
|
|
207
139
|
"-rn",
|
|
208
140
|
"--color=never",
|
|
@@ -211,13 +143,11 @@ async function searchFilesTool(
|
|
|
211
143
|
if (args.glob) {
|
|
212
144
|
grepArgs.push(`--include=${args.glob}`);
|
|
213
145
|
}
|
|
214
|
-
// Pattern and path as positional args (not shell-interpolated)
|
|
215
146
|
grepArgs.push(args.pattern, ".");
|
|
216
147
|
|
|
217
148
|
return new Promise((resolve) => {
|
|
218
149
|
execFile("grep", grepArgs, { cwd, timeout: 15_000, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
|
|
219
150
|
if (err && (err as NodeJS.ErrnoException & { code: number }).code === 1) {
|
|
220
|
-
// grep exits 1 when no matches are found — not an error
|
|
221
151
|
resolve("No matches found.");
|
|
222
152
|
return;
|
|
223
153
|
}
|
|
@@ -244,8 +174,6 @@ export async function executeToolCall(cwd: string, call: ToolCall): Promise<Tool
|
|
|
244
174
|
switch (call.name) {
|
|
245
175
|
case "read_file":
|
|
246
176
|
return { result: await readFileTool(cwd, args), isError: false };
|
|
247
|
-
case "write_file":
|
|
248
|
-
return { result: await writeFileTool(cwd, args), isError: false };
|
|
249
177
|
case "run_command":
|
|
250
178
|
return { result: await runCommandTool(cwd, args), isError: false };
|
|
251
179
|
case "git":
|