@gilangranggaid/kilua-agent 1.1.2 → 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,11 +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");
13
+ const MAX_STEPS = 40; // ponytail: naik dari 16; agent butuh banyak langkah untuk self-fix loop
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
+ }
12
81
 
13
82
  function ensureKiluaDir() {
14
83
  if (!fs.existsSync(KILUA_DIR)) fs.mkdirSync(KILUA_DIR, { recursive: true });
@@ -45,12 +114,20 @@ function listInstalledSkills() {
45
114
  return skills;
46
115
  }
47
116
 
48
- function getSkillsPrompt() {
117
+ function getSkillsPrompt(userPrompt) {
49
118
  const list = listInstalledSkills();
50
- if (!list.length) return "";
51
- let prompt = "\n\n=== INSTALLED SKILLS ===\n";
52
- for (const s of list) {
53
- 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";
54
131
  }
55
132
  return prompt;
56
133
  }
@@ -88,6 +165,30 @@ const TOOLS_OPENAI = [
88
165
  parameters: { type: "object", properties: { path: { type: "string" } } }
89
166
  }
90
167
  },
168
+ {
169
+ type: "function",
170
+ function: {
171
+ name: "grep_search",
172
+ description: "Mencari pola teks (regex) di dalam file project. Lebih hemat token daripada membaca seluruh file. Hasil: path:baris:isi.",
173
+ parameters: {
174
+ type: "object",
175
+ properties: {
176
+ pattern: { type: "string", description: "Pola regex atau teks yang dicari." },
177
+ path: { type: "string", description: "Folder atau file awal. Default: folder aktif." },
178
+ glob: { type: "string", description: "Filter file, contoh: *.js atau **/*.ts" }
179
+ },
180
+ required: ["pattern"]
181
+ }
182
+ }
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
+ },
91
192
  {
92
193
  type: "function",
93
194
  function: {
@@ -113,9 +214,10 @@ function executeLocalTool(name, args) {
113
214
  }
114
215
  if (name === "write_file") {
115
216
  const full = path.resolve(cwd, args.path);
217
+ const existed = fs.existsSync(full);
116
218
  fs.mkdirSync(path.dirname(full), { recursive: true });
117
219
  fs.writeFileSync(full, args.content, "utf8");
118
- return `✓ Written: ${args.path}`;
220
+ return `\x1b[32m● Added\x1b[0m ${args.path}${existed ? " (overwritten)" : ` (${args.content.split("\n").length} lines)`}`;
119
221
  }
120
222
  if (name === "edit_file") {
121
223
  const full = path.resolve(cwd, args.path);
@@ -123,14 +225,42 @@ function executeLocalTool(name, args) {
123
225
  const src = fs.readFileSync(full, "utf8");
124
226
  if (!src.includes(args.old_string)) return `Error: old_string not matched in ${args.path}.`;
125
227
  fs.writeFileSync(full, src.replace(args.old_string, args.new_string), "utf8");
126
- 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}`;
127
237
  }
128
238
  if (name === "list_dir") {
129
239
  const target = path.resolve(cwd, args.path || ".");
130
240
  return fs.readdirSync(target, { withFileTypes: true }).map(it => `${it.isDirectory() ? "[DIR]" : "[FILE]"} ${it.name}`).join("\n");
131
241
  }
242
+ if (name === "grep_search") {
243
+ const target = path.resolve(cwd, args.path || ".");
244
+ const pattern = args.pattern;
245
+ const glob = args.glob || "";
246
+ // ponytail: pakai findstr/Select-String bawaan OS, hindari dependency
247
+ const isWin = process.platform === "win32";
248
+ let cmd;
249
+ if (isWin) {
250
+ const p = target.replace(/\//g, "\\");
251
+ cmd = glob
252
+ ? `powershell -NoProfile -Command "Get-ChildItem -Path '${p}' -Recurse -File -Filter '${glob}' -ErrorAction SilentlyContinue | Select-String -Pattern '${pattern.replace(/'/g, "''")}' | Select-Object -First 200 | ForEach-Object { \\"$($_.Path):$($_.LineNumber):$($_.Line.Trim())\\" }"`
253
+ : `powershell -NoProfile -Command "Get-ChildItem -Path '${p}' -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Length -lt 2MB -and $_.FullName -notmatch 'node_modules|\\\\.git\\\\' } | Select-String -Pattern '${pattern.replace(/'/g, "''")}' | Select-Object -First 200 | ForEach-Object { \\"$($_.Path):$($_.LineNumber):$($_.Line.Trim())\\" }"`;
254
+ } else {
255
+ cmd = glob
256
+ ? `grep -rn --include='${glob}' '${pattern.replace(/'/g, "'\\''")}' '${target}' | head -200`
257
+ : `grep -rn --exclude-dir=node_modules --exclude-dir=.git '${pattern.replace(/'/g, "'\\''")}' '${target}' | head -200`;
258
+ }
259
+ const out = execSync(cmd, { cwd, encoding: "utf8", timeout: 60000, stdio: ["pipe", "pipe", "pipe"] });
260
+ return out.trim() || "(tidak ada hasil)";
261
+ }
132
262
  if (name === "run_cmd") {
133
- const out = execSync(args.command, { cwd, encoding: "utf8", timeout: 120000, stdio: ["pipe", "pipe", "pipe"] });
263
+ const out = execSync(args.command, { cwd, encoding: "utf8", timeout: 180000, stdio: ["pipe", "pipe", "pipe"], maxBuffer: 10 * 1024 * 1024 });
134
264
  return out.trim() || "(Done with exit code 0)";
135
265
  }
136
266
  } catch (err) {
@@ -143,6 +273,67 @@ function promptQuestion(rl, query) {
143
273
  return new Promise(res => rl.question(query, res));
144
274
  }
145
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
+
146
337
  async function runInteractiveConfig() {
147
338
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
148
339
 
@@ -229,36 +420,129 @@ async function readSseStream(res, onChunk) {
229
420
  }
230
421
  }
231
422
 
232
- function buildSystemPrompt() {
233
- 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);
234
429
  return `Kamu KILUA, AI Coding Agent otonom di terminal lokal. Folder aktif: ${process.cwd()}.
235
430
  KEMAMPUAN TOOLS:
236
431
  - read_file, write_file, edit_file, list_dir
432
+ - grep_search: mencari pola di seluruh file project (hemat token, pakai ini sebelum read_file untuk file besar)
237
433
  - run_cmd: menjalankan perintah terminal lokal (npm, npx, git, node, python, build, test, curl, install skill, dsb).
238
434
 
239
- ATURAN OTONOM KILUA (SELF-TESTING & AUTO-INSTALL SKILL):
240
- 1. Jika pengguna meminta membuat project/fitur/skrip, JANGAN langsung serahkan begitu saja. Kamu HARUS mencoba atau memvalidasinya sendiri terlebih dahulu menggunakan run_cmd (misal: jalankan skrip, test, build, atau cek sintaks).
241
- 2. Jika ada error saat kamu mencoba, perbaiki langsung menggunakan edit_file/write_file sampai berhasil.
242
- 3. Setelah berhasil dicoba dan dipastikan tidak error, barulah beri tahu pengguna hasil dan cara menjalankannya.
243
- 4. Jika pengguna di chat menyuruh kamu install skill (misal: "install skill vercel-labs/skills@find-skills" atau skill lain), jalankan run_cmd dengan perintah:
435
+ CARA KERJA (WAJIB DIIKUTI):
436
+ 1. Pahami dulu: list_dir + grep_search untuk menemukan file yang relevan. Jangan menebak isi file.
437
+ 2. Baca hanya file yang benar-benar perlu (read_file), bukan seluruh project.
438
+ 3. Tulis perubahan dengan edit_file (teks lama harus persis sama). Pakai write_file hanya untuk file baru.
439
+ 4. SETELAH mengubah kode, WAJIB verifikasi sendiri lewat run_cmd: jalankan file, test, atau build.
440
+ 5. Jika muncul error, perbaiki sendiri dengan edit_file lalu jalankan ulang. Ulangi sampai lolos.
441
+ 6. Baru setelah terbukti jalan tanpa error, laporkan hasil + cara menjalankannya ke pengguna.
442
+ 7. Jika pengguna menyuruh install skill di chat (misal: "install skill vercel-labs/skills@find-skills"), jalankan run_cmd:
244
443
  npx skills add <nama-skill> --cwd "${SKILLS_DIR}"
245
- Lalu beri tahu pengguna bahwa skill tersebut telah siap dipakai.${skillsInfo}
444
+ lalu beri tahu skill sudah siap dipakai.${skillsInfo}
445
+
446
+ Gaya: ringkas, langsung bertindak, jangan minta konfirmasi untuk langkah yang sudah jelas.`;
447
+ }
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
+ }
246
498
 
247
- Respon ringkas, padat, dan bertindak langsung.`;
499
+ // ponytail: retry untuk 429/5xx/network — provider gratis sering rate-limit
500
+ async function fetchWithRetry(url, init) {
501
+ let lastErr;
502
+ let hadFailure = false;
503
+ for (let attempt = 0; attempt <= MAX_RECONNECT_ATTEMPTS; attempt++) {
504
+ try {
505
+ const res = await fetch(url, init);
506
+ if (res.status === 429 || res.status === 503 || res.status >= 500) {
507
+ if (attempt === MAX_RECONNECT_ATTEMPTS) return res;
508
+ hadFailure = true;
509
+ const wait = Math.min(30000, 1500 * Math.pow(2, attempt));
510
+ startStatus(`Connection busy (${res.status}) — retry ${attempt + 1}/${MAX_RECONNECT_ATTEMPTS} in ${Math.round(wait / 1000)}s...`);
511
+ await new Promise(r => setTimeout(r, wait));
512
+ continue;
513
+ }
514
+ if (hadFailure) {
515
+ stopStatus();
516
+ console.log("\x1b[32m[✓] Connection restored — resuming task.\x1b[0m");
517
+ }
518
+ return res;
519
+ } catch (e) {
520
+ lastErr = e;
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})...`);
525
+ await new Promise(r => setTimeout(r, wait));
526
+ }
527
+ }
528
+ if (lastErr) throw lastErr;
248
529
  }
249
530
 
250
531
  // Loop untuk Anthropic / 9router local (/v1/messages)
251
532
  async function runAnthropicLoop(settings, convo) {
252
- const SYSTEM = buildSystemPrompt();
533
+ const SYSTEM = buildSystemPrompt(convo);
253
534
 
254
535
  let endpoint = settings.base_url.replace(/\/+$/, "");
255
536
  if (!endpoint.endsWith("/messages")) endpoint += "/messages";
256
537
 
257
- for (let step = 0; step < 16; step++) {
258
- process.stdout.write("\r\x1b[2K\x1b[35m[KILUA Berpikir...]\x1b[0m ");
538
+ let finished = false;
539
+ for (let step = 0; step < MAX_STEPS; step++) {
540
+ startStatus(stepLabel(step, MAX_STEPS));
541
+ const turnStart = Date.now();
542
+ let usage = null;
259
543
  let res;
260
544
  try {
261
- res = await fetch(endpoint, {
545
+ res = await fetchWithRetry(endpoint, {
262
546
  method: "POST",
263
547
  headers: {
264
548
  "Content-Type": "application/json",
@@ -276,13 +560,12 @@ async function runAnthropicLoop(settings, convo) {
276
560
  })
277
561
  });
278
562
  } catch (e) {
279
- console.error("\n[!] Connection error:", e.message);
563
+ reportConnectionLost(e.message || "network error");
280
564
  return;
281
565
  }
282
566
 
283
567
  if (!res.ok) {
284
- const errText = await res.text().catch(() => "");
285
- console.error(`\n[!] Error (${res.status}):`, errText.slice(0, 200));
568
+ handleHttpError(res.status, await res.text().catch(() => ""));
286
569
  return;
287
570
  }
288
571
 
@@ -291,6 +574,9 @@ async function runAnthropicLoop(settings, convo) {
291
574
  const toolBlocks = [];
292
575
 
293
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
+
294
580
  let deltaText = "";
295
581
  if (typeof evt.delta?.text === "string") deltaText = evt.delta.text;
296
582
  else if (typeof evt.delta?.content === "string") deltaText = evt.delta.content;
@@ -299,6 +585,7 @@ async function runAnthropicLoop(settings, convo) {
299
585
 
300
586
  if (deltaText) {
301
587
  if (!printedHeader) {
588
+ stopStatus();
302
589
  process.stdout.write("\r\x1b[2KKILUA > ");
303
590
  printedHeader = true;
304
591
  }
@@ -322,8 +609,10 @@ async function runAnthropicLoop(settings, convo) {
322
609
  }
323
610
  });
324
611
 
612
+ stopStatus();
325
613
  if (printedHeader) console.log();
326
614
  else process.stdout.write("\r\x1b[2K");
615
+ printTokenLine(usage, turnStart);
327
616
 
328
617
  const activeTools = toolBlocks.filter(Boolean);
329
618
  const assistantBlocks = [];
@@ -332,15 +621,14 @@ async function runAnthropicLoop(settings, convo) {
332
621
 
333
622
  convo.push({ role: "assistant", content: assistantBlocks.length ? assistantBlocks : fullText });
334
623
 
335
- if (!activeTools.length) break;
624
+ if (!activeTools.length) { finished = true; break; }
336
625
 
337
626
  const toolResultContent = [];
338
627
  for (const tu of activeTools) {
339
628
  const name = tu.name;
340
629
  const args = tu.input || {};
341
- console.log(`\x1b[36m⚡ Tool: ${name}\x1b[0m(${JSON.stringify(args)})`);
342
630
  const output = executeLocalTool(name, args);
343
- console.log(`\x1b[90m↳ ${output.slice(0, 160).replace(/\n/g, " ")}${output.length > 160 ? "..." : ""}\x1b[0m`);
631
+ printToolCall(name, args, output);
344
632
  toolResultContent.push({
345
633
  type: "tool_result",
346
634
  tool_use_id: tu.id,
@@ -349,11 +637,16 @@ async function runAnthropicLoop(settings, convo) {
349
637
  }
350
638
  convo.push({ role: "user", content: toolResultContent });
351
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
+ }
352
645
  }
353
646
 
354
647
  // Loop untuk OpenAI / TokenHarbor / Gemini OpenAI protocol
355
648
  async function runOpenAiLoop(settings, convo) {
356
- const SYSTEM = buildSystemPrompt();
649
+ const SYSTEM = buildSystemPrompt(convo);
357
650
 
358
651
  let endpoint = settings.base_url.replace(/\/+$/, "");
359
652
  if (endpoint.includes("generativelanguage.googleapis.com") && !endpoint.includes("/openai")) {
@@ -363,11 +656,14 @@ async function runOpenAiLoop(settings, convo) {
363
656
  endpoint += "/chat/completions";
364
657
  }
365
658
 
366
- for (let step = 0; step < 16; step++) {
367
- process.stdout.write("\r\x1b[2K\x1b[35m[KILUA Berpikir...]\x1b[0m ");
659
+ let finished = false;
660
+ for (let step = 0; step < MAX_STEPS; step++) {
661
+ startStatus(stepLabel(step, MAX_STEPS));
662
+ const turnStart = Date.now();
663
+ let usage = null;
368
664
  let res;
369
665
  try {
370
- res = await fetch(endpoint, {
666
+ res = await fetchWithRetry(endpoint, {
371
667
  method: "POST",
372
668
  headers: {
373
669
  "Content-Type": "application/json",
@@ -377,19 +673,19 @@ async function runOpenAiLoop(settings, convo) {
377
673
  body: JSON.stringify({
378
674
  model: settings.model,
379
675
  stream: true,
676
+ stream_options: { include_usage: true },
380
677
  messages: [{ role: "system", content: SYSTEM }, ...convo],
381
678
  tools: TOOLS_OPENAI,
382
679
  tool_choice: "auto"
383
680
  })
384
681
  });
385
682
  } catch (e) {
386
- console.error("\n[!] Connection error:", e.message);
683
+ reportConnectionLost(e.message || "network error");
387
684
  return;
388
685
  }
389
686
 
390
687
  if (!res.ok) {
391
- const errText = await res.text().catch(() => "");
392
- console.error(`\n[!] Error (${res.status}):`, errText.slice(0, 200));
688
+ handleHttpError(res.status, await res.text().catch(() => ""));
393
689
  return;
394
690
  }
395
691
 
@@ -398,10 +694,12 @@ async function runOpenAiLoop(settings, convo) {
398
694
  const toolCallsMap = {};
399
695
 
400
696
  await readSseStream(res, (evt) => {
697
+ if (evt.usage) usage = evt.usage;
401
698
  const choice = evt.choices?.[0];
402
699
  const delta = choice?.delta;
403
700
  if (delta?.content) {
404
701
  if (!printedHeader) {
702
+ stopStatus();
405
703
  process.stdout.write("\r\x1b[2KKILUA > ");
406
704
  printedHeader = true;
407
705
  }
@@ -418,24 +716,30 @@ async function runOpenAiLoop(settings, convo) {
418
716
  }
419
717
  });
420
718
 
719
+ stopStatus();
421
720
  if (printedHeader) console.log();
422
721
  else process.stdout.write("\r\x1b[2K");
722
+ printTokenLine(usage, turnStart);
423
723
 
424
724
  const toolCalls = Object.values(toolCallsMap);
425
725
  convo.push({ role: "assistant", content: fullText, tool_calls: toolCalls.length ? toolCalls : undefined });
426
726
 
427
- if (!toolCalls.length) break;
727
+ if (!toolCalls.length) { finished = true; break; }
428
728
 
429
729
  for (const tc of toolCalls) {
430
730
  const name = tc.function.name;
431
731
  let args = {};
432
732
  try { args = JSON.parse(tc.function.arguments || "{}"); } catch {}
433
- console.log(`\x1b[36m⚡ Tool: ${name}\x1b[0m(${JSON.stringify(args)})`);
434
733
  const output = executeLocalTool(name, args);
435
- console.log(`\x1b[90m↳ ${output.slice(0, 160).replace(/\n/g, " ")}${output.length > 160 ? "..." : ""}\x1b[0m`);
734
+ printToolCall(name, args, output);
436
735
  convo.push({ role: "tool", tool_call_id: tc.id, name, content: output });
437
736
  }
438
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
+ }
439
743
  }
440
744
 
441
745
  async function runAgent(settings, userPrompt, convo) {
@@ -489,6 +793,7 @@ async function handleSlashCommand(cmd, settings, convo, rl) {
489
793
 
490
794
  if (action === "/skills" || action === "/skill") {
491
795
  const list = listInstalledSkills();
796
+ const builtin = loadBuiltinSkills();
492
797
  console.log(`\n\x1b[35m=== SKILLS TERPASANG (${SKILLS_DIR}) ===\x1b[0m`);
493
798
  if (!list.length) {
494
799
  console.log(`Belum ada skill tambahan terpasang.`);
@@ -498,6 +803,7 @@ async function handleSlashCommand(cmd, settings, convo, rl) {
498
803
  console.log(`- \x1b[32m${s.name}\x1b[0m: ${s.desc}`);
499
804
  }
500
805
  }
806
+ console.log(`\x1b[90m+ ${builtin.length} skill bawaan aktif otomatis (disuntik maks ${MAX_INJECTED_SKILLS}/pesan sesuai tugas)\x1b[0m`);
501
807
  console.log();
502
808
  return settings;
503
809
  }