@ayoxx/kundex 0.1.7 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/repl.ts CHANGED
@@ -1,16 +1,184 @@
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
 
8
- const HELP = `Commands:
9
- /help Show this help
10
- /model Show the model this session is using
11
- /clear Start a new agent session
12
- /exit Quit
13
- Anything else is sent to the agent as a message.`;
8
+ // ─── ANSI colour helpers ──────────────────────────────────────────────────────
9
+ const c = {
10
+ reset: "\x1b[0m",
11
+ bold: "\x1b[1m",
12
+ dim: "\x1b[2m",
13
+ italic: "\x1b[3m",
14
+ cyan: "\x1b[36m",
15
+ green: "\x1b[32m",
16
+ yellow: "\x1b[33m",
17
+ red: "\x1b[31m",
18
+ blue: "\x1b[34m",
19
+ magenta: "\x1b[35m",
20
+ white: "\x1b[37m",
21
+ // Syntax-highlight colours (256-colour palette)
22
+ kw: "\x1b[38;5;204m", // keywords – salmon/red
23
+ str: "\x1b[38;5;150m", // strings – light green
24
+ num: "\x1b[38;5;141m", // numbers – light purple
25
+ comment: "\x1b[38;5;244m", // comments – grey
26
+ fn: "\x1b[38;5;117m", // functions – light blue
27
+ type_: "\x1b[38;5;215m", // types/decorators – orange
28
+ punct: "\x1b[38;5;250m", // punctuation – light grey
29
+ };
30
+
31
+ // ─── Per-language syntax highlighter (ANSI, no deps) ─────────────────────────
32
+ interface LangRule {
33
+ pattern: RegExp;
34
+ color: string;
35
+ }
36
+
37
+ const KEYWORDS: Record<string, string[]> = {
38
+ python: ["def","class","return","import","from","if","elif","else","for","while",
39
+ "try","except","with","as","in","not","and","or","is","None","True","False","pass","raise","yield","async","await","lambda"],
40
+ javascript: ["const","let","var","function","return","if","else","for","while",
41
+ "class","extends","import","export","from","default","new","this",
42
+ "try","catch","finally","throw","async","await","typeof","instanceof","of","in","null","undefined","true","false"],
43
+ typescript: ["const","let","var","function","return","if","else","for","while",
44
+ "class","extends","implements","interface","type","import","export",
45
+ "from","default","new","this","try","catch","finally","throw","async",
46
+ "await","typeof","instanceof","of","in","null","undefined","true","false",
47
+ "string","number","boolean","any","void","never","unknown","enum","as","namespace"],
48
+ bash: ["if","then","else","elif","fi","for","while","do","done","case","esac",
49
+ "function","return","echo","exit","local","export","source","cd","ls","grep","awk","sed"],
50
+ go: ["func","package","import","var","const","type","struct","interface","return",
51
+ "if","else","for","range","switch","case","default","go","chan","defer","select","nil","true","false","map","make","new"],
52
+ rust: ["fn","let","mut","pub","use","mod","struct","enum","impl","trait","return",
53
+ "if","else","for","while","loop","match","Some","None","Ok","Err","true","false","self","Self","super","as","in","where","async","await"],
54
+ };
55
+
56
+ function buildRules(lang: string): LangRule[] {
57
+ const kws = KEYWORDS[lang] || KEYWORDS["javascript"];
58
+ return [
59
+ // Comments
60
+ { pattern: /(#.*$|\/\/.*$|\/\*[\s\S]*?\*\/|"""[\s\S]*?"""|'''[\s\S]*?''')/gm, color: c.comment },
61
+ // Strings
62
+ { pattern: /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g, color: c.str },
63
+ // Numbers
64
+ { pattern: /\b(\d+\.?\d*(?:e[+-]?\d+)?)\b/g, color: c.num },
65
+ // Keywords (built from per-language list)
66
+ { pattern: new RegExp(`\\b(${kws.join("|")})\\b`, "g"), color: c.kw },
67
+ // Function calls
68
+ { pattern: /\b([a-zA-Z_]\w*)\s*(?=\()/g, color: c.fn },
69
+ // Type annotations (CamelCase identifiers)
70
+ { pattern: /\b([A-Z][a-zA-Z0-9_]*)\b/g, color: c.type_ },
71
+ ];
72
+ }
73
+
74
+ /** Apply regex-based ANSI syntax highlighting to a code string. */
75
+ function syntaxHighlight(code: string, lang: string): string {
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"
81
+ : ["rs"].includes(l) ? "rust"
82
+ : l;
83
+
84
+ const rules = buildRules(normalised in KEYWORDS ? normalised : "javascript");
85
+
86
+ type Span = { start: number; end: number; color: string };
87
+ const spans: Span[] = [];
88
+
89
+ for (const rule of rules) {
90
+ rule.pattern.lastIndex = 0;
91
+ let m: RegExpExecArray | null;
92
+ while ((m = rule.pattern.exec(code)) !== null) {
93
+ spans.push({ start: m.index, end: m.index + m[0].length, color: rule.color });
94
+ if (rule.pattern.lastIndex === m.index) rule.pattern.lastIndex++;
95
+ }
96
+ }
97
+
98
+ if (spans.length === 0) return code;
99
+
100
+ spans.sort((a, b) => a.start - b.start);
101
+ const merged: Span[] = [];
102
+ let cursor = 0;
103
+ for (const span of spans) {
104
+ if (span.start < cursor) continue;
105
+ merged.push(span);
106
+ cursor = span.end;
107
+ }
108
+
109
+ let out = "";
110
+ cursor = 0;
111
+ for (const span of merged) {
112
+ out += code.slice(cursor, span.start);
113
+ out += span.color + code.slice(span.start, span.end) + c.reset;
114
+ cursor = span.end;
115
+ }
116
+ out += code.slice(cursor);
117
+ return out;
118
+ }
119
+
120
+ // ─── Code-block printer ───────────────────────────────────────────────────────
121
+ function printCodeBlock(lang: string, code: string) {
122
+ const border = "─".repeat(52);
123
+ const label = lang || "code";
124
+ const labelPad = "─".repeat(Math.max(0, 52 - label.length - 3));
125
+
126
+ process.stdout.write(`${c.dim}┌─ ${c.reset}${c.bold}${label}${c.reset}${c.dim} ${labelPad}${c.reset}\n`);
127
+
128
+ const highlighted = syntaxHighlight(code, lang);
129
+ highlighted.split("\n").forEach(line => {
130
+ process.stdout.write(`${c.dim}│${c.reset} ${line}\n`);
131
+ });
132
+
133
+ process.stdout.write(`${c.dim}└${border}${c.reset}\n`);
134
+ }
135
+
136
+ // ─── Reply printer (handles mixed text + code blocks) ────────────────────────
137
+ function printReply(text: string) {
138
+ process.stdout.write("\n");
139
+ const codeBlockRe = /```(\w*)\n?([\s\S]*?)```/g;
140
+ let last = 0;
141
+ let match: RegExpExecArray | null;
142
+
143
+ while ((match = codeBlockRe.exec(text)) !== null) {
144
+ const before = text.slice(last, match.index);
145
+ if (before.trim()) {
146
+ before.split("\n").forEach(line => {
147
+ process.stdout.write(` ${line}\n`);
148
+ });
149
+ }
150
+ printCodeBlock(match[1], match[2].trimEnd());
151
+ last = match.index + match[0].length;
152
+ }
153
+
154
+ const tail = text.slice(last);
155
+ if (tail.trim()) {
156
+ tail.split("\n").forEach(line => {
157
+ process.stdout.write(` ${line}\n`);
158
+ });
159
+ }
160
+
161
+ process.stdout.write(`\n${c.dim}${"─".repeat(60)}${c.reset}\n\n`);
162
+ }
163
+
164
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
165
+ const HELP = `
166
+ ${c.bold}Commands:${c.reset}
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
174
+
175
+ ${c.bold}Tips:${c.reset}
176
+ • The agent can read, edit and search files, run commands, and use git.
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.
179
+ • Code blocks are syntax-highlighted in the terminal.
180
+ • Press Ctrl+C to abort a running request.
181
+ `;
14
182
 
15
183
  function safeGitStatus(cwd: string): string | undefined {
16
184
  try {
@@ -20,78 +188,279 @@ function safeGitStatus(cwd: string): string | undefined {
20
188
  }
21
189
  }
22
190
 
191
+ function safeFileTree(cwd: string): string | undefined {
192
+ try {
193
+ return execSync(
194
+ `find . -maxdepth 3 ! -path '*/node_modules/*' ! -path '*/.git/*' ! -path '*/dist/*' ! -path '*/build/*' -print`,
195
+ { cwd, timeout: 5000 }
196
+ ).toString().trim().split("\n").slice(0, 200).join("\n");
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ }
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
+ */
23
225
  async function handleTurn(
24
226
  kundex: Kundex,
25
227
  cwd: string,
26
228
  sessionId: number,
27
229
  turn: AgentTurn,
230
+ signal?: AbortSignal,
28
231
  ): Promise<void> {
29
232
  let current = turn;
30
233
 
31
234
  while (!current.done && current.toolCalls && current.toolCalls.length > 0) {
32
235
  for (const call of current.toolCalls) {
33
- console.log(`\n\u2192 ${call.name}(${call.arguments})`);
34
236
  const { result, isError } = await executeToolCall(cwd, call);
35
- console.log(isError ? ` error: ${result}` : ` ${result.slice(0, 2000)}`);
237
+ if (isError) {
238
+ process.stdout.write(`${c.red} ✗ ${call.name}: ${result}${c.reset}\n`);
239
+ }
36
240
 
37
- current = await kundex.agent.submitToolResult({
38
- sessionId,
39
- toolCallId: call.id,
40
- result,
41
- isError,
42
- });
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
+ }
43
266
  }
44
267
  }
45
268
 
46
- if (current.reply) {
47
- console.log(`\nkundex: ${current.reply}\n`);
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) {
273
+ printReply(current.reply);
48
274
  }
49
275
  }
50
276
 
277
+ function printBanner(cwd: string, model: string) {
278
+ const shortCwd = cwd.replace(process.env.HOME ?? "", "~");
279
+ console.log(`
280
+ ${c.bold}${c.cyan} ██╗ ██╗██╗ ██╗███╗ ██╗██████╗ ███████╗██╗ ██╗${c.reset}
281
+ ${c.bold}${c.cyan} ██║ ██╔╝██║ ██║████╗ ██║██╔══██╗██╔════╝╚██╗██╔╝${c.reset}
282
+ ${c.bold}${c.cyan} █████╔╝ ██║ ██║██╔██╗ ██║██║ ██║█████╗ ╚███╔╝ ${c.reset}
283
+ ${c.bold}${c.cyan} ██╔═██╗ ██║ ██║██║╚██╗██║██║ ██║██╔══╝ ██╔██╗ ${c.reset}
284
+ ${c.bold}${c.cyan} ██║ ██╗╚██████╔╝██║ ╚████║██████╔╝███████╗██╔╝ ██╗${c.reset}
285
+ ${c.bold}${c.cyan} ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚══════╝╚═╝ ╚═╝${c.reset}
286
+
287
+ ${c.dim}AI coding agent — terminal edition${c.reset}
288
+ ${c.dim}cwd:${c.reset} ${c.bold}${shortCwd}${c.reset}
289
+ ${c.dim}model:${c.reset} ${c.bold}${model}${c.reset}
290
+
291
+ Type ${c.cyan}/help${c.reset} for commands, ${c.cyan}Ctrl+C${c.reset} to stop a running request.
292
+ `);
293
+ }
294
+
295
+ // ─── REPL ─────────────────────────────────────────────────────────────────────
51
296
  export async function runRepl(config: KundexConfig, apiKey: string, baseUrl: string): Promise<void> {
52
297
  const cwd = process.cwd();
53
298
  const kundex = new Kundex({ apiKey, baseUrl });
54
299
  const model = config.defaultModel ?? "openai/gpt-oss-120b";
55
300
 
56
- console.log(`Kundex agent — cwd: ${cwd}, model: ${model}`);
57
- console.log("Type /help for commands.\n");
301
+ printBanner(cwd, model);
58
302
 
59
303
  let session = await kundex.agent.createSession({ cwd, model });
60
-
61
304
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
62
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
+
63
325
  try {
64
326
  for (;;) {
65
- const input = (await rl.question("> ")).trim();
327
+ const raw = await rl.question(`${c.bold}${c.cyan}kundex${c.reset} ${c.dim}▸${c.reset} `);
328
+ const input = raw.trim();
66
329
  if (!input) continue;
67
330
 
68
- if (input === "/exit") break;
69
- if (input === "/help") {
70
- console.log(HELP);
71
- continue;
331
+ // ── REPL commands ───────────────────────────────────────────────────────
332
+ if (input === "/exit" || input === "/quit") {
333
+ console.log(`\n${c.dim}Goodbye!${c.reset}\n`);
334
+ break;
72
335
  }
336
+ if (input === "/help") { console.log(HELP); continue; }
73
337
  if (input === "/model") {
74
- console.log(`Model: ${session.model}`);
338
+ console.log(` ${c.dim}model:${c.reset} ${c.bold}${session.model}${c.reset}`);
75
339
  continue;
76
340
  }
77
341
  if (input === "/clear") {
78
342
  session = await kundex.agent.createSession({ cwd, model });
79
- console.log("Started a new session.");
343
+ console.log(`\n${c.green} ✓ Started a new session.${c.reset}\n`);
80
344
  continue;
81
345
  }
82
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
+
83
416
  try {
84
- const turn = await kundex.agent.sendMessage({
85
- sessionId: session.id,
86
- message: input,
87
- context: { gitStatus: safeGitStatus(cwd), openFiles: [] },
88
- });
89
- await handleTurn(kundex, cwd, session.id, turn);
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);
434
+ },
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
+ }
90
450
  } catch (err) {
91
- console.error(`Error: ${(err as Error).message}`);
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;
92
460
  }
93
461
  }
94
462
  } finally {
463
+ process.off("SIGINT", sigintHandler);
95
464
  rl.close();
96
465
  }
97
466
  }