@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 +3 -0
- package/bin/kilua.js +343 -37
- package/bin/kilua.mjs +343 -37
- package/bin/skills.json +1 -0
- package/package.json +1 -1
package/bin/kilua.mjs
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
1.
|
|
241
|
-
2.
|
|
242
|
-
3.
|
|
243
|
-
4.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
|
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
|
-
|
|
563
|
+
reportConnectionLost(e.message || "network error");
|
|
280
564
|
return;
|
|
281
565
|
}
|
|
282
566
|
|
|
283
567
|
if (!res.ok) {
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
|
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
|
-
|
|
683
|
+
reportConnectionLost(e.message || "network error");
|
|
387
684
|
return;
|
|
388
685
|
}
|
|
389
686
|
|
|
390
687
|
if (!res.ok) {
|
|
391
|
-
|
|
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
|
-
|
|
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
|
}
|