@ayoxx/kundex 0.1.9 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayoxx/kundex",
3
- "version": "0.1.9",
3
+ "version": "1.0.0",
4
4
  "description": "Kundex — a terminal-based AI coding agent CLI and SDK, powered by Groq.",
5
5
  "type": "module",
6
6
  "bin": {
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://kundex-api.onrender.com";
36
+ export const KUNDEX_BASE_URL = "https://api.kundex.com.ng";
37
37
 
38
- export function resolveBaseUrl(_config: KundexConfig): string | null {
39
- return KUNDEX_BASE_URL;
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,575 @@
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
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+
8
+ const VERSION = "0.2.0";
9
+
10
+ // ─── Help text ────────────────────────────────────────────────────────────────
11
+
12
+ const HELP = `
13
+ \x1b[1m\x1b[36mkundex\x1b[0m — AI coding agent CLI v${VERSION}
14
+
15
+ \x1b[1mUsage:\x1b[0m
16
+ kundex [command] [options]
17
+
18
+ \x1b[1mAuthentication:\x1b[0m
19
+ login Save your Kundex API key
20
+ logout Remove saved credentials
21
+
22
+ \x1b[1mAgent:\x1b[0m
23
+ (no command) Start the interactive AI coding agent REPL
24
+ run <message> Run a single agent turn (non-interactive)
25
+
26
+ \x1b[1mWorkspaces:\x1b[0m
27
+ workspace list List all your agent workspaces
28
+ workspace new [cwd] Create a new workspace (default: current dir)
29
+ workspace delete <id> Delete a workspace
30
+
31
+ \x1b[1mFiles:\x1b[0m
32
+ files list <wsId> List files in a workspace
33
+ files get <wsId> <path> Print a file's content to stdout
34
+ files push <wsId> <localPath> Upload a local file to a workspace
35
+ files push <wsId> <localPath> <remotePath>
36
+ files pull <wsId> <remotePath> Download a workspace file to stdout (or --out)
37
+
38
+ \x1b[1mModels:\x1b[0m
39
+ models List available models
40
+
41
+ \x1b[1mConfiguration:\x1b[0m
42
+ config show Show current configuration
43
+ config set <key> <val> Set a config value
44
+ Keys: model, baseUrl
45
+
46
+ \x1b[1mOther:\x1b[0m
47
+ help, --help, -h Show this help
48
+ version, --version, -v Show version
49
+
50
+ \x1b[1mExamples:\x1b[0m
51
+ kundex login
52
+ kundex # start REPL in current directory
53
+ kundex run "Fix all TypeScript errors"
54
+ kundex models
55
+ kundex workspace list
56
+ kundex workspace new /path/to/project
57
+ kundex workspace delete 42
58
+ kundex files list 17
59
+ kundex files get 17 src/index.ts
60
+ kundex files push 17 ./README.md
61
+ kundex files pull 17 src/index.ts --out ./downloaded.ts
62
+ kundex config set model openai/gpt-oss-120b
63
+ kundex config set baseUrl https://api.kundex.com.ng
64
+ `.trim();
65
+
66
+ // ─── Colour helpers ───────────────────────────────────────────────────────────
67
+
68
+ const c = {
69
+ reset: "\x1b[0m",
70
+ bold: "\x1b[1m",
71
+ dim: "\x1b[2m",
72
+ cyan: "\x1b[36m",
73
+ green: "\x1b[32m",
74
+ yellow: "\x1b[33m",
75
+ red: "\x1b[31m",
76
+ };
77
+
78
+ function ok(msg: string) { console.log(`${c.green}${c.bold} ✓${c.reset} ${msg}`); }
79
+ function errMsg(msg: string) { console.error(`${c.red}${c.bold} ✗${c.reset} ${msg}`); }
80
+ function dim(msg: string) { console.log(`${c.dim} ${msg}${c.reset}`); }
81
+ function header(msg: string) { console.log(`\n${c.bold}${c.cyan} ${msg}${c.reset}`); }
82
+
83
+ // ─── Commands ─────────────────────────────────────────────────────────────────
84
+
85
+ const PROVIDER_DISPLAY: Record<string, string> = {
86
+ heavstal: "Heavstal",
87
+ gemini: "Gemini",
88
+ groq: "Groq",
89
+ mistral: "Mistral",
90
+ };
91
+
92
+ async function cmdModels(config: ReturnType<typeof loadConfig>, baseUrl: string) {
93
+ const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
94
+ header("Available models");
95
+ try {
96
+ const models = await kundex.models.list();
97
+ if (!models.length) { dim("No models found."); return; }
98
+ const nameWidth = Math.max(...models.map((m) => m.name.length), 4);
99
+ const idWidth = Math.max(...models.map((m) => m.id.length), 2);
100
+ const providerWidth = Math.max(...models.map((m) => (m.provider?.length ?? 0)), 8);
101
+ console.log(`\n ${c.dim}${"Name".padEnd(nameWidth)} ${"ID".padEnd(idWidth)} ${"Provider".padEnd(providerWidth)} Context${c.reset}`);
102
+ console.log(` ${c.dim}${"─".repeat(nameWidth + idWidth + providerWidth + 18)}${c.reset}`);
103
+
104
+ // Group by provider for cleaner output
105
+ const grouped: Record<string, typeof models> = {};
106
+ const order: string[] = [];
107
+ for (const m of models) {
108
+ const p = m.provider ?? "other";
109
+ if (!grouped[p]) { grouped[p] = []; order.push(p); }
110
+ grouped[p].push(m);
111
+ }
112
+
113
+ for (const provider of order) {
114
+ for (const m of grouped[provider]) {
115
+ const ctx = m.contextWindow >= 1_000_000
116
+ ? `${(m.contextWindow / 1_000_000).toFixed(1)}M`
117
+ : m.contextWindow >= 1000
118
+ ? `${(m.contextWindow / 1000).toFixed(0)}k`
119
+ : String(m.contextWindow);
120
+ const providerLabel = (PROVIDER_DISPLAY[m.provider ?? ""] ?? m.provider ?? "").padEnd(providerWidth);
121
+ console.log(
122
+ ` ${c.bold}${m.name.padEnd(nameWidth)}${c.reset}` +
123
+ ` ${c.dim}${m.id.padEnd(idWidth)}${c.reset}` +
124
+ ` ${c.cyan}${providerLabel}${c.reset}` +
125
+ ` ${c.dim}${ctx}${c.reset}`,
126
+ );
127
+ }
128
+ }
129
+ console.log();
130
+ } catch (e) {
131
+ errMsg(`Failed to list models: ${(e as Error).message}`);
132
+ process.exitCode = 1;
133
+ }
134
+ }
135
+
136
+ async function cmdWorkspaceList(config: ReturnType<typeof loadConfig>, baseUrl: string) {
137
+ const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
138
+ header("Workspaces");
139
+ try {
140
+ const sessions = await kundex.agent.listSessions();
141
+
142
+ if (!sessions.length) {
143
+ dim("No workspaces yet. Create one with: kundex workspace new <directory>");
144
+ return;
145
+ }
146
+
147
+ console.log(`\n ${c.dim}${"ID".padEnd(6)} ${"Name".padEnd(28)} ${"Directory".padEnd(34)} Model${c.reset}`);
148
+ console.log(` ${c.dim}${"─".repeat(90)}${c.reset}`);
149
+ for (const s of sessions) {
150
+ const cwd = (s.cwd ?? "").length > 32 ? `…${s.cwd.slice(-31)}` : (s.cwd ?? "");
151
+ const name = (s.name ?? `Workspace #${s.id}`).slice(0, 26);
152
+ console.log(` ${c.bold}${String(s.id).padEnd(6)}${c.reset} ${name.padEnd(28)} ${c.dim}${cwd.padEnd(34)} ${s.model}${c.reset}`);
153
+ }
154
+ console.log();
155
+ } catch (e) {
156
+ errMsg(`Failed to list workspaces: ${(e as Error).message}`);
157
+ process.exitCode = 1;
158
+ }
159
+ }
160
+
161
+ async function cmdWorkspaceNew(
162
+ config: ReturnType<typeof loadConfig>,
163
+ baseUrl: string,
164
+ args: string[],
165
+ ) {
166
+ const cwd = args[0] ?? process.cwd();
167
+
168
+ // Parse optional flags: --model <model>, --name <name>
169
+ let model = config.defaultModel ?? "openai/gpt-oss-120b";
170
+ let name: string | undefined;
171
+ for (let i = 0; i < args.length; i++) {
172
+ if (args[i] === "--model" && args[i + 1]) { model = args[++i]; }
173
+ else if (args[i] === "--name" && args[i + 1]) { name = args[++i]; }
174
+ }
175
+
176
+ const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
177
+
178
+ try {
179
+ const session = await kundex.agent.createSession({ cwd, model, name });
180
+ ok(`Created workspace #${session.id}`);
181
+ dim(`Name: ${session.name}`);
182
+ dim(`Directory: ${cwd}`);
183
+ dim(`Model: ${session.model}`);
184
+ console.log();
185
+ } catch (e) {
186
+ errMsg(`Failed to create workspace: ${(e as Error).message}`);
187
+ process.exitCode = 1;
188
+ }
189
+ }
190
+
191
+ async function cmdWorkspaceDelete(
192
+ config: ReturnType<typeof loadConfig>,
193
+ baseUrl: string,
194
+ idStr: string,
195
+ ) {
196
+ const id = Number(idStr);
197
+ if (!Number.isFinite(id) || id <= 0) {
198
+ errMsg("Usage: kundex workspace delete <id>");
199
+ process.exitCode = 1;
200
+ return;
201
+ }
202
+
203
+ const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
204
+
205
+ try {
206
+ await kundex.agent.deleteSession(id);
207
+ ok(`Deleted workspace #${id}`);
208
+ } catch (e) {
209
+ errMsg(`Failed to delete workspace: ${(e as Error).message}`);
210
+ process.exitCode = 1;
211
+ }
212
+ }
213
+
214
+ // ── Files ─────────────────────────────────────────────────────────────────────
215
+
216
+ async function cmdFilesList(
217
+ config: ReturnType<typeof loadConfig>,
218
+ baseUrl: string,
219
+ wsIdStr: string,
220
+ ) {
221
+ const wsId = Number(wsIdStr);
222
+ if (!Number.isFinite(wsId) || wsId <= 0) {
223
+ errMsg("Usage: kundex files list <wsId>");
224
+ process.exitCode = 1;
225
+ return;
226
+ }
227
+
228
+ const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
229
+ header(`Files in workspace #${wsId}`);
230
+
231
+ try {
232
+ const { files } = await kundex.files.list(wsId);
233
+ if (!files.length) {
234
+ dim("No files yet.");
235
+ return;
236
+ }
237
+
238
+ console.log(`\n ${c.dim}${"Path".padEnd(44)} ${"Size".padEnd(10)} MIME${c.reset}`);
239
+ console.log(` ${c.dim}${"─".repeat(80)}${c.reset}`);
240
+ for (const f of files) {
241
+ const size = f.size < 1024 ? `${f.size} B`
242
+ : f.size < 1024 * 1024 ? `${(f.size / 1024).toFixed(1)} KB`
243
+ : `${(f.size / 1024 / 1024).toFixed(1)} MB`;
244
+ const displayPath = f.path.length > 42 ? `…${f.path.slice(-41)}` : f.path;
245
+ console.log(` ${displayPath.padEnd(44)} ${size.padEnd(10)} ${c.dim}${f.mimeType}${c.reset}`);
246
+ }
247
+ console.log();
248
+ } catch (e) {
249
+ errMsg(`Failed to list files: ${(e as Error).message}`);
250
+ process.exitCode = 1;
251
+ }
252
+ }
253
+
254
+ async function cmdFilesGet(
255
+ config: ReturnType<typeof loadConfig>,
256
+ baseUrl: string,
257
+ wsIdStr: string,
258
+ remotePath: string,
259
+ ) {
260
+ const wsId = Number(wsIdStr);
261
+ if (!Number.isFinite(wsId) || wsId <= 0 || !remotePath) {
262
+ errMsg("Usage: kundex files get <wsId> <path>");
263
+ process.exitCode = 1;
264
+ return;
265
+ }
266
+
267
+ const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
268
+
269
+ try {
270
+ const file = await kundex.files.get(wsId, remotePath);
271
+ process.stdout.write(file.content ?? "");
272
+ } catch (e) {
273
+ errMsg(`Failed to get file: ${(e as Error).message}`);
274
+ process.exitCode = 1;
275
+ }
276
+ }
277
+
278
+ async function cmdFilesPush(
279
+ config: ReturnType<typeof loadConfig>,
280
+ baseUrl: string,
281
+ wsIdStr: string,
282
+ localPath: string,
283
+ remotePath?: string,
284
+ ) {
285
+ const wsId = Number(wsIdStr);
286
+ if (!Number.isFinite(wsId) || wsId <= 0 || !localPath) {
287
+ errMsg("Usage: kundex files push <wsId> <localPath> [remotePath]");
288
+ process.exitCode = 1;
289
+ return;
290
+ }
291
+
292
+ const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
293
+ const destPath = remotePath ?? path.basename(localPath);
294
+
295
+ try {
296
+ const content = await fs.readFile(localPath, "utf-8");
297
+ const name = path.basename(destPath);
298
+ await kundex.files.upsert(wsId, { path: destPath, name, content });
299
+ ok(`Pushed ${localPath} → workspace #${wsId}:${destPath}`);
300
+ } catch (e) {
301
+ errMsg(`Failed to push file: ${(e as Error).message}`);
302
+ process.exitCode = 1;
303
+ }
304
+ }
305
+
306
+ async function cmdFilesPull(
307
+ config: ReturnType<typeof loadConfig>,
308
+ baseUrl: string,
309
+ wsIdStr: string,
310
+ remotePath: string,
311
+ outPath?: string,
312
+ ) {
313
+ const wsId = Number(wsIdStr);
314
+ if (!Number.isFinite(wsId) || wsId <= 0 || !remotePath) {
315
+ errMsg("Usage: kundex files pull <wsId> <remotePath> [--out <localPath>]");
316
+ process.exitCode = 1;
317
+ return;
318
+ }
319
+
320
+ const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
321
+
322
+ try {
323
+ const file = await kundex.files.get(wsId, remotePath);
324
+ const content = file.content ?? "";
325
+
326
+ if (outPath) {
327
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
328
+ await fs.writeFile(outPath, content, "utf-8");
329
+ ok(`Saved ${remotePath} → ${outPath} (${content.length} bytes)`);
330
+ } else {
331
+ process.stdout.write(content);
332
+ }
333
+ } catch (e) {
334
+ errMsg(`Failed to pull file: ${(e as Error).message}`);
335
+ process.exitCode = 1;
336
+ }
337
+ }
338
+
339
+ // ── Run (single-turn non-interactive) ─────────────────────────────────────────
340
+
341
+ async function cmdRun(
342
+ config: ReturnType<typeof loadConfig>,
343
+ baseUrl: string,
344
+ message: string,
345
+ args: string[],
346
+ ) {
347
+ if (!message) {
348
+ errMsg("Usage: kundex run <message>");
349
+ process.exitCode = 1;
350
+ return;
351
+ }
352
+
353
+ // Parse optional flags: --model, --workspace, --cwd
354
+ let model = config.defaultModel ?? "openai/gpt-oss-120b";
355
+ let wsId: number | null = null;
356
+ let cwd = process.cwd();
357
+ for (let i = 0; i < args.length; i++) {
358
+ if (args[i] === "--model" && args[i + 1]) model = args[++i];
359
+ else if ((args[i] === "--workspace" || args[i] === "-w") && args[i + 1]) wsId = Number(args[++i]);
360
+ else if (args[i] === "--cwd" && args[i + 1]) cwd = args[++i];
361
+ }
362
+
363
+ const kundex = new Kundex({ apiKey: config.apiKey!, baseUrl });
364
+ let sessionId: number;
365
+
366
+ try {
367
+ if (wsId !== null && Number.isFinite(wsId)) {
368
+ sessionId = wsId;
369
+ } else {
370
+ const session = await kundex.agent.createSession({ cwd, model });
371
+ sessionId = session.id;
372
+ }
373
+
374
+ process.stdout.write(`\n`);
375
+ const { executeToolCall } = await import("./tools");
376
+
377
+ let turn = await kundex.agent.streamMessage(
378
+ { sessionId, message },
379
+ (delta) => process.stdout.write(delta),
380
+ );
381
+
382
+ // Handle tool calls in a loop until the turn is done
383
+ while (!turn.done && turn.toolCalls && turn.toolCalls.length > 0) {
384
+ for (const call of turn.toolCalls) {
385
+ const { result, isError } = await executeToolCall(cwd, call);
386
+ turn = await kundex.agent.submitToolResult(
387
+ { sessionId, toolCallId: call.id, result, isError },
388
+ (delta) => process.stdout.write(delta),
389
+ );
390
+ }
391
+ }
392
+
393
+ process.stdout.write("\n");
394
+ } catch (e) {
395
+ errMsg(`Agent error: ${(e as Error).message}`);
396
+ process.exitCode = 1;
397
+ }
398
+ }
399
+
400
+ // ─── Config commands ──────────────────────────────────────────────────────────
401
+
402
+ function cmdConfigShow(config: ReturnType<typeof loadConfig>, baseUrl: string) {
403
+ header("Current configuration");
404
+ console.log();
405
+ const rows = [
406
+ ["API key", config.apiKey ? `${config.apiKey.slice(0, 8)}…` : "(not set)"],
407
+ ["Base URL", baseUrl],
408
+ ["Model", config.defaultModel ?? "(default)"],
409
+ ];
410
+ for (const [k, v] of rows) {
411
+ console.log(` ${c.dim}${k.padEnd(12)}${c.reset} ${c.bold}${v}${c.reset}`);
412
+ }
413
+ console.log();
414
+ }
415
+
416
+ function cmdConfigSet(key: string, value: string) {
417
+ const allowed = ["model", "baseUrl"];
418
+ if (!allowed.includes(key)) {
419
+ errMsg(`Unknown config key "${key}". Allowed keys: ${allowed.join(", ")}`);
420
+ process.exitCode = 1;
421
+ return;
422
+ }
423
+
424
+ const map: Record<string, keyof ReturnType<typeof loadConfig>> = {
425
+ model: "defaultModel",
426
+ baseUrl: "baseUrl",
427
+ };
428
+
429
+ saveConfig({ [map[key]]: value });
430
+ ok(`Set ${key} = ${value}`);
431
+ }
432
+
433
+ function cmdLogout() {
434
+ saveConfig({ apiKey: null, baseUrl: null, defaultModel: null });
435
+ ok("Logged out. Run `kundex login` to authenticate again.");
436
+ }
437
+
438
+ // ─── Main ─────────────────────────────────────────────────────────────────────
4
439
 
5
440
  async function main(): Promise<void> {
6
- const [, , command] = process.argv;
441
+ const args = process.argv.slice(2);
442
+ const [command, sub, ...rest] = args;
7
443
 
8
- if (command === "login") {
9
- await runLogin();
444
+ // Version
445
+ if (command === "version" || command === "--version" || command === "-v") {
446
+ console.log(`kundex v${VERSION}`);
10
447
  return;
11
448
  }
12
449
 
450
+ // Help
13
451
  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
- );
452
+ console.log(HELP);
453
+ return;
454
+ }
455
+
456
+ // No command — drop into REPL if logged in, else show help
457
+ if (!command) {
458
+ const config = loadConfig();
459
+ const baseUrl = resolveBaseUrl(config);
460
+ if (config.apiKey && baseUrl) {
461
+ await runRepl(config, config.apiKey, baseUrl);
462
+ return;
463
+ }
464
+ console.log(HELP);
465
+ console.log(`\n${c.yellow} Run \`kundex login\` to get started.${c.reset}\n`);
466
+ return;
467
+ }
468
+
469
+ // Login / logout
470
+ if (command === "login") {
471
+ await runLogin();
472
+ return;
473
+ }
474
+ if (command === "logout") {
475
+ cmdLogout();
23
476
  return;
24
477
  }
25
478
 
479
+ // Load config — required for all remaining commands
26
480
  const config = loadConfig();
27
481
  const baseUrl = resolveBaseUrl(config);
28
482
 
29
483
  if (!config.apiKey || !baseUrl) {
30
- console.error('Not logged in. Run "kundex login" first.');
484
+ errMsg('Not logged in. Run "kundex login" first.');
31
485
  process.exitCode = 1;
32
486
  return;
33
487
  }
34
488
 
35
- await runRepl(config, config.apiKey, baseUrl);
489
+ // Models
490
+ if (command === "models") {
491
+ await cmdModels(config, baseUrl);
492
+ return;
493
+ }
494
+
495
+ // Config
496
+ if (command === "config") {
497
+ if (sub === "show" || !sub) {
498
+ cmdConfigShow(config, baseUrl);
499
+ } else if (sub === "set") {
500
+ const [key, value] = rest;
501
+ if (!key || !value) {
502
+ errMsg("Usage: kundex config set <key> <value>");
503
+ process.exitCode = 1;
504
+ } else {
505
+ cmdConfigSet(key, value);
506
+ }
507
+ } else {
508
+ errMsg(`Unknown config subcommand "${sub}". Use: show, set`);
509
+ process.exitCode = 1;
510
+ }
511
+ return;
512
+ }
513
+
514
+ // Workspace
515
+ if (command === "workspace") {
516
+ if (sub === "list" || !sub) {
517
+ await cmdWorkspaceList(config, baseUrl);
518
+ } else if (sub === "new") {
519
+ await cmdWorkspaceNew(config, baseUrl, rest);
520
+ } else if (sub === "delete") {
521
+ await cmdWorkspaceDelete(config, baseUrl, rest[0]);
522
+ } else {
523
+ errMsg(`Unknown workspace subcommand "${sub}". Use: list, new, delete`);
524
+ process.exitCode = 1;
525
+ }
526
+ return;
527
+ }
528
+
529
+ // Files
530
+ if (command === "files") {
531
+ if (sub === "list") {
532
+ await cmdFilesList(config, baseUrl, rest[0]);
533
+ } else if (sub === "get") {
534
+ await cmdFilesGet(config, baseUrl, rest[0], rest[1]);
535
+ } else if (sub === "push") {
536
+ await cmdFilesPush(config, baseUrl, rest[0], rest[1], rest[2]);
537
+ } else if (sub === "pull") {
538
+ // Support --out flag
539
+ let outPath: string | undefined;
540
+ const remaining: string[] = [];
541
+ for (let i = 0; i < rest.length; i++) {
542
+ if (rest[i] === "--out" && rest[i + 1]) { outPath = rest[++i]; }
543
+ else remaining.push(rest[i]);
544
+ }
545
+ await cmdFilesPull(config, baseUrl, remaining[0], remaining[1], outPath);
546
+ } else {
547
+ errMsg(`Unknown files subcommand "${sub}". Use: list, get, push, pull`);
548
+ process.exitCode = 1;
549
+ }
550
+ return;
551
+ }
552
+
553
+ // Run (single-turn agent)
554
+ if (command === "run") {
555
+ // "kundex run <message>" — join remaining args as the message
556
+ const allArgs = [sub, ...rest].filter(Boolean);
557
+ // Find message (everything before the first -- flag or flag-like arg)
558
+ const flagStart = allArgs.findIndex((a) => a.startsWith("--"));
559
+ const messageParts = flagStart >= 0 ? allArgs.slice(0, flagStart) : allArgs;
560
+ const flagArgs = flagStart >= 0 ? allArgs.slice(flagStart) : [];
561
+ const message = messageParts.join(" ").trim();
562
+
563
+ await cmdRun(config, baseUrl, message, flagArgs);
564
+ return;
565
+ }
566
+
567
+ // Unknown command — suggest help
568
+ errMsg(`Unknown command "${command}". Run \`kundex help\` to see available commands.`);
569
+ process.exitCode = 1;
36
570
  }
37
571
 
38
- main().catch((err) => {
39
- console.error(err instanceof Error ? err.message : err);
572
+ main().catch((e) => {
573
+ console.error(`${c.red} Fatal: ${e instanceof Error ? e.message : e}${c.reset}`);
40
574
  process.exitCode = 1;
41
575
  });
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
 
@@ -73,16 +73,16 @@ function buildRules(lang: string): LangRule[] {
73
73
 
74
74
  /** Apply regex-based ANSI syntax highlighting to a code string. */
75
75
  function syntaxHighlight(code: string, lang: string): string {
76
- const l = lang.toLowerCase().replace(/^(tsx?|jsx?)$/, lang.includes("ts") || lang.includes("tsx") ? "typescript" : "javascript");
77
- const normalised = ["python","py"].includes(l) ? "python"
78
- : ["sh","shell","zsh"].includes(l) ? "bash"
76
+ const l = lang.toLowerCase();
77
+ const normalised = ["tsx","ts"].includes(l) ? "typescript"
78
+ : ["jsx","js","mjs"].includes(l) ? "javascript"
79
+ : ["python","py"].includes(l) ? "python"
80
+ : ["sh","shell","zsh","bash"].includes(l) ? "bash"
79
81
  : ["rs"].includes(l) ? "rust"
80
82
  : l;
81
83
 
82
84
  const rules = buildRules(normalised in KEYWORDS ? normalised : "javascript");
83
85
 
84
- // We work character-by-character with a simple placeholder approach:
85
- // collect all matches with their positions, sort them, then reconstruct.
86
86
  type Span = { start: number; end: number; color: string };
87
87
  const spans: Span[] = [];
88
88
 
@@ -97,7 +97,6 @@ function syntaxHighlight(code: string, lang: string): string {
97
97
 
98
98
  if (spans.length === 0) return code;
99
99
 
100
- // Sort by start; discard overlapping spans (first wins)
101
100
  spans.sort((a, b) => a.start - b.start);
102
101
  const merged: Span[] = [];
103
102
  let cursor = 0;
@@ -144,7 +143,6 @@ function printReply(text: string) {
144
143
  while ((match = codeBlockRe.exec(text)) !== null) {
145
144
  const before = text.slice(last, match.index);
146
145
  if (before.trim()) {
147
- // Wrap plain text at ~100 chars
148
146
  before.split("\n").forEach(line => {
149
147
  process.stdout.write(` ${line}\n`);
150
148
  });
@@ -166,15 +164,20 @@ function printReply(text: string) {
166
164
  // ─── Helpers ──────────────────────────────────────────────────────────────────
167
165
  const HELP = `
168
166
  ${c.bold}Commands:${c.reset}
169
- /help Show this help
170
- /model Show the model for this session
171
- /clear Start a fresh agent session
172
- /exit Quit kundex
167
+ /help Show this help
168
+ /model Show the model for this session
169
+ /clear Start a fresh agent session
170
+ /workspace list List all your workspaces
171
+ /workspace use <id> Switch to an existing workspace session
172
+ /files List files in the current workspace
173
+ /exit Quit kundex
173
174
 
174
175
  ${c.bold}Tips:${c.reset}
175
176
  • The agent can read, edit and search files, run commands, and use git.
176
177
  • You will be asked to confirm any writes or shell commands before they run.
178
+ • Responses stream in real-time — text appears as the model generates it.
177
179
  • Code blocks are syntax-highlighted in the terminal.
180
+ • Press Ctrl+C to abort a running request.
178
181
  `;
179
182
 
180
183
  function safeGitStatus(cwd: string): string | undefined {
@@ -196,11 +199,35 @@ function safeFileTree(cwd: string): string | undefined {
196
199
  }
197
200
  }
198
201
 
202
+ /**
203
+ * Spinner that writes to stdout with carriage-return to overwrite itself.
204
+ * Call clear() to erase it before writing other output.
205
+ */
206
+ function createSpinner(label: string) {
207
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
208
+ let frame = 0;
209
+ const timer = setInterval(() => {
210
+ process.stdout.write(`\r${c.dim} ${frames[frame++ % frames.length]} ${label}${c.reset} `);
211
+ }, 80);
212
+
213
+ return {
214
+ clear() {
215
+ clearInterval(timer);
216
+ process.stdout.write("\r\x1b[2K"); // CR + erase line
217
+ },
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Handle a completed AgentTurn: process any tool calls, stream tool-result turns.
223
+ * All tool results are submitted using the streaming API so progress is visible.
224
+ */
199
225
  async function handleTurn(
200
226
  kundex: Kundex,
201
227
  cwd: string,
202
228
  sessionId: number,
203
229
  turn: AgentTurn,
230
+ signal?: AbortSignal,
204
231
  ): Promise<void> {
205
232
  let current = turn;
206
233
 
@@ -210,16 +237,39 @@ async function handleTurn(
210
237
  if (isError) {
211
238
  process.stdout.write(`${c.red} ✗ ${call.name}: ${result}${c.reset}\n`);
212
239
  }
213
- current = await kundex.agent.submitToolResult({
214
- sessionId,
215
- toolCallId: call.id,
216
- result,
217
- isError,
218
- });
240
+
241
+ // Show a spinner while waiting for the follow-up AI turn
242
+ const sp = createSpinner("processing tool result…");
243
+ let followUpText = "";
244
+
245
+ try {
246
+ current = await kundex.agent.submitToolResult(
247
+ { sessionId, toolCallId: call.id, result, isError },
248
+ (delta) => {
249
+ if (!followUpText) {
250
+ // First token arrived — clear the spinner
251
+ sp.clear();
252
+ process.stdout.write("\n");
253
+ }
254
+ followUpText += delta;
255
+ process.stdout.write(delta);
256
+ },
257
+ signal,
258
+ );
259
+ } finally {
260
+ sp.clear();
261
+ }
262
+
263
+ if (followUpText && !followUpText.endsWith("\n")) {
264
+ process.stdout.write("\n");
265
+ }
219
266
  }
220
267
  }
221
268
 
222
- if (current.reply) {
269
+ if (current.reply && !turn.toolCalls?.length) {
270
+ // Reply was already streamed inline; just print the closing rule
271
+ process.stdout.write(`\n${c.dim}${"─".repeat(60)}${c.reset}\n\n`);
272
+ } else if (current.reply) {
223
273
  printReply(current.reply);
224
274
  }
225
275
  }
@@ -238,7 +288,7 @@ ${c.bold}${c.cyan} ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═
238
288
  ${c.dim}cwd:${c.reset} ${c.bold}${shortCwd}${c.reset}
239
289
  ${c.dim}model:${c.reset} ${c.bold}${model}${c.reset}
240
290
 
241
- Type ${c.cyan}/help${c.reset} for commands.
291
+ Type ${c.cyan}/help${c.reset} for commands, ${c.cyan}Ctrl+C${c.reset} to stop a running request.
242
292
  `);
243
293
  }
244
294
 
@@ -253,13 +303,33 @@ export async function runRepl(config: KundexConfig, apiKey: string, baseUrl: str
253
303
  let session = await kundex.agent.createSession({ cwd, model });
254
304
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
255
305
 
306
+ // AbortController for the current in-progress request.
307
+ // Ctrl+C signals the controller and sends a SIGINT to abort the fetch.
308
+ let currentAbort: AbortController | null = null;
309
+
310
+ const sigintHandler = () => {
311
+ if (currentAbort && !currentAbort.signal.aborted) {
312
+ currentAbort.abort();
313
+ process.stdout.write(`\n${c.yellow} ⚡ Stopped.${c.reset}\n\n`);
314
+ currentAbort = null;
315
+ } else {
316
+ // Second Ctrl+C with no request in flight → exit
317
+ console.log(`\n${c.dim}Goodbye!${c.reset}\n`);
318
+ rl.close();
319
+ process.exit(0);
320
+ }
321
+ };
322
+
323
+ process.on("SIGINT", sigintHandler);
324
+
256
325
  try {
257
326
  for (;;) {
258
327
  const raw = await rl.question(`${c.bold}${c.cyan}kundex${c.reset} ${c.dim}▸${c.reset} `);
259
328
  const input = raw.trim();
260
329
  if (!input) continue;
261
330
 
262
- if (input === "/exit") {
331
+ // ── REPL commands ───────────────────────────────────────────────────────
332
+ if (input === "/exit" || input === "/quit") {
263
333
  console.log(`\n${c.dim}Goodbye!${c.reset}\n`);
264
334
  break;
265
335
  }
@@ -274,23 +344,123 @@ export async function runRepl(config: KundexConfig, apiKey: string, baseUrl: str
274
344
  continue;
275
345
  }
276
346
 
347
+ // /workspace list
348
+ if (input === "/workspace list") {
349
+ try {
350
+ const sessions = await kundex.agent.listSessions();
351
+ if (!sessions.length) {
352
+ console.log(`${c.dim} No workspaces yet.${c.reset}`);
353
+ } else {
354
+ console.log(`\n ${c.dim}${"ID".padEnd(6)} ${"Name".padEnd(26)} CWD${c.reset}`);
355
+ console.log(` ${c.dim}${"─".repeat(70)}${c.reset}`);
356
+ for (const s of sessions) {
357
+ const isActive = s.id === session.id ? ` ${c.cyan}← active${c.reset}` : "";
358
+ const name = (s.name ?? `Workspace #${s.id}`).slice(0, 24);
359
+ const cwd2 = (s.cwd ?? "").length > 30 ? `…${s.cwd.slice(-29)}` : s.cwd;
360
+ console.log(` ${c.bold}${String(s.id).padEnd(6)}${c.reset} ${name.padEnd(26)} ${c.dim}${cwd2}${c.reset}${isActive}`);
361
+ }
362
+ console.log();
363
+ }
364
+ } catch (err) {
365
+ console.error(`${c.red} error: ${(err as Error).message}${c.reset}`);
366
+ }
367
+ continue;
368
+ }
369
+
370
+ // /workspace use <id>
371
+ if (input.startsWith("/workspace use ")) {
372
+ const id = Number(input.slice("/workspace use ".length).trim());
373
+ if (!Number.isFinite(id) || id <= 0) {
374
+ console.error(`${c.red} Invalid workspace ID.${c.reset}`);
375
+ continue;
376
+ }
377
+ try {
378
+ const s = await kundex.agent.getSession(id) as AgentSession;
379
+ session = s as any;
380
+ console.log(`\n${c.green} ✓ Switched to workspace #${id} — ${s.name}${c.reset}\n`);
381
+ } catch (err) {
382
+ console.error(`${c.red} error: ${(err as Error).message}${c.reset}`);
383
+ }
384
+ continue;
385
+ }
386
+
387
+ // /files
388
+ if (input === "/files") {
389
+ try {
390
+ const { files } = await kundex.files.list(session.id);
391
+ if (!files.length) {
392
+ console.log(`${c.dim} No files in this workspace yet.${c.reset}`);
393
+ } else {
394
+ console.log(`\n ${c.dim}${"Path".padEnd(40)} Size${c.reset}`);
395
+ console.log(` ${c.dim}${"─".repeat(52)}${c.reset}`);
396
+ for (const f of files) {
397
+ const size = f.size < 1024 ? `${f.size} B`
398
+ : f.size < 1024 * 1024 ? `${(f.size / 1024).toFixed(1)} KB`
399
+ : `${(f.size / 1024 / 1024).toFixed(1)} MB`;
400
+ console.log(` ${f.path.padEnd(40)} ${c.dim}${size}${c.reset}`);
401
+ }
402
+ console.log();
403
+ }
404
+ } catch (err) {
405
+ console.error(`${c.red} error: ${(err as Error).message}${c.reset}`);
406
+ }
407
+ continue;
408
+ }
409
+
410
+ // ── Agent message ────────────────────────────────────────────────────────
411
+ currentAbort = new AbortController();
412
+ const { signal } = currentAbort;
413
+ const spinner = createSpinner("thinking…");
414
+ let streamedAnyText = false;
415
+
277
416
  try {
278
- process.stdout.write(`\n${c.dim} thinking...${c.reset}\n`);
279
- const turn = await kundex.agent.sendMessage({
280
- sessionId: session.id,
281
- message: input,
282
- context: {
283
- gitStatus: safeGitStatus(cwd),
284
- fileTree: safeFileTree(cwd),
285
- openFiles: [],
417
+ const turn = await kundex.agent.streamMessage(
418
+ {
419
+ sessionId: session.id,
420
+ message: input,
421
+ context: {
422
+ gitStatus: safeGitStatus(cwd),
423
+ fileTree: safeFileTree(cwd),
424
+ openFiles: [],
425
+ },
426
+ },
427
+ (delta) => {
428
+ if (!streamedAnyText) {
429
+ spinner.clear();
430
+ process.stdout.write("\n");
431
+ streamedAnyText = true;
432
+ }
433
+ process.stdout.write(delta);
286
434
  },
287
- });
288
- await handleTurn(kundex, cwd, session.id, turn);
435
+ signal,
436
+ );
437
+
438
+ spinner.clear();
439
+
440
+ // If the response contained only tool calls (no streamed text), let
441
+ // handleTurn print the tool output and follow-up replies.
442
+ if (turn.toolCalls?.length) {
443
+ await handleTurn(kundex, cwd, session.id, turn, signal);
444
+ } else if (streamedAnyText) {
445
+ // Text was streamed inline; just print the closing separator.
446
+ process.stdout.write(`\n${c.dim}${"─".repeat(60)}${c.reset}\n\n`);
447
+ } else if (turn.reply) {
448
+ printReply(turn.reply);
449
+ }
289
450
  } catch (err) {
290
- console.error(`\n${c.red} error: ${(err as Error).message}${c.reset}\n`);
451
+ spinner.clear();
452
+ const e = err as Error;
453
+ if (e.name === "AbortError") {
454
+ // Already printed "⚡ Stopped" via sigintHandler
455
+ } else {
456
+ console.error(`\n${c.red} error: ${e.message}${c.reset}\n`);
457
+ }
458
+ } finally {
459
+ currentAbort = null;
291
460
  }
292
461
  }
293
462
  } finally {
463
+ process.off("SIGINT", sigintHandler);
294
464
  rl.close();
295
465
  }
296
466
  }
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
- private baseUrl: string;
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
- for (;;) {
134
- const { done, value } = await reader.read();
135
- if (done) break;
136
- buffer += decoder.decode(value, { stream: true });
137
- const lines = buffer.split("\n");
138
- buffer = lines.pop() ?? "";
139
-
140
- for (const line of lines) {
141
- const trimmed = line.trim();
142
- if (!trimmed.startsWith("data:")) continue;
143
- const payload = trimmed.slice(5).trim();
144
- if (!payload) continue;
145
- const json = JSON.parse(payload);
146
- if (json.delta) onDelta(json.delta);
147
- if (json.done) {
148
- finishReason = json.finishReason ?? finishReason;
149
- usage = json.usage ?? usage;
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: { cwd: string; model?: string }) =>
160
- this.request<{ id: number; cwd: string; model: string; createdAt: string }>(
161
- "/v1/agent/session",
162
- params,
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
- sendMessage: (params: {
166
- sessionId: number;
167
- message: string;
168
- context?: { fileTree?: string; gitStatus?: string; openFiles?: string[] };
169
- }) => this.request<AgentTurn>("/v1/agent/message", params),
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
  }