@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 +3 -0
- package/bin/kilua.js +271 -32
- package/bin/kilua.mjs +271 -32
- package/bin/skills.json +1 -0
- package/package.json +1 -1
package/bin/kilua.mjs
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
507
|
+
if (attempt === MAX_RECONNECT_ATTEMPTS) return res;
|
|
508
|
+
hadFailure = true;
|
|
300
509
|
const wait = Math.min(30000, 1500 * Math.pow(2, attempt));
|
|
301
|
-
|
|
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 ===
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
563
|
+
reportConnectionLost(e.message || "network error");
|
|
347
564
|
return;
|
|
348
565
|
}
|
|
349
566
|
|
|
350
567
|
if (!res.ok) {
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
683
|
+
reportConnectionLost(e.message || "network error");
|
|
454
684
|
return;
|
|
455
685
|
}
|
|
456
686
|
|
|
457
687
|
if (!res.ok) {
|
|
458
|
-
|
|
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
|
-
|
|
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
|
}
|