@gilangranggaid/kilua-agent 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,9 @@ Autonomous AI Coding Agent di terminal & VS Code kamu (seperti Claude Code).
7
7
  - ✏️ Mengedit dan membuat file kode secara otomatis.
8
8
  - ⚡ Mengeksekusi perintah shell lokal (`git`, `npm`, script).
9
9
  - 🧠 Mendukung TokenHarbor, 9router, Gemini, dan OpenAI-compatible provider.
10
+ - 📚 **1.498 skill bawaan** langsung aktif setelah install — dipilih otomatis sesuai tugas (maks 5 per pesan biar hemat token).
11
+ - 🎯 Status real-time (Thinking..., Running step N/40...), hitungan token per turn, dan warna hijau/merah untuk file yang ditulis/dihapus.
12
+ - 🔁 Tidak berhenti sendiri: otomatis reconnect saat koneksi putus; berhenti + memberi tahu hanya kalau token/kuota habis.
10
13
 
11
14
  ## Cara Menggunakan
12
15
 
package/bin/kilua.js CHANGED
@@ -4,13 +4,80 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import os from "node:os";
6
6
  import { execSync } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
7
8
 
8
9
  // ponytail: autonomous single-file AI Coding Agent CLI with slash commands, local skills & test execution.
9
10
  const KILUA_DIR = path.join(os.homedir(), ".kilua");
10
11
  const SETTINGS_PATH = path.join(KILUA_DIR, "settings.json");
11
12
  const SKILLS_DIR = path.join(KILUA_DIR, "skills");
12
13
  const MAX_STEPS = 40; // ponytail: naik dari 16; agent butuh banyak langkah untuk self-fix loop
13
- const MAX_RETRIES = 4;
14
+ const MAX_INJECTED_SKILLS = 5; // sama seperti web: cukup 5 skill per pesan biar konteks hemat
15
+
16
+ // ponytail: match pakai substring ternormalisasi ("docker-compose" == "docker compose").
17
+ // Regex mentah dari web terlalu kaku: trigger "docker-compose" tak pernah kena "docker compose".
18
+ const STOPWORDS = new Set(("yang dan untuk dengan pada dari ini itu saya aku kamu anda bikin buat buatkan "
19
+ + "tolong cara gimana bagaimana kenapa apa apakah mana di ke the a an of for to in on "
20
+ + "with and or is are how what why make create build help please my our your it this that "
21
+ + "setup set up install pasang pakai pake integrasi terus trus juga bisa gak nggak udah sudah "
22
+ + "dong sih nih deh ya aja sama kalo kalau biar masih mau pengen pingin file kode code project").split(/\s+/));
23
+
24
+ function normText(s) {
25
+ return String(s).toLowerCase().replace(/[-_/]+/g, " ").replace(/[^a-z0-9.+#\s]/g, " ")
26
+ .replace(/\s+/g, " ").trim();
27
+ }
28
+
29
+ function tokens(s) {
30
+ return normText(s).split(" ").filter(w => w.length >= 3 && !STOPWORDS.has(w));
31
+ }
32
+
33
+ // Katalog skill bawaan (1.498, port dari web). Dibaca sekali lalu di-cache.
34
+ let BUILTIN_SKILLS = null;
35
+ function loadBuiltinSkills() {
36
+ if (BUILTIN_SKILLS) return BUILTIN_SKILLS;
37
+ try {
38
+ const here = path.dirname(fileURLToPath(import.meta.url));
39
+ // ponytail: repo root bin/ pakai file yang sama, jangan duplikat 300KB
40
+ const candidates = [
41
+ path.join(here, "skills.json"),
42
+ path.join(here, "..", "packages", "kilua-agent", "bin", "skills.json"),
43
+ ];
44
+ const file = candidates.find(p => fs.existsSync(p));
45
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
46
+ const skills = raw.map(([id, triggerStr, domain, focus]) => ({
47
+ name: id, domain, focus,
48
+ keys: [...new Set(String(triggerStr).split("|").flatMap(k => tokens(k)))],
49
+ }));
50
+ // IDF: token langka (mis. "stripe") lebih menentukan daripada token umum (mis. "api").
51
+ const df = new Map();
52
+ for (const s of skills) for (const k of s.keys) df.set(k, (df.get(k) || 0) + 1);
53
+ for (const s of skills) {
54
+ s.score = new Map();
55
+ for (const k of s.keys) s.score.set(k, Math.log(skills.length / (1 + (df.get(k) || 0))));
56
+ }
57
+ BUILTIN_SKILLS = skills;
58
+ } catch { BUILTIN_SKILLS = []; }
59
+ return BUILTIN_SKILLS;
60
+ }
61
+
62
+ // Relevansi skill untuk satu pesan. Ambang relatif 0.55 membuang hasil nyasar.
63
+ function selectBuiltinSkills(userPrompt, limit = MAX_INJECTED_SKILLS) {
64
+ const skills = loadBuiltinSkills();
65
+ if (!skills.length || !userPrompt) return [];
66
+ const wanted = new Set(tokens(userPrompt));
67
+ if (!wanted.size) return [];
68
+ const hay = normText(userPrompt);
69
+ const scored = [];
70
+ for (const s of skills) {
71
+ let score = 0;
72
+ for (const k of s.keys) if (wanted.has(k)) score += s.score.get(k);
73
+ if (score > 0 && hay.includes(normText(s.name))) score += 2;
74
+ if (score > 0.4) scored.push({ s, score });
75
+ }
76
+ if (!scored.length) return [];
77
+ scored.sort((a, b) => b.score - a.score);
78
+ const top = scored[0].score;
79
+ return scored.filter(x => x.score >= top * 0.55).slice(0, limit).map(x => x.s);
80
+ }
14
81
 
15
82
  function ensureKiluaDir() {
16
83
  if (!fs.existsSync(KILUA_DIR)) fs.mkdirSync(KILUA_DIR, { recursive: true });
@@ -47,12 +114,20 @@ function listInstalledSkills() {
47
114
  return skills;
48
115
  }
49
116
 
50
- function getSkillsPrompt() {
117
+ function getSkillsPrompt(userPrompt) {
51
118
  const list = listInstalledSkills();
52
- if (!list.length) return "";
53
- let prompt = "\n\n=== INSTALLED SKILLS ===\n";
54
- for (const s of list) {
55
- prompt += `- ${s.name}: ${s.desc}\n`;
119
+ let prompt = "";
120
+ if (list.length) {
121
+ prompt += "\n\n=== INSTALLED SKILLS ===\n";
122
+ for (const s of list) prompt += `- ${s.name}: ${s.desc}\n`;
123
+ }
124
+
125
+ // ponytail: 1.498 skill bawaan — suntik maks 5 yang paling cocok per pesan.
126
+ const hits = selectBuiltinSkills(userPrompt);
127
+ if (hits.length) {
128
+ prompt += `\n\n=== RELEVANT SKILLS (built-in, ${BUILTIN_SKILLS.length} total) ===\n`;
129
+ for (const s of hits) prompt += `- ${s.name} (${s.domain}): ${s.focus}\n`;
130
+ prompt += "Pakai panduan di atas kalau relevan dengan tugas ini.\n";
56
131
  }
57
132
  return prompt;
58
133
  }
@@ -106,6 +181,14 @@ const TOOLS_OPENAI = [
106
181
  }
107
182
  }
108
183
  },
184
+ {
185
+ type: "function",
186
+ function: {
187
+ name: "delete_file",
188
+ description: "Menghapus file lokal.",
189
+ parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] }
190
+ }
191
+ },
109
192
  {
110
193
  type: "function",
111
194
  function: {
@@ -131,9 +214,10 @@ function executeLocalTool(name, args) {
131
214
  }
132
215
  if (name === "write_file") {
133
216
  const full = path.resolve(cwd, args.path);
217
+ const existed = fs.existsSync(full);
134
218
  fs.mkdirSync(path.dirname(full), { recursive: true });
135
219
  fs.writeFileSync(full, args.content, "utf8");
136
- return `✓ Written: ${args.path}`;
220
+ return `\x1b[32m● Added\x1b[0m ${args.path}${existed ? " (overwritten)" : ` (${args.content.split("\n").length} lines)`}`;
137
221
  }
138
222
  if (name === "edit_file") {
139
223
  const full = path.resolve(cwd, args.path);
@@ -141,7 +225,15 @@ function executeLocalTool(name, args) {
141
225
  const src = fs.readFileSync(full, "utf8");
142
226
  if (!src.includes(args.old_string)) return `Error: old_string not matched in ${args.path}.`;
143
227
  fs.writeFileSync(full, src.replace(args.old_string, args.new_string), "utf8");
144
- return `✓ Edited: ${args.path}`;
228
+ const added = args.new_string.split("\n").length;
229
+ const removed = args.old_string.split("\n").length;
230
+ return `\x1b[32m+${added}\x1b[0m \x1b[31m-${removed}\x1b[0m ${args.path}`;
231
+ }
232
+ if (name === "delete_file") {
233
+ const full = path.resolve(cwd, args.path);
234
+ if (!fs.existsSync(full)) return `Error: ${args.path} not found.`;
235
+ fs.rmSync(full, { recursive: true, force: true });
236
+ return `\x1b[31m✖ Deleted\x1b[0m ${args.path}`;
145
237
  }
146
238
  if (name === "list_dir") {
147
239
  const target = path.resolve(cwd, args.path || ".");
@@ -181,6 +273,67 @@ function promptQuestion(rl, query) {
181
273
  return new Promise(res => rl.question(query, res));
182
274
  }
183
275
 
276
+ // ponytail: status spinner minimal tanpa dependency — tampilkan langkah + detik berjalan
277
+ let spinnerTimer = null;
278
+ let spinnerStart = 0;
279
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
280
+
281
+ function startStatus(label) {
282
+ stopStatus();
283
+ spinnerStart = Date.now();
284
+ let i = 0;
285
+ const paint = () => {
286
+ const secs = Math.floor((Date.now() - spinnerStart) / 1000);
287
+ const frame = SPINNER_FRAMES[i++ % SPINNER_FRAMES.length];
288
+ process.stdout.write(`\r\x1b[2K\x1b[35m${frame} KILUA\x1b[0m \x1b[90m${label}\x1b[0m \x1b[90m(${secs}s)\x1b[0m`);
289
+ };
290
+ paint();
291
+ spinnerTimer = setInterval(paint, 120);
292
+ }
293
+
294
+ function stopStatus() {
295
+ if (spinnerTimer) {
296
+ clearInterval(spinnerTimer);
297
+ spinnerTimer = null;
298
+ process.stdout.write("\r\x1b[2K");
299
+ }
300
+ }
301
+
302
+ function stepLabel(step, max) {
303
+ return step === 0 ? "Thinking…" : `Running step ${step}/${max}…`;
304
+ }
305
+
306
+ // ponytail: file ops tampil berwarna (hijau tambah, merah hapus), lainnya ringkas abu-abu
307
+ const FILE_OPS = new Set(["write_file", "edit_file", "delete_file"]);
308
+
309
+ function printToolCall(name, args, output) {
310
+ if (FILE_OPS.has(name)) {
311
+ // output sudah berisi ANSI sendiri, jangan dibungkus abu-abu
312
+ console.log(` ${output}`);
313
+ return;
314
+ }
315
+ const brief = String(output).slice(0, 160).replace(/\n/g, " ");
316
+ console.log(`\x1b[36m⚡ ${name}\x1b[0m \x1b[90m${brief}${String(output).length > 160 ? "..." : ""}\x1b[0m`);
317
+ }
318
+ function fmtTokens(n) {
319
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
320
+ }
321
+
322
+ function printTokenLine(usage, startedAt) {
323
+ const secs = ((Date.now() - startedAt) / 1000).toFixed(1);
324
+ if (!usage) {
325
+ console.log(`\x1b[90m⏱ ${secs}s · tokens not reported by provider\x1b[0m`);
326
+ return;
327
+ }
328
+ const inTok = usage.input_tokens ?? usage.prompt_tokens ?? 0;
329
+ const outTok = usage.output_tokens ?? usage.completion_tokens ?? 0;
330
+ const total = usage.total_tokens ?? inTok + outTok;
331
+ let line = `\x1b[90m⏱ ${secs}s · ↑ ${fmtTokens(inTok)} in · ↓ ${fmtTokens(outTok)} out`;
332
+ if (usage.cache_read_input_tokens) line += ` · ⚡ ${fmtTokens(usage.cache_read_input_tokens)} cached`;
333
+ line += ` · Σ ${fmtTokens(total)}\x1b[0m`;
334
+ console.log(line);
335
+ }
336
+
184
337
  async function runInteractiveConfig() {
185
338
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
186
339
 
@@ -267,8 +420,12 @@ async function readSseStream(res, onChunk) {
267
420
  }
268
421
  }
269
422
 
270
- function buildSystemPrompt() {
271
- const skillsInfo = getSkillsPrompt();
423
+ function buildSystemPrompt(convo) {
424
+ let lastUser = "";
425
+ for (let i = (convo?.length ?? 0) - 1; i >= 0; i--) {
426
+ if (convo[i].role === "user" && typeof convo[i].content === "string") { lastUser = convo[i].content; break; }
427
+ }
428
+ const skillsInfo = getSkillsPrompt(lastUser);
272
429
  return `Kamu KILUA, AI Coding Agent otonom di terminal lokal. Folder aktif: ${process.cwd()}.
273
430
  KEMAMPUAN TOOLS:
274
431
  - read_file, write_file, edit_file, list_dir
@@ -289,25 +446,82 @@ CARA KERJA (WAJIB DIIKUTI):
289
446
  Gaya: ringkas, langsung bertindak, jangan minta konfirmasi untuk langkah yang sudah jelas.`;
290
447
  }
291
448
 
449
+ // ponytail: bedakan token habis vs koneksi putus. Token habis = berhenti + beritahu.
450
+ // Koneksi putus = terus coba sampai nyambung lagi, maksimal ~5 menit lalu beritahu.
451
+ const TOKEN_EXHAUSTED_RE = /insufficient_quota|quota exceeded|exceeded your current quota|credit balance|billing|payment required|no credit|saldo|quota habis/i;
452
+ const MAX_RECONNECT_ATTEMPTS = 12;
453
+
454
+ function isTokenExhausted(status, errText) {
455
+ if (TOKEN_EXHAUSTED_RE.test(errText || "")) return true;
456
+ if (status === 402) return true;
457
+ return false;
458
+ }
459
+
460
+ function isBadCredentials(status, errText) {
461
+ if (status === 403) return true;
462
+ return status === 401 && /api key|unauthorized|invalid.*key|authentication/i.test(errText || "");
463
+ }
464
+
465
+ function reportTokenExhausted(status, errText, badCredentials) {
466
+ stopStatus();
467
+ const hint = String(errText || "").slice(0, 200).replace(/\n/g, " ");
468
+ if (badCredentials) {
469
+ console.error(`\n\x1b[31m[✖] API KEY DITOLAK (HTTP ${status}).\x1b[0m`);
470
+ console.error(`\x1b[90m ${hint}\x1b[0m`);
471
+ console.error(`\x1b[90m Perbaiki key lewat: /config (riwayat chat tetap ada, tinggal minta "lanjut").\x1b[0m\n`);
472
+ return;
473
+ }
474
+ console.error(`\n\x1b[31m[✖] TOKEN / KUOTA HABIS (HTTP ${status}).\x1b[0m`);
475
+ console.error(`\x1b[33m Tugas dihentikan karena provider menolak permintaan.\x1b[0m`);
476
+ if (hint) console.error(`\x1b[90m ${hint}\x1b[0m`);
477
+ console.error(`\x1b[90m Isi ulang kuota / ganti provider lalu ketik: /config (riwayat chat tetap ada, tinggal minta "lanjut").\x1b[0m\n`);
478
+ }
479
+
480
+ function reportConnectionLost(reason) {
481
+ stopStatus();
482
+ console.error(`\n\x1b[31m[✖] KONEKSI TERPUTUS: ${reason}\x1b[0m`);
483
+ console.error(`\x1b[33m Kilua berhenti setelah ${MAX_RECONNECT_ATTEMPTS} percobaan.\x1b[0m`);
484
+ console.error(`\x1b[90m Begitu internet/provider normal, minta Kilua "lanjut" — riwayat langkahnya masih tersimpan.\x1b[0m\n`);
485
+ }
486
+
487
+ // true = sudah dilaporkan, loop harus berhenti.
488
+ function handleHttpError(status, errText) {
489
+ if (isTokenExhausted(status, errText) || isBadCredentials(status, errText)) {
490
+ reportTokenExhausted(status, errText, isBadCredentials(status, errText));
491
+ return true;
492
+ }
493
+ stopStatus();
494
+ console.error(`\n\x1b[31m[✖] Provider error (HTTP ${status}).\x1b[0m`);
495
+ console.error(`\x1b[90m ${String(errText).slice(0, 300).replace(/\n/g, " ")}\x1b[0m\n`);
496
+ return true;
497
+ }
498
+
292
499
  // ponytail: retry untuk 429/5xx/network — provider gratis sering rate-limit
293
500
  async function fetchWithRetry(url, init) {
294
501
  let lastErr;
295
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
502
+ let hadFailure = false;
503
+ for (let attempt = 0; attempt <= MAX_RECONNECT_ATTEMPTS; attempt++) {
296
504
  try {
297
505
  const res = await fetch(url, init);
298
506
  if (res.status === 429 || res.status === 503 || res.status >= 500) {
299
- if (attempt === MAX_RETRIES) return res;
507
+ if (attempt === MAX_RECONNECT_ATTEMPTS) return res;
508
+ hadFailure = true;
300
509
  const wait = Math.min(30000, 1500 * Math.pow(2, attempt));
301
- process.stdout.write(`\r\x1b[2K\x1b[33m[!] Server sibuk (${res.status}), ulang ${attempt + 1}/${MAX_RETRIES} dalam ${Math.round(wait / 1000)}s...\x1b[0m`);
510
+ startStatus(`Connection busy (${res.status}) — retry ${attempt + 1}/${MAX_RECONNECT_ATTEMPTS} in ${Math.round(wait / 1000)}s...`);
302
511
  await new Promise(r => setTimeout(r, wait));
303
512
  continue;
304
513
  }
514
+ if (hadFailure) {
515
+ stopStatus();
516
+ console.log("\x1b[32m[✓] Connection restored — resuming task.\x1b[0m");
517
+ }
305
518
  return res;
306
519
  } catch (e) {
307
520
  lastErr = e;
308
- if (attempt === MAX_RETRIES) throw e;
309
- const wait = Math.min(20000, 1500 * Math.pow(2, attempt));
310
- process.stdout.write(`\r\x1b[2K\x1b[33m[!] Koneksi gagal, ulang ${attempt + 1}/${MAX_RETRIES} dalam ${Math.round(wait / 1000)}s...\x1b[0m`);
521
+ if (attempt === MAX_RECONNECT_ATTEMPTS) throw e;
522
+ hadFailure = true;
523
+ const wait = Math.min(30000, 1500 * Math.pow(2, attempt));
524
+ startStatus(`Connection lost — reconnecting in ${Math.round(wait / 1000)}s (${attempt + 1}/${MAX_RECONNECT_ATTEMPTS})...`);
311
525
  await new Promise(r => setTimeout(r, wait));
312
526
  }
313
527
  }
@@ -316,13 +530,16 @@ async function fetchWithRetry(url, init) {
316
530
 
317
531
  // Loop untuk Anthropic / 9router local (/v1/messages)
318
532
  async function runAnthropicLoop(settings, convo) {
319
- const SYSTEM = buildSystemPrompt();
533
+ const SYSTEM = buildSystemPrompt(convo);
320
534
 
321
535
  let endpoint = settings.base_url.replace(/\/+$/, "");
322
536
  if (!endpoint.endsWith("/messages")) endpoint += "/messages";
323
537
 
538
+ let finished = false;
324
539
  for (let step = 0; step < MAX_STEPS; step++) {
325
- process.stdout.write("\r\x1b[2K\x1b[35m[KILUA Berpikir...]\x1b[0m ");
540
+ startStatus(stepLabel(step, MAX_STEPS));
541
+ const turnStart = Date.now();
542
+ let usage = null;
326
543
  let res;
327
544
  try {
328
545
  res = await fetchWithRetry(endpoint, {
@@ -343,13 +560,12 @@ async function runAnthropicLoop(settings, convo) {
343
560
  })
344
561
  });
345
562
  } catch (e) {
346
- console.error("\n[!] Connection error:", e.message);
563
+ reportConnectionLost(e.message || "network error");
347
564
  return;
348
565
  }
349
566
 
350
567
  if (!res.ok) {
351
- const errText = await res.text().catch(() => "");
352
- console.error(`\n[!] Error (${res.status}):`, errText.slice(0, 200));
568
+ handleHttpError(res.status, await res.text().catch(() => ""));
353
569
  return;
354
570
  }
355
571
 
@@ -358,6 +574,9 @@ async function runAnthropicLoop(settings, convo) {
358
574
  const toolBlocks = [];
359
575
 
360
576
  await readSseStream(res, (evt) => {
577
+ if (evt.type === "message_start" && evt.message?.usage) usage = { ...evt.message.usage };
578
+ if (evt.type === "message_delta" && evt.usage) usage = { ...usage, ...evt.usage };
579
+
361
580
  let deltaText = "";
362
581
  if (typeof evt.delta?.text === "string") deltaText = evt.delta.text;
363
582
  else if (typeof evt.delta?.content === "string") deltaText = evt.delta.content;
@@ -366,6 +585,7 @@ async function runAnthropicLoop(settings, convo) {
366
585
 
367
586
  if (deltaText) {
368
587
  if (!printedHeader) {
588
+ stopStatus();
369
589
  process.stdout.write("\r\x1b[2KKILUA > ");
370
590
  printedHeader = true;
371
591
  }
@@ -389,8 +609,10 @@ async function runAnthropicLoop(settings, convo) {
389
609
  }
390
610
  });
391
611
 
612
+ stopStatus();
392
613
  if (printedHeader) console.log();
393
614
  else process.stdout.write("\r\x1b[2K");
615
+ printTokenLine(usage, turnStart);
394
616
 
395
617
  const activeTools = toolBlocks.filter(Boolean);
396
618
  const assistantBlocks = [];
@@ -399,15 +621,14 @@ async function runAnthropicLoop(settings, convo) {
399
621
 
400
622
  convo.push({ role: "assistant", content: assistantBlocks.length ? assistantBlocks : fullText });
401
623
 
402
- if (!activeTools.length) break;
624
+ if (!activeTools.length) { finished = true; break; }
403
625
 
404
626
  const toolResultContent = [];
405
627
  for (const tu of activeTools) {
406
628
  const name = tu.name;
407
629
  const args = tu.input || {};
408
- console.log(`\x1b[36m⚡ Tool: ${name}\x1b[0m(${JSON.stringify(args)})`);
409
630
  const output = executeLocalTool(name, args);
410
- console.log(`\x1b[90m↳ ${output.slice(0, 160).replace(/\n/g, " ")}${output.length > 160 ? "..." : ""}\x1b[0m`);
631
+ printToolCall(name, args, output);
411
632
  toolResultContent.push({
412
633
  type: "tool_result",
413
634
  tool_use_id: tu.id,
@@ -416,11 +637,16 @@ async function runAnthropicLoop(settings, convo) {
416
637
  }
417
638
  convo.push({ role: "user", content: toolResultContent });
418
639
  }
640
+ if (!finished) {
641
+ stopStatus();
642
+ console.log(`\x1b[33m[!] Batas ${MAX_STEPS} langkah tercapai — tugas belum tuntas.\x1b[0m`);
643
+ console.log(`\x1b[90m Ketik "lanjut" untuk meneruskan (riwayat tetap tersimpan).\x1b[0m\n`);
644
+ }
419
645
  }
420
646
 
421
647
  // Loop untuk OpenAI / TokenHarbor / Gemini OpenAI protocol
422
648
  async function runOpenAiLoop(settings, convo) {
423
- const SYSTEM = buildSystemPrompt();
649
+ const SYSTEM = buildSystemPrompt(convo);
424
650
 
425
651
  let endpoint = settings.base_url.replace(/\/+$/, "");
426
652
  if (endpoint.includes("generativelanguage.googleapis.com") && !endpoint.includes("/openai")) {
@@ -430,8 +656,11 @@ async function runOpenAiLoop(settings, convo) {
430
656
  endpoint += "/chat/completions";
431
657
  }
432
658
 
659
+ let finished = false;
433
660
  for (let step = 0; step < MAX_STEPS; step++) {
434
- process.stdout.write("\r\x1b[2K\x1b[35m[KILUA Berpikir...]\x1b[0m ");
661
+ startStatus(stepLabel(step, MAX_STEPS));
662
+ const turnStart = Date.now();
663
+ let usage = null;
435
664
  let res;
436
665
  try {
437
666
  res = await fetchWithRetry(endpoint, {
@@ -444,19 +673,19 @@ async function runOpenAiLoop(settings, convo) {
444
673
  body: JSON.stringify({
445
674
  model: settings.model,
446
675
  stream: true,
676
+ stream_options: { include_usage: true },
447
677
  messages: [{ role: "system", content: SYSTEM }, ...convo],
448
678
  tools: TOOLS_OPENAI,
449
679
  tool_choice: "auto"
450
680
  })
451
681
  });
452
682
  } catch (e) {
453
- console.error("\n[!] Connection error:", e.message);
683
+ reportConnectionLost(e.message || "network error");
454
684
  return;
455
685
  }
456
686
 
457
687
  if (!res.ok) {
458
- const errText = await res.text().catch(() => "");
459
- console.error(`\n[!] Error (${res.status}):`, errText.slice(0, 200));
688
+ handleHttpError(res.status, await res.text().catch(() => ""));
460
689
  return;
461
690
  }
462
691
 
@@ -465,10 +694,12 @@ async function runOpenAiLoop(settings, convo) {
465
694
  const toolCallsMap = {};
466
695
 
467
696
  await readSseStream(res, (evt) => {
697
+ if (evt.usage) usage = evt.usage;
468
698
  const choice = evt.choices?.[0];
469
699
  const delta = choice?.delta;
470
700
  if (delta?.content) {
471
701
  if (!printedHeader) {
702
+ stopStatus();
472
703
  process.stdout.write("\r\x1b[2KKILUA > ");
473
704
  printedHeader = true;
474
705
  }
@@ -485,24 +716,30 @@ async function runOpenAiLoop(settings, convo) {
485
716
  }
486
717
  });
487
718
 
719
+ stopStatus();
488
720
  if (printedHeader) console.log();
489
721
  else process.stdout.write("\r\x1b[2K");
722
+ printTokenLine(usage, turnStart);
490
723
 
491
724
  const toolCalls = Object.values(toolCallsMap);
492
725
  convo.push({ role: "assistant", content: fullText, tool_calls: toolCalls.length ? toolCalls : undefined });
493
726
 
494
- if (!toolCalls.length) break;
727
+ if (!toolCalls.length) { finished = true; break; }
495
728
 
496
729
  for (const tc of toolCalls) {
497
730
  const name = tc.function.name;
498
731
  let args = {};
499
732
  try { args = JSON.parse(tc.function.arguments || "{}"); } catch {}
500
- console.log(`\x1b[36m⚡ Tool: ${name}\x1b[0m(${JSON.stringify(args)})`);
501
733
  const output = executeLocalTool(name, args);
502
- console.log(`\x1b[90m↳ ${output.slice(0, 160).replace(/\n/g, " ")}${output.length > 160 ? "..." : ""}\x1b[0m`);
734
+ printToolCall(name, args, output);
503
735
  convo.push({ role: "tool", tool_call_id: tc.id, name, content: output });
504
736
  }
505
737
  }
738
+ if (!finished) {
739
+ stopStatus();
740
+ console.log(`\x1b[33m[!] Batas ${MAX_STEPS} langkah tercapai — tugas belum tuntas.\x1b[0m`);
741
+ console.log(`\x1b[90m Ketik "lanjut" untuk meneruskan (riwayat tetap tersimpan).\x1b[0m\n`);
742
+ }
506
743
  }
507
744
 
508
745
  async function runAgent(settings, userPrompt, convo) {
@@ -556,6 +793,7 @@ async function handleSlashCommand(cmd, settings, convo, rl) {
556
793
 
557
794
  if (action === "/skills" || action === "/skill") {
558
795
  const list = listInstalledSkills();
796
+ const builtin = loadBuiltinSkills();
559
797
  console.log(`\n\x1b[35m=== SKILLS TERPASANG (${SKILLS_DIR}) ===\x1b[0m`);
560
798
  if (!list.length) {
561
799
  console.log(`Belum ada skill tambahan terpasang.`);
@@ -565,6 +803,7 @@ async function handleSlashCommand(cmd, settings, convo, rl) {
565
803
  console.log(`- \x1b[32m${s.name}\x1b[0m: ${s.desc}`);
566
804
  }
567
805
  }
806
+ console.log(`\x1b[90m+ ${builtin.length} skill bawaan aktif otomatis (disuntik maks ${MAX_INJECTED_SKILLS}/pesan sesuai tugas)\x1b[0m`);
568
807
  console.log();
569
808
  return settings;
570
809
  }