@chispear/vibekit 0.1.1 → 0.3.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 +33 -3
- package/dist/agent.js +453 -0
- package/dist/banner.js +36 -0
- package/dist/cli.js +186 -63
- package/dist/router.js +31 -5
- package/package.json +3 -1
- package/scripts/banner.js +34 -0
package/README.md
CHANGED
|
@@ -61,11 +61,41 @@ vibekit status
|
|
|
61
61
|
| Perintah | Fungsi |
|
|
62
62
|
|---|---|
|
|
63
63
|
| `vibekit init` | Buat `~/.vibekit/config.json` dari template (tidak menimpa yang sudah ada) |
|
|
64
|
-
| `vibekit ask "<pertanyaan>" [--file <path>]... [--dir <path>]...` |
|
|
65
|
-
| `vibekit chat` | Percakapan interaktif; riwayat dipangkas otomatis (maks ±4.000 token, giliran
|
|
64
|
+
| `vibekit ask "<pertanyaan>" [--file <path>]... [--dir <path>]...` | Satu pertanyaan yang boleh berakhir dengan aksi; lampirkan file/folder sebagai konteks terseleksi. Ringkasan hemat token ditulis ke stderr, jawaban bersih di stdout, ditutup baris info model + token terpakai |
|
|
65
|
+
| `vibekit chat` | Percakapan interaktif yang juga bisa bertindak di tengah obrolan; riwayat dipangkas otomatis dan sadar-tool (maks ±4.000 token, hanya pada batas giliran utuh); `/keluar` untuk berhenti |
|
|
66
|
+
| `vibekit do "<tugas>"` | Mode agent sekali jalan: model mengerjakan tugas nyata dengan tool file/perintah di folder saat ini (lihat bagian Mode Agent di bawah) |
|
|
66
67
|
| `vibekit status` | Tabel per penyedia: model, status (aktif / cooldown s.d. jam / anggaran tercapai), token terpakai hari ini |
|
|
67
68
|
| `vibekit --help` / `--version` | Bantuan / versi |
|
|
68
69
|
|
|
70
|
+
## Mode Agent — `ask`, `chat`, dan `do`
|
|
71
|
+
|
|
72
|
+
Selain menjawab pertanyaan, VibeKit bisa **bertindak** di folder proyek Anda. Sejak v0.3.0 kemampuan agent ini menyatu ke semua perintah — pertanyaan biasa dijawab teks seperti biasa, sedangkan permintaan membuat file atau menjalankan perintah dikerjakan lewat tool dengan pagar keamanan yang sama:
|
|
73
|
+
|
|
74
|
+
- **`vibekit ask`** — satu pertanyaan yang boleh berakhir dengan aksi: `vibekit ask "buatkan file halo.txt berisi sapaan"`.
|
|
75
|
+
- **`vibekit chat`** — ngobrol yang bisa bertindak di tengah percakapan: minta dibuatkan file di giliran mana pun, model memakai tool lalu melanjutkan obrolan dengan riwayat yang utuh (termasuk rangkaian tool-nya).
|
|
76
|
+
- **`vibekit do`** — mode tugas sekali jalan: jejak langkah dicetak, ditutup ringkasan akhir, dan kode keluar mencerminkan berhasil/tidaknya tugas:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
vibekit do "buatkan file halo.txt berisi sapaan, lalu tampilkan daftar file di sini"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Model bekerja langkah demi langkah lewat tool calling (API OpenAI-compatible yang sama, failover router yang sama), sampai ia membalas teks akhir. Ada **5 tool**:
|
|
83
|
+
|
|
84
|
+
| Tool | Fungsi | Konfirmasi? |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `daftar_file` | Daftar isi folder | Tidak (baca-saja) |
|
|
87
|
+
| `baca_file` | Baca isi file teks | Tidak (baca-saja) |
|
|
88
|
+
| `tulis_file` | Buat/timpa file (folder induk dibuat bila perlu) | **Ya, satu per satu** |
|
|
89
|
+
| `edit_file` | Ganti kemunculan pertama `teksLama` → `teksBaru` dalam file | **Ya, satu per satu** |
|
|
90
|
+
| `jalankan_perintah` | Jalankan perintah shell (timeout 60 detik, keluaran ditangkap) | **Ya, satu per satu** |
|
|
91
|
+
|
|
92
|
+
**Model keamanan:**
|
|
93
|
+
|
|
94
|
+
- **Hanya folder kerja (cwd).** Semua path di-resolve terhadap folder tempat perintah dijalankan; path yang keluar dari folder itu (mis. `../`) ditolak eksekutor, bahkan bila pengguna menyetujui.
|
|
95
|
+
- **Konfirmasi per aksi.** Setiap aksi yang mengubah file atau menjalankan perintah menampilkan deskripsi aksinya (path + pratinjau konten untuk tulis, potongan teks lama→baru untuk edit, perintah lengkap untuk shell) dan hanya berjalan bila Anda menjawab `y`/`ya` — selain itu dianggap menolak, dan penolakan itu dilaporkan ke model agar ia menyesuaikan rencana.
|
|
96
|
+
- **Batas 12 langkah** per giliran/tugas, dan hasil tiap tool dipangkas (±4.000 karakter) sebelum dikirim balik ke model demi hemat token.
|
|
97
|
+
- **Riwayat chat yang aman untuk tool.** Pemangkasan riwayat chat hanya terjadi pada batas giliran utuh (satu pesan pengguna + seluruh rangkaian tool-nya), sehingga tidak pernah ada pesan hasil tool yang yatim atau panggilan tool yang kehilangan hasilnya.
|
|
98
|
+
|
|
69
99
|
## Cara Failover Bekerja
|
|
70
100
|
|
|
71
101
|
1. Sebelum memanggil, router memeriksa state lokal (`~/.vibekit/state.json`): penyedia yang masih **cooldown** atau sudah mencapai **anggaran harian** dilewati tanpa dipanggil.
|
|
@@ -93,7 +123,7 @@ Keputusan repo: `dist/` masuk `.gitignore` dan dibangun saat publish (`prepublis
|
|
|
93
123
|
|
|
94
124
|
## Status Nama di npm
|
|
95
125
|
|
|
96
|
-
Package ini terbit di npm sebagai **`@chispear/vibekit`** (v0.1.0, 25 Oktober 2026) karena nama tak-scoped `vibekit` sudah dipakai proyek lain. Nama perintah CLI tetap `vibekit`.
|
|
126
|
+
Package ini terbit di npm sebagai **`@chispear/vibekit`** (v0.1.0, 25 Oktober 2026; v0.2.0 menambahkan mode agent `vibekit do`; v0.3.0 menggabungkan kemampuan agent itu ke `ask` dan `chat`) karena nama tak-scoped `vibekit` sudah dipakai proyek lain. Nama perintah CLI tetap `vibekit`.
|
|
97
127
|
|
|
98
128
|
## Lisensi
|
|
99
129
|
|
package/dist/agent.js
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tanyaRouter } from "./router.js";
|
|
5
|
+
import { hitungToken } from "./token.js";
|
|
6
|
+
/** Hasil tool dipangkas sampai ±karakter ini sebelum dikirim balik ke model (hemat token). */
|
|
7
|
+
const BATAS_HASIL_TOOL = 4000;
|
|
8
|
+
/** Batas waktu jalankan_perintah. */
|
|
9
|
+
const TIMEOUT_PERINTAH_MS = 60_000;
|
|
10
|
+
/** Batas langkah agent bawaan. */
|
|
11
|
+
export const MAKS_LANGKAH_BAWAAN = 12;
|
|
12
|
+
const SKEMA_PATH = {
|
|
13
|
+
type: "string",
|
|
14
|
+
description: "Path relatif terhadap folder kerja (cwd). Tidak boleh keluar dari folder kerja.",
|
|
15
|
+
};
|
|
16
|
+
/** Definisi 5 tool mode agent (format function calling OpenAI, skema ketat). */
|
|
17
|
+
export const DEFINISI_TOOL = [
|
|
18
|
+
{
|
|
19
|
+
type: "function",
|
|
20
|
+
function: {
|
|
21
|
+
name: "daftar_file",
|
|
22
|
+
description: "Daftar isi sebuah folder di dalam folder kerja. Baca-saja, tanpa konfirmasi.",
|
|
23
|
+
parameters: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
path: { ...SKEMA_PATH, description: "Path folder relatif; kosong atau '.' berarti folder kerja itu sendiri." },
|
|
27
|
+
},
|
|
28
|
+
required: [],
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
type: "function",
|
|
35
|
+
function: {
|
|
36
|
+
name: "baca_file",
|
|
37
|
+
description: "Baca isi sebuah file teks di dalam folder kerja. Baca-saja, tanpa konfirmasi.",
|
|
38
|
+
parameters: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: { path: SKEMA_PATH },
|
|
41
|
+
required: ["path"],
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: "function",
|
|
48
|
+
function: {
|
|
49
|
+
name: "tulis_file",
|
|
50
|
+
description: "Buat file baru atau timpa file yang ada dengan konten yang diberikan (folder induk dibuat bila perlu). Selalu meminta konfirmasi pengguna.",
|
|
51
|
+
parameters: {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: {
|
|
54
|
+
path: SKEMA_PATH,
|
|
55
|
+
konten: { type: "string", description: "Seluruh isi file yang akan ditulis." },
|
|
56
|
+
},
|
|
57
|
+
required: ["path", "konten"],
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: "function",
|
|
64
|
+
function: {
|
|
65
|
+
name: "edit_file",
|
|
66
|
+
description: "Ganti kemunculan PERTAMA teksLama dengan teksBaru di dalam file. Error bila teksLama tidak ditemukan. Selalu meminta konfirmasi pengguna.",
|
|
67
|
+
parameters: {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: {
|
|
70
|
+
path: SKEMA_PATH,
|
|
71
|
+
teksLama: { type: "string", description: "Teks persis yang dicari (harus muncul minimal sekali)." },
|
|
72
|
+
teksBaru: { type: "string", description: "Teks pengganti." },
|
|
73
|
+
},
|
|
74
|
+
required: ["path", "teksLama", "teksBaru"],
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
type: "function",
|
|
81
|
+
function: {
|
|
82
|
+
name: "jalankan_perintah",
|
|
83
|
+
description: "Jalankan sebuah perintah shell di folder kerja (timeout 60 detik) dan kembalikan stdout+stderr. Selalu meminta konfirmasi pengguna.",
|
|
84
|
+
parameters: {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
perintah: { type: "string", description: "Perintah shell lengkap yang akan dijalankan di folder kerja." },
|
|
88
|
+
},
|
|
89
|
+
required: ["perintah"],
|
|
90
|
+
additionalProperties: false,
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
const SYSTEM_PROMPT_AGENT = [
|
|
96
|
+
"Kamu adalah agent pelaksana tugas VibeKit yang bekerja di dalam SATU folder kerja pengguna.",
|
|
97
|
+
"Kerjakan tugas langkah demi langkah memakai tool yang tersedia: daftar_file, baca_file, tulis_file, edit_file, jalankan_perintah.",
|
|
98
|
+
"Semua path bersifat relatif terhadap folder kerja; jangan pernah mencoba mengakses apa pun di luar folder itu.",
|
|
99
|
+
"Aksi tulis_file, edit_file, dan jalankan_perintah selalu meminta persetujuan pengguna dulu; bila pengguna menolak, jangan memaksa dan jangan mengulang aksi yang sama — sesuaikan rencana atau akhiri tugas.",
|
|
100
|
+
"Jangan mengarang isi file: gunakan daftar_file/baca_file dulu bila perlu mengetahui isi folder/file.",
|
|
101
|
+
"Bila tugas selesai (atau tidak bisa dilanjutkan), balas TANPA memanggil tool, berisi ringkasan akhir yang singkat dalam poin-poin penting: apa yang dilakukan dan hasilnya.",
|
|
102
|
+
].join(" ");
|
|
103
|
+
/**
|
|
104
|
+
* System prompt gabungan untuk `ask` dan `chat` (sejak v0.3.0): model adalah
|
|
105
|
+
* lawan bicara yang menjawab pertanyaan biasa langsung sebagai teks, tetapi
|
|
106
|
+
* juga bisa bertindak memakai tool yang sama seperti mode agent bila diminta.
|
|
107
|
+
*/
|
|
108
|
+
export const SYSTEM_PROMPT_GABUNGAN = [
|
|
109
|
+
"Kamu adalah lawan bicara VibeKit yang bekerja di dalam SATU folder kerja pengguna.",
|
|
110
|
+
"Jawab pertanyaan biasa langsung sebagai teks, singkat dan hanya poin-poin penting.",
|
|
111
|
+
"Bila pengguna meminta dibuatkan/diubah file atau menjalankan perintah, gunakan tool yang tersedia: daftar_file, baca_file, tulis_file, edit_file, jalankan_perintah.",
|
|
112
|
+
"Semua path bersifat relatif terhadap folder kerja; jangan pernah mencoba mengakses apa pun di luar folder itu.",
|
|
113
|
+
"Aksi tulis_file, edit_file, dan jalankan_perintah selalu meminta persetujuan pengguna dulu; bila pengguna menolak, jangan memaksa dan jangan mengulang aksi yang sama.",
|
|
114
|
+
"Jangan mengarang isi file: gunakan daftar_file/baca_file dulu bila perlu mengetahui isi folder/file.",
|
|
115
|
+
"Setelah bertindak, tutup giliran dengan balasan teks (tanpa tool) berisi ringkasan singkat: apa yang dilakukan dan hasilnya.",
|
|
116
|
+
].join(" ");
|
|
117
|
+
/**
|
|
118
|
+
* Resolve path masukan terhadap cwd. Mengembalikan null bila hasil resolve
|
|
119
|
+
* keluar dari cwd (path traversal) — kunci keamanan mode agent.
|
|
120
|
+
*/
|
|
121
|
+
function resolusiAman(cwd, masukan) {
|
|
122
|
+
const dasar = path.resolve(cwd);
|
|
123
|
+
const tujuan = path.resolve(dasar, masukan);
|
|
124
|
+
if (tujuan === dasar)
|
|
125
|
+
return tujuan;
|
|
126
|
+
return tujuan.startsWith(dasar + path.sep) ? tujuan : null;
|
|
127
|
+
}
|
|
128
|
+
function pathRelatif(cwd, tujuan) {
|
|
129
|
+
const rel = path.relative(path.resolve(cwd), tujuan);
|
|
130
|
+
return rel === "" ? "." : rel;
|
|
131
|
+
}
|
|
132
|
+
function potong(teks, maks) {
|
|
133
|
+
return teks.length > maks ? teks.slice(0, maks) + "…" : teks;
|
|
134
|
+
}
|
|
135
|
+
/** Pangkas hasil tool sampai ±BATAS_HASIL_TOOL karakter sebelum dikirim ke model. */
|
|
136
|
+
function potongHasilTool(teks) {
|
|
137
|
+
if (teks.length <= BATAS_HASIL_TOOL)
|
|
138
|
+
return teks;
|
|
139
|
+
return teks.slice(0, BATAS_HASIL_TOOL) + `\n…(hasil dipotong demi hemat token; total ${teks.length} karakter)`;
|
|
140
|
+
}
|
|
141
|
+
/** Ringkasan argumen satu baris untuk jejak langkah yang dicetak CLI. */
|
|
142
|
+
function argumenRingkas(nama, arg) {
|
|
143
|
+
switch (nama) {
|
|
144
|
+
case "jalankan_perintah":
|
|
145
|
+
return potong(String(arg.perintah ?? ""), 80);
|
|
146
|
+
case "tulis_file":
|
|
147
|
+
case "edit_file":
|
|
148
|
+
case "baca_file":
|
|
149
|
+
case "daftar_file":
|
|
150
|
+
return String(arg.path ?? ".");
|
|
151
|
+
default:
|
|
152
|
+
return potong(JSON.stringify(arg), 80);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Jalankan satu panggilan tool. Selalu mengembalikan teks hasil untuk model
|
|
157
|
+
* (tidak pernah throw): keberhasilan diawali "OK:", kegagalan "ERROR:",
|
|
158
|
+
* penolakan pengguna "DITOLAK:".
|
|
159
|
+
*/
|
|
160
|
+
export async function eksekusiTool(panggilan, konteks) {
|
|
161
|
+
const nama = panggilan.function.name;
|
|
162
|
+
let arg;
|
|
163
|
+
try {
|
|
164
|
+
arg = JSON.parse(panggilan.function.arguments || "{}");
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return `ERROR: argumen untuk tool ${nama} bukan JSON yang valid.`;
|
|
168
|
+
}
|
|
169
|
+
const { cwd, konfirmasi } = konteks;
|
|
170
|
+
const ambilPath = (mentah) => {
|
|
171
|
+
const masukan = typeof mentah === "string" && mentah.trim() !== "" ? mentah : ".";
|
|
172
|
+
const tujuan = resolusiAman(cwd, masukan);
|
|
173
|
+
if (!tujuan) {
|
|
174
|
+
return { error: `ERROR: path "${masukan}" berada di luar folder kerja — akses ditolak demi keamanan.` };
|
|
175
|
+
}
|
|
176
|
+
return { tujuan, rel: pathRelatif(cwd, tujuan) };
|
|
177
|
+
};
|
|
178
|
+
try {
|
|
179
|
+
switch (nama) {
|
|
180
|
+
case "daftar_file": {
|
|
181
|
+
const p = ambilPath(arg.path);
|
|
182
|
+
if ("error" in p)
|
|
183
|
+
return p.error;
|
|
184
|
+
const entri = await fs.readdir(p.tujuan, { withFileTypes: true });
|
|
185
|
+
if (entri.length === 0)
|
|
186
|
+
return `OK: folder ${p.rel} kosong.`;
|
|
187
|
+
const baris = entri
|
|
188
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
189
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
|
|
190
|
+
return `OK: isi folder ${p.rel}:\n${baris.join("\n")}`;
|
|
191
|
+
}
|
|
192
|
+
case "baca_file": {
|
|
193
|
+
const p = ambilPath(arg.path);
|
|
194
|
+
if ("error" in p)
|
|
195
|
+
return p.error;
|
|
196
|
+
const isi = await fs.readFile(p.tujuan, "utf8");
|
|
197
|
+
return `OK: isi file ${p.rel}:\n${isi}`;
|
|
198
|
+
}
|
|
199
|
+
case "tulis_file": {
|
|
200
|
+
const p = ambilPath(arg.path);
|
|
201
|
+
if ("error" in p)
|
|
202
|
+
return p.error;
|
|
203
|
+
const konten = typeof arg.konten === "string" ? arg.konten : "";
|
|
204
|
+
const deskripsi = `tulis_file → ${p.rel} (${konten.length} karakter)\n` +
|
|
205
|
+
`Pratinjau konten:\n${potong(konten, 300)}`;
|
|
206
|
+
if (!(await konfirmasi(deskripsi))) {
|
|
207
|
+
return `DITOLAK: pengguna menolak aksi tulis_file ke ${p.rel}. File tidak ditulis. Jangan mengulang aksi yang sama; sesuaikan rencana atau akhiri dengan ringkasan.`;
|
|
208
|
+
}
|
|
209
|
+
await fs.mkdir(path.dirname(p.tujuan), { recursive: true });
|
|
210
|
+
await fs.writeFile(p.tujuan, konten, "utf8");
|
|
211
|
+
return `OK: file ${p.rel} ditulis (${konten.length} karakter).`;
|
|
212
|
+
}
|
|
213
|
+
case "edit_file": {
|
|
214
|
+
const p = ambilPath(arg.path);
|
|
215
|
+
if ("error" in p)
|
|
216
|
+
return p.error;
|
|
217
|
+
const teksLama = typeof arg.teksLama === "string" ? arg.teksLama : "";
|
|
218
|
+
const teksBaru = typeof arg.teksBaru === "string" ? arg.teksBaru : "";
|
|
219
|
+
const isi = await fs.readFile(p.tujuan, "utf8");
|
|
220
|
+
const idx = isi.indexOf(teksLama);
|
|
221
|
+
if (idx < 0) {
|
|
222
|
+
return `ERROR: teksLama tidak ditemukan di ${p.rel}; tidak ada yang diubah. Baca file lagi dan salin teks persisnya.`;
|
|
223
|
+
}
|
|
224
|
+
const deskripsi = `edit_file → ${p.rel}\n` +
|
|
225
|
+
`Teks lama: ${potong(teksLama, 150)}\n` +
|
|
226
|
+
`Teks baru: ${potong(teksBaru, 150)}`;
|
|
227
|
+
if (!(await konfirmasi(deskripsi))) {
|
|
228
|
+
return `DITOLAK: pengguna menolak aksi edit_file di ${p.rel}. File tidak diubah. Jangan mengulang aksi yang sama; sesuaikan rencana atau akhiri dengan ringkasan.`;
|
|
229
|
+
}
|
|
230
|
+
const baru = isi.slice(0, idx) + teksBaru + isi.slice(idx + teksLama.length);
|
|
231
|
+
await fs.writeFile(p.tujuan, baru, "utf8");
|
|
232
|
+
return `OK: ${p.rel} diubah (kemunculan pertama teksLama diganti teksBaru).`;
|
|
233
|
+
}
|
|
234
|
+
case "jalankan_perintah": {
|
|
235
|
+
const perintah = typeof arg.perintah === "string" ? arg.perintah.trim() : "";
|
|
236
|
+
if (!perintah)
|
|
237
|
+
return "ERROR: argumen perintah kosong.";
|
|
238
|
+
const deskripsi = `jalankan_perintah → ${perintah}`;
|
|
239
|
+
if (!(await konfirmasi(deskripsi))) {
|
|
240
|
+
return `DITOLAK: pengguna menolak menjalankan perintah "${perintah}". Perintah tidak dijalankan. Jangan mengulang aksi yang sama; sesuaikan rencana atau akhiri dengan ringkasan.`;
|
|
241
|
+
}
|
|
242
|
+
const hasil = await new Promise((resolve) => {
|
|
243
|
+
exec(perintah, { cwd: path.resolve(cwd), timeout: TIMEOUT_PERINTAH_MS, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
244
|
+
if (err) {
|
|
245
|
+
const e = err;
|
|
246
|
+
resolve({
|
|
247
|
+
kode: typeof e.code === "number" ? e.code : null,
|
|
248
|
+
stdout: String(stdout ?? ""),
|
|
249
|
+
stderr: String(stderr ?? ""),
|
|
250
|
+
timeout: e.killed === true,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
resolve({ kode: 0, stdout: String(stdout), stderr: String(stderr), timeout: false });
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
const bagian = [`kode keluar: ${hasil.kode ?? "?"}${hasil.timeout ? " (timeout 60 detik, perintah dihentikan)" : ""}`];
|
|
259
|
+
if (hasil.stdout.trim())
|
|
260
|
+
bagian.push(`stdout:\n${hasil.stdout.trimEnd()}`);
|
|
261
|
+
if (hasil.stderr.trim())
|
|
262
|
+
bagian.push(`stderr:\n${hasil.stderr.trimEnd()}`);
|
|
263
|
+
return (hasil.kode === 0 ? "OK: " : "ERROR: ") + bagian.join("\n");
|
|
264
|
+
}
|
|
265
|
+
default:
|
|
266
|
+
return `ERROR: tool tidak dikenal: ${nama}. Tool yang tersedia: daftar_file, baca_file, tulis_file, edit_file, jalankan_perintah.`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
return `ERROR: ${e.message}`;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Inti loop agent untuk SATU giliran: panggil router (dengan tools) → jalankan
|
|
275
|
+
* tool_calls yang diminta model lewat eksekutor aman → kirim hasil tool balik
|
|
276
|
+
* → ulangi sampai model membalas teks tanpa tool_calls atau langkah habis.
|
|
277
|
+
* Dipakai bersama oleh `do` (lewat jalankanAgent), `ask`, dan `chat`.
|
|
278
|
+
*
|
|
279
|
+
* Catatan kegagalan: bila router gagal di tengah giliran, `messages` mungkin
|
|
280
|
+
* sudah memuat rangkaian parsial (assistant tool_calls + hasil tool tanpa teks
|
|
281
|
+
* akhir). Pemanggil yang menyimpan riwayat (chat) sebaiknya menggulung balik
|
|
282
|
+
* giliran itu; pemanggil sekali jalan (ask/do) bisa mengabaikannya.
|
|
283
|
+
*/
|
|
284
|
+
export async function jalankanGiliran(opsi) {
|
|
285
|
+
const maksLangkah = opsi.maksLangkah ?? MAKS_LANGKAH_BAWAAN;
|
|
286
|
+
const messages = opsi.messages;
|
|
287
|
+
const catatan = [];
|
|
288
|
+
let totalToken = 0;
|
|
289
|
+
let semuaUsage = true;
|
|
290
|
+
let providerTerakhir = null;
|
|
291
|
+
const sumber = () => (semuaUsage ? "usage" : "estimasi");
|
|
292
|
+
let langkah = 0;
|
|
293
|
+
while (langkah < maksLangkah) {
|
|
294
|
+
langkah++;
|
|
295
|
+
const hasil = await tanyaRouter({
|
|
296
|
+
config: opsi.config,
|
|
297
|
+
messages,
|
|
298
|
+
lokasiState: opsi.lokasiState,
|
|
299
|
+
fetchImpl: opsi.fetchImpl,
|
|
300
|
+
tools: DEFINISI_TOOL,
|
|
301
|
+
});
|
|
302
|
+
catatan.push(...hasil.catatan);
|
|
303
|
+
if (!hasil.ok) {
|
|
304
|
+
return {
|
|
305
|
+
ok: false,
|
|
306
|
+
jawabanAkhir: "",
|
|
307
|
+
providerTerakhir,
|
|
308
|
+
totalToken,
|
|
309
|
+
tokenSumber: sumber(),
|
|
310
|
+
jumlahLangkah: langkah,
|
|
311
|
+
catatan,
|
|
312
|
+
pesanGagal: hasil.pesan,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
totalToken += hasil.tokenTerpakai;
|
|
316
|
+
if (hasil.tokenSumber !== "usage")
|
|
317
|
+
semuaUsage = false;
|
|
318
|
+
providerTerakhir = hasil.provider;
|
|
319
|
+
const panggilan = hasil.toolCalls ?? [];
|
|
320
|
+
if (panggilan.length === 0) {
|
|
321
|
+
// Balasan teks murni = jawaban akhir giliran; simpan ke riwayat agar utuh.
|
|
322
|
+
messages.push({ role: "assistant", content: hasil.jawaban });
|
|
323
|
+
return {
|
|
324
|
+
ok: true,
|
|
325
|
+
jawabanAkhir: hasil.jawaban,
|
|
326
|
+
providerTerakhir,
|
|
327
|
+
totalToken,
|
|
328
|
+
tokenSumber: sumber(),
|
|
329
|
+
jumlahLangkah: langkah,
|
|
330
|
+
catatan,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (hasil.pesanMentah && typeof hasil.pesanMentah === "object") {
|
|
334
|
+
// Teruskan pesan assistant persis seperti yang dikirim penyedia. Gemini 3
|
|
335
|
+
// menyisipkan thought_signature di dalam tool_calls (extra_content) dan
|
|
336
|
+
// menolak permintaan berikutnya dengan HTTP 400 bila tanda tangan itu
|
|
337
|
+
// hilang dari riwayat — jadi pesan mentah tidak boleh dibangun ulang.
|
|
338
|
+
messages.push(hasil.pesanMentah);
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
messages.push({
|
|
342
|
+
role: "assistant",
|
|
343
|
+
content: hasil.jawaban ? hasil.jawaban : null,
|
|
344
|
+
tool_calls: panggilan,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
for (const p of panggilan) {
|
|
348
|
+
let argRingkas = "";
|
|
349
|
+
try {
|
|
350
|
+
argRingkas = argumenRingkas(p.function.name, JSON.parse(p.function.arguments || "{}"));
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
argRingkas = "";
|
|
354
|
+
}
|
|
355
|
+
opsi.jejak?.(`langkah ${langkah}: ${p.function.name}${argRingkas ? ` (${argRingkas})` : ""}`);
|
|
356
|
+
const hasilTool = await eksekusiTool(p, { cwd: opsi.cwd, konfirmasi: opsi.konfirmasi });
|
|
357
|
+
opsi.jejak?.(` → ${potong(hasilTool.split("\n")[0], 120)}`);
|
|
358
|
+
messages.push({
|
|
359
|
+
role: "tool",
|
|
360
|
+
tool_call_id: p.id,
|
|
361
|
+
name: p.function.name,
|
|
362
|
+
content: potongHasilTool(hasilTool),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
ok: false,
|
|
368
|
+
jawabanAkhir: "",
|
|
369
|
+
providerTerakhir,
|
|
370
|
+
totalToken,
|
|
371
|
+
tokenSumber: sumber(),
|
|
372
|
+
jumlahLangkah: langkah,
|
|
373
|
+
catatan,
|
|
374
|
+
batasTercapai: true,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/** Perkiraan token satu pesan: konten teks ditambah argumen tool_calls (bila ada). */
|
|
378
|
+
function tokenPesan(m) {
|
|
379
|
+
let teks = m.content ?? "";
|
|
380
|
+
if (m.tool_calls && m.tool_calls.length > 0)
|
|
381
|
+
teks += "\n" + JSON.stringify(m.tool_calls);
|
|
382
|
+
return hitungToken(teks);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Pangkas riwayat percakapan secara SADAR-TOOL: pemotongan hanya terjadi pada
|
|
386
|
+
* batas giliran utuh. Satu giliran = satu pesan "user" beserta seluruh
|
|
387
|
+
* rangkaian sesudahnya sampai sebelum "user" berikutnya (assistant tool_calls,
|
|
388
|
+
* hasil-hasil tool, assistant teks akhir). Fungsi ini TIDAK PERNAH menyisakan
|
|
389
|
+
* pesan "tool" yatim atau assistant tool_calls tanpa hasil tool-nya.
|
|
390
|
+
*
|
|
391
|
+
* Awalan pesan "system" (bila ada) selalu dipertahankan karena bukan bagian
|
|
392
|
+
* giliran mana pun. Giliran terakhir selalu dipertahankan utuh, jadi hasil
|
|
393
|
+
* boleh tetap melebihi maksToken bila satu giliran terakhir saja sudah
|
|
394
|
+
* sebesar itu. Tidak mengubah array masukan; mengembalikan array baru.
|
|
395
|
+
*/
|
|
396
|
+
export function pangkasRiwayatAman(messages, maksToken) {
|
|
397
|
+
let awal = 0;
|
|
398
|
+
while (awal < messages.length && messages[awal].role === "system")
|
|
399
|
+
awal++;
|
|
400
|
+
const awalan = messages.slice(0, awal);
|
|
401
|
+
const sisa = messages.slice(awal);
|
|
402
|
+
// Kelompokkan sisa menjadi giliran-giliran: grup baru dimulai di setiap
|
|
403
|
+
// pesan "user"; pesan sebelum "user" pertama membentuk grupnya sendiri.
|
|
404
|
+
const grup = [];
|
|
405
|
+
for (const m of sisa) {
|
|
406
|
+
if (m.role === "user" || grup.length === 0)
|
|
407
|
+
grup.push([m]);
|
|
408
|
+
else
|
|
409
|
+
grup[grup.length - 1].push(m);
|
|
410
|
+
}
|
|
411
|
+
const tokenGrup = (g) => g.reduce((s, m) => s + tokenPesan(m), 0);
|
|
412
|
+
let total = awalan.reduce((s, m) => s + tokenPesan(m), 0) + grup.reduce((s, g) => s + tokenGrup(g), 0);
|
|
413
|
+
// Buang giliran tertua secara utuh sampai muat; giliran terakhir tak dibuang.
|
|
414
|
+
while (total > maksToken && grup.length > 1) {
|
|
415
|
+
total -= tokenGrup(grup.shift());
|
|
416
|
+
}
|
|
417
|
+
return [...awalan, ...grup.flat()];
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Mode tugas sekali jalan (`vibekit do`): satu giliran agent dengan system
|
|
421
|
+
* prompt pelaksana tugas, dibangun di atas jalankanGiliran. Perilaku dan
|
|
422
|
+
* bentuk HasilAgent sama seperti sebelum refaktor v0.3.0.
|
|
423
|
+
*/
|
|
424
|
+
export async function jalankanAgent(opsi) {
|
|
425
|
+
const maksLangkah = opsi.maksLangkah ?? MAKS_LANGKAH_BAWAAN;
|
|
426
|
+
const messages = [
|
|
427
|
+
{ role: "system", content: SYSTEM_PROMPT_AGENT },
|
|
428
|
+
{ role: "user", content: opsi.tugas },
|
|
429
|
+
];
|
|
430
|
+
const hasil = await jalankanGiliran({
|
|
431
|
+
config: opsi.config,
|
|
432
|
+
messages,
|
|
433
|
+
lokasiState: opsi.lokasiState,
|
|
434
|
+
cwd: opsi.cwd,
|
|
435
|
+
konfirmasi: opsi.konfirmasi,
|
|
436
|
+
fetchImpl: opsi.fetchImpl,
|
|
437
|
+
maksLangkah: opsi.maksLangkah,
|
|
438
|
+
jejak: opsi.jejak,
|
|
439
|
+
});
|
|
440
|
+
if (hasil.pesanGagal !== undefined) {
|
|
441
|
+
return { ok: false, ringkasan: hasil.pesanGagal, jumlahLangkah: hasil.jumlahLangkah, catatan: hasil.catatan };
|
|
442
|
+
}
|
|
443
|
+
if (hasil.batasTercapai) {
|
|
444
|
+
return {
|
|
445
|
+
ok: false,
|
|
446
|
+
ringkasan: `Batas langkah tercapai (${maksLangkah} langkah) sebelum tugas selesai. ` +
|
|
447
|
+
"Jalankan lagi perintah yang sama untuk melanjutkan, atau persempit tugasnya.",
|
|
448
|
+
jumlahLangkah: hasil.jumlahLangkah,
|
|
449
|
+
catatan: hasil.catatan,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
return { ok: true, ringkasan: hasil.jawabanAkhir, jumlahLangkah: hasil.jumlahLangkah, catatan: hasil.catatan };
|
|
453
|
+
}
|
package/dist/banner.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Banner CHISPEAR hijau besar — ditampilkan saat `vibekit init` pertama kali
|
|
2
|
+
// (momen "berhasil terpasang & diatur" yang pasti terlihat pengguna, karena
|
|
3
|
+
// keluaran skrip postinstall npm tidak selalu ditampilkan npm).
|
|
4
|
+
// CATATAN: scripts/banner.js adalah salinan mandiri dari seni ini untuk
|
|
5
|
+
// postinstall — jaga keduanya tetap sinkron.
|
|
6
|
+
const GLIF = {
|
|
7
|
+
C: [" █████ ", "██ ██", "██ ", "██ ", "██ ██", " █████ "],
|
|
8
|
+
H: ["██ ██", "██ ██", "███████", "██ ██", "██ ██", "██ ██"],
|
|
9
|
+
I: ["███", " █ ", " █ ", " █ ", " █ ", "███"],
|
|
10
|
+
S: ["███████", "██ ", "██████ ", " ██", " ██", "██████ "],
|
|
11
|
+
P: ["██████ ", "██ ██", "██████ ", "██ ", "██ ", "██ "],
|
|
12
|
+
E: ["███████", "██ ", "█████ ", "█████ ", "██ ", "███████"],
|
|
13
|
+
A: [" █████ ", "██ ██", "███████", "██ ██", "██ ██", "██ ██"],
|
|
14
|
+
R: ["██████ ", "██ ██", "██████ ", "██ ██ ", "██ ██", "██ ██"],
|
|
15
|
+
};
|
|
16
|
+
/** Teks banner CHISPEAR (hijau terang bila NO_COLOR tidak diset). */
|
|
17
|
+
export function teksBanner() {
|
|
18
|
+
const baris = ["", "", "", "", "", ""];
|
|
19
|
+
for (const huruf of "CHISPEAR") {
|
|
20
|
+
const g = GLIF[huruf];
|
|
21
|
+
if (!g)
|
|
22
|
+
continue;
|
|
23
|
+
for (let i = 0; i < 6; i++)
|
|
24
|
+
baris[i] += (baris[i] ? " " : "") + g[i];
|
|
25
|
+
}
|
|
26
|
+
const pakaiWarna = !process.env.NO_COLOR;
|
|
27
|
+
const HIJAU = pakaiWarna ? "\x1b[1;92m" : "";
|
|
28
|
+
const RESET = pakaiWarna ? "\x1b[0m" : "";
|
|
29
|
+
return [
|
|
30
|
+
"",
|
|
31
|
+
...baris.map((b) => HIJAU + b + RESET),
|
|
32
|
+
"",
|
|
33
|
+
HIJAU + " VibeKit CLI terpasang — router AI hemat tokenmu siap dipakai." + RESET,
|
|
34
|
+
"",
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import readline from "node:readline";
|
|
3
3
|
import { buatConfigGlobal, globalConfigPath, muatConfig, statePath, } from "./config.js";
|
|
4
|
+
import { jalankanAgent, jalankanGiliran, pangkasRiwayatAman, SYSTEM_PROMPT_GABUNGAN, } from "./agent.js";
|
|
5
|
+
import { teksBanner } from "./banner.js";
|
|
4
6
|
import { bangunKonteks } from "./context.js";
|
|
5
|
-
import { tanyaRouter } from "./router.js";
|
|
6
7
|
import { formatJam, muatState, sedangCooldown, statePenyedia, } from "./state.js";
|
|
7
|
-
import { formatAngka
|
|
8
|
-
const VERSI = "0.
|
|
8
|
+
import { formatAngka } from "./token.js";
|
|
9
|
+
const VERSI = "0.3.0";
|
|
9
10
|
const BATAS_TOKEN_RIWAYAT = 4000;
|
|
10
11
|
function bantuan() {
|
|
11
12
|
console.log(`VibeKit CLI v${VERSI} — router AI hemat token dengan failover otomatis
|
|
12
13
|
|
|
13
14
|
Penggunaan:
|
|
14
15
|
vibekit init Buat ~/.vibekit/config.json dari template
|
|
15
|
-
vibekit ask "<pertanyaan>" [opsi] Tanya sekali
|
|
16
|
-
vibekit chat Percakapan interaktif (perintah /keluar untuk berhenti)
|
|
16
|
+
vibekit ask "<pertanyaan>" [opsi] Tanya sekali (bisa bertindak: membuat file & menjalankan perintah dengan persetujuanmu)
|
|
17
|
+
vibekit chat Percakapan interaktif yang juga bisa bertindak (perintah /keluar untuk berhenti)
|
|
18
|
+
vibekit do "<tugas>" Mode agent sekali jalan: kerjakan tugas dengan tool file/perintah di folder ini
|
|
17
19
|
vibekit status Status & pemakaian token tiap penyedia hari ini
|
|
18
20
|
vibekit --help Tampilkan bantuan ini
|
|
19
21
|
vibekit --version Tampilkan versi
|
|
@@ -22,6 +24,18 @@ Opsi ask:
|
|
|
22
24
|
--file <path> Lampirkan file sebagai konteks (boleh diulang)
|
|
23
25
|
--dir <path> Lampirkan isi folder sebagai konteks (boleh diulang)
|
|
24
26
|
|
|
27
|
+
Kemampuan agent (ask, chat, do):
|
|
28
|
+
Sejak v0.3.0 ketiga perintah itu bisa bertindak, bukan hanya menjawab:
|
|
29
|
+
bila kamu meminta dibuatkan/diubah file atau menjalankan perintah, model
|
|
30
|
+
memakai tool di folder tempat perintah dijalankan. Tool baca (daftar_file,
|
|
31
|
+
baca_file) berjalan langsung; tool yang mengubah sesuatu (tulis_file,
|
|
32
|
+
edit_file, jalankan_perintah) selalu meminta persetujuanmu satu per satu
|
|
33
|
+
— jawaban selain y/ya berarti ditolak. Batas 12 langkah per giliran.
|
|
34
|
+
Perbedaannya: ask = satu pertanyaan yang boleh berakhir dengan aksi,
|
|
35
|
+
chat = percakapan yang bisa bertindak di tengah obrolan,
|
|
36
|
+
do = mode tugas sekali jalan dengan jejak langkah & ringkasan akhir.
|
|
37
|
+
Contoh: vibekit do "buatkan file halo.txt berisi sapaan, lalu tampilkan daftar file di sini"
|
|
38
|
+
|
|
25
39
|
Cara kerja singkat:
|
|
26
40
|
Penyedia dicoba sesuai urutan di konfigurasi. Bila kuota penyedia terdeteksi
|
|
27
41
|
habis, permintaan otomatis diulang ke penyedia berikutnya. Setiap jawaban
|
|
@@ -32,6 +46,7 @@ Cara kerja singkat:
|
|
|
32
46
|
function perintahInit() {
|
|
33
47
|
const dibuat = buatConfigGlobal();
|
|
34
48
|
if (dibuat) {
|
|
49
|
+
console.log(teksBanner());
|
|
35
50
|
console.log(`Konfigurasi dibuat: ${globalConfigPath()}`);
|
|
36
51
|
console.log("Isi kolom \"apiKey\" untuk tiap penyedia yang ingin dipakai (atau pakai sintaks \"env:NAMA_VAR\").");
|
|
37
52
|
console.log("Urutan penyedia = prioritas: yang pertama adalah bawaan, berikutnya cadangan failover.");
|
|
@@ -75,18 +90,46 @@ function uraiArgAsk(args) {
|
|
|
75
90
|
return { error: "Pertanyaan kosong. Contoh: vibekit ask \"apa itu API?\"" };
|
|
76
91
|
return { pertanyaan, files, dirs };
|
|
77
92
|
}
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
function buatPembacaBaris() {
|
|
94
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
95
|
+
const antrian = [];
|
|
96
|
+
const penunggu = [];
|
|
97
|
+
let berakhir = false;
|
|
98
|
+
rl.on("line", (baris) => {
|
|
99
|
+
const p = penunggu.shift();
|
|
100
|
+
if (p)
|
|
101
|
+
p(baris);
|
|
102
|
+
else
|
|
103
|
+
antrian.push(baris);
|
|
104
|
+
});
|
|
105
|
+
rl.on("close", () => {
|
|
106
|
+
berakhir = true;
|
|
107
|
+
while (penunggu.length > 0)
|
|
108
|
+
penunggu.shift()(null);
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
tanya: (prompt) => {
|
|
112
|
+
process.stdout.write(prompt);
|
|
113
|
+
const siap = antrian.shift();
|
|
114
|
+
if (siap !== undefined)
|
|
115
|
+
return Promise.resolve(siap);
|
|
116
|
+
if (berakhir)
|
|
117
|
+
return Promise.resolve(null);
|
|
118
|
+
return new Promise((resolve) => penunggu.push(resolve));
|
|
119
|
+
},
|
|
120
|
+
tutup: () => rl.close(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** Pembuat callback konfirmasi bersama untuk do/ask/chat di atas satu PembacaBaris. */
|
|
124
|
+
function buatKonfirmasi(pembaca) {
|
|
125
|
+
return async (deskripsi) => {
|
|
126
|
+
console.log(`\n[vibekit] Usulan aksi:\n${deskripsi}`);
|
|
127
|
+
const jawaban = await pembaca.tanya("Jalankan? (y/N) ");
|
|
128
|
+
if (jawaban === null)
|
|
129
|
+
return false; // EOF: tolak demi keamanan
|
|
130
|
+
const j = jawaban.trim().toLowerCase();
|
|
131
|
+
return j === "y" || j === "ya";
|
|
132
|
+
};
|
|
90
133
|
}
|
|
91
134
|
async function perintahAsk(args) {
|
|
92
135
|
const urai = uraiArgAsk(args);
|
|
@@ -116,12 +159,45 @@ async function perintahAsk(args) {
|
|
|
116
159
|
for (const d of konteks.dilewati)
|
|
117
160
|
console.error(`[vibekit] konteks dilewati: ${d}`);
|
|
118
161
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
162
|
+
// Sejak v0.3.0 ask menjalankan SATU giliran agent: pertanyaan biasa dijawab
|
|
163
|
+
// teks langsung, permintaan membuat file/perintah dijalankan lewat tool
|
|
164
|
+
// dengan konfirmasi — pagar keamanan yang sama seperti `do`.
|
|
165
|
+
const messages = [
|
|
166
|
+
{ role: "system", content: SYSTEM_PROMPT_GABUNGAN },
|
|
167
|
+
{ role: "user", content: isiUser },
|
|
168
|
+
];
|
|
169
|
+
const pembaca = buatPembacaBaris();
|
|
170
|
+
try {
|
|
171
|
+
const hasil = await jalankanGiliran({
|
|
172
|
+
config,
|
|
173
|
+
messages,
|
|
174
|
+
lokasiState: statePath(),
|
|
175
|
+
cwd: process.cwd(),
|
|
176
|
+
konfirmasi: buatKonfirmasi(pembaca),
|
|
177
|
+
jejak: (pesan) => console.error(`[vibekit] ${pesan}`),
|
|
178
|
+
});
|
|
179
|
+
if (!hasil.ok) {
|
|
180
|
+
if (hasil.batasTercapai) {
|
|
181
|
+
console.error(`[vibekit] Batas langkah tercapai (${hasil.jumlahLangkah} langkah) sebelum pertanyaan ini selesai dikerjakan. Persempit permintaanmu, atau gunakan vibekit do untuk mode tugas.`);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
console.error(hasil.pesanGagal ?? "[vibekit] Semua penyedia gagal atau sedang tidak tersedia.");
|
|
185
|
+
}
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
console.log(hasil.jawabanAkhir);
|
|
189
|
+
if (hasil.providerTerakhir) {
|
|
190
|
+
const sumber = hasil.tokenSumber === "usage" ? "dari usage API" : "estimasi";
|
|
191
|
+
console.log(`\n— dijawab oleh ${hasil.providerTerakhir.nama} (${hasil.providerTerakhir.model}) • ${formatAngka(hasil.totalToken)} token (${sumber})`);
|
|
192
|
+
}
|
|
193
|
+
for (const c of hasil.catatan) {
|
|
194
|
+
console.error(`[vibekit] dilewati ${c.nama}: ${c.alasan}`);
|
|
195
|
+
}
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
pembaca.tutup();
|
|
200
|
+
}
|
|
125
201
|
}
|
|
126
202
|
async function perintahChat() {
|
|
127
203
|
let config;
|
|
@@ -133,54 +209,99 @@ async function perintahChat() {
|
|
|
133
209
|
return 2;
|
|
134
210
|
}
|
|
135
211
|
console.log("VibeKit chat — ketik pertanyaan, /keluar untuk berhenti.");
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
212
|
+
console.log("Chat ini juga bisa bertindak: minta saja dibuatkan file atau menjalankan perintah — setiap aksi meminta persetujuanmu dulu.");
|
|
213
|
+
const riwayat = [{ role: "system", content: SYSTEM_PROMPT_GABUNGAN }];
|
|
214
|
+
const pembaca = buatPembacaBaris();
|
|
215
|
+
const konfirmasi = buatKonfirmasi(pembaca);
|
|
216
|
+
try {
|
|
217
|
+
for (;;) {
|
|
218
|
+
const barisMentah = await pembaca.tanya("kamu> ");
|
|
219
|
+
if (barisMentah === null)
|
|
220
|
+
break; // stdin berakhir (EOF)
|
|
221
|
+
const baris = barisMentah.trim();
|
|
222
|
+
if (baris === "/keluar" || baris === "/exit")
|
|
223
|
+
break;
|
|
224
|
+
if (!baris)
|
|
225
|
+
continue;
|
|
226
|
+
riwayat.push({ role: "user", content: baris });
|
|
227
|
+
// Pangkas SADAR-TOOL: hanya pada batas giliran utuh, tidak pernah
|
|
228
|
+
// menyisakan pesan tool yatim atau tool_calls tanpa hasilnya.
|
|
229
|
+
const terpotong = pangkasRiwayatAman(riwayat, BATAS_TOKEN_RIWAYAT);
|
|
230
|
+
riwayat.splice(0, riwayat.length, ...terpotong);
|
|
231
|
+
const hasil = await jalankanGiliran({
|
|
232
|
+
config,
|
|
233
|
+
messages: riwayat,
|
|
234
|
+
lokasiState: statePath(),
|
|
235
|
+
cwd: process.cwd(),
|
|
236
|
+
konfirmasi,
|
|
237
|
+
jejak: (pesan) => console.error(`[vibekit] ${pesan}`),
|
|
238
|
+
});
|
|
239
|
+
if (hasil.ok) {
|
|
240
|
+
console.log(`\nvibekit (${hasil.providerTerakhir?.nama ?? "?"})> ${hasil.jawabanAkhir}\n`);
|
|
146
241
|
}
|
|
147
|
-
|
|
148
|
-
|
|
242
|
+
else {
|
|
243
|
+
// Giliran gagal/batas tercapai: gulung balik SELURUH giliran ini
|
|
244
|
+
// (pesan user + rangkaian parsialnya) agar riwayat tetap bersih.
|
|
245
|
+
let idxUser = -1;
|
|
246
|
+
for (let i = riwayat.length - 1; i >= 0; i--) {
|
|
247
|
+
if (riwayat[i].role === "user") {
|
|
248
|
+
idxUser = i;
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (idxUser >= 0)
|
|
253
|
+
riwayat.length = idxUser;
|
|
254
|
+
const pesan = hasil.batasTercapai
|
|
255
|
+
? `Batas langkah tercapai (${hasil.jumlahLangkah} langkah) pada giliran ini; giliran dibatalkan. Persempit permintaanmu.`
|
|
256
|
+
: (hasil.pesanGagal ?? "Semua penyedia gagal atau sedang tidak tersedia.");
|
|
257
|
+
console.error(`\n${pesan}\n`);
|
|
149
258
|
}
|
|
150
259
|
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
pembaca.tutup();
|
|
263
|
+
}
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
async function perintahDo(args) {
|
|
267
|
+
const tugas = args.join(" ").trim();
|
|
268
|
+
if (!tugas) {
|
|
269
|
+
console.error("[vibekit] Tugas kosong. Contoh: vibekit do \"buatkan file halo.txt berisi sapaan\"");
|
|
270
|
+
return 2;
|
|
271
|
+
}
|
|
272
|
+
let config;
|
|
273
|
+
try {
|
|
274
|
+
config = muatConfig().config;
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
console.error(`[vibekit] ${e.message}`);
|
|
278
|
+
return 2;
|
|
279
|
+
}
|
|
280
|
+
const cwd = process.cwd();
|
|
281
|
+
console.log(`VibeKit agent — folder kerja: ${cwd}`);
|
|
282
|
+
console.log(`Tugas: ${tugas}`);
|
|
283
|
+
console.log("Tool baca berjalan langsung; tulis/edit file dan jalankan perintah meminta persetujuanmu satu per satu.\n");
|
|
284
|
+
const pembaca = buatPembacaBaris();
|
|
285
|
+
const konfirmasi = buatKonfirmasi(pembaca);
|
|
286
|
+
try {
|
|
287
|
+
const hasil = await jalankanAgent({
|
|
168
288
|
config,
|
|
169
|
-
|
|
289
|
+
tugas,
|
|
170
290
|
lokasiState: statePath(),
|
|
291
|
+
cwd,
|
|
292
|
+
konfirmasi,
|
|
293
|
+
jejak: (pesan) => console.error(`[vibekit] ${pesan}`),
|
|
171
294
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
console.log(`\nvibekit (${hasil.provider.nama})> ${hasil.jawaban}\n`);
|
|
175
|
-
}
|
|
176
|
-
else {
|
|
177
|
-
riwayat.pop(); // pertanyaan yang gagal tidak disimpan sebagai giliran selesai
|
|
178
|
-
console.error(`\n${hasil.pesan}\n`);
|
|
295
|
+
for (const c of hasil.catatan) {
|
|
296
|
+
console.error(`[vibekit] dilewati ${c.nama}: ${c.alasan}`);
|
|
179
297
|
}
|
|
180
|
-
|
|
298
|
+
console.log(`\n— ringkasan agent (${hasil.jumlahLangkah} langkah) —`);
|
|
299
|
+
console.log(hasil.ringkasan);
|
|
300
|
+
return hasil.ok ? 0 : 1;
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
pembaca.tutup();
|
|
181
304
|
}
|
|
182
|
-
rl.close();
|
|
183
|
-
return 0;
|
|
184
305
|
}
|
|
185
306
|
function perintahStatus() {
|
|
186
307
|
let config;
|
|
@@ -220,6 +341,8 @@ async function utama() {
|
|
|
220
341
|
return perintahAsk(sisa);
|
|
221
342
|
case "chat":
|
|
222
343
|
return perintahChat();
|
|
344
|
+
case "do":
|
|
345
|
+
return perintahDo(sisa);
|
|
223
346
|
case "status":
|
|
224
347
|
return perintahStatus();
|
|
225
348
|
case "--version":
|
package/dist/router.js
CHANGED
|
@@ -38,7 +38,7 @@ export async function tanyaRouter(opsi) {
|
|
|
38
38
|
const catatan = [];
|
|
39
39
|
const system = { role: "system", content: systemPromptHemat(opsi.config.gaya) };
|
|
40
40
|
const semuaPesan = [system, ...opsi.messages];
|
|
41
|
-
const tokenPromptEstimasi = hitungToken(semuaPesan.map((m) => m.content).join("\n"));
|
|
41
|
+
const tokenPromptEstimasi = hitungToken(semuaPesan.map((m) => m.content ?? "").join("\n"));
|
|
42
42
|
for (const provider of opsi.config.providers) {
|
|
43
43
|
const entri = statePenyedia(state, provider.nama, sekarang);
|
|
44
44
|
if (sedangCooldown(entri, sekarang)) {
|
|
@@ -85,6 +85,7 @@ export async function tanyaRouter(opsi) {
|
|
|
85
85
|
model: provider.model,
|
|
86
86
|
messages: semuaPesan,
|
|
87
87
|
max_tokens: opsi.config.gaya.maksTokenJawaban,
|
|
88
|
+
...(opsi.tools && opsi.tools.length > 0 ? { tools: opsi.tools } : {}),
|
|
88
89
|
...(provider.extraBody ?? {}),
|
|
89
90
|
};
|
|
90
91
|
let respons;
|
|
@@ -162,8 +163,23 @@ export async function tanyaRouter(opsi) {
|
|
|
162
163
|
catatan.push({ nama: provider.nama, model: provider.model, status: "gagal", alasan });
|
|
163
164
|
continue;
|
|
164
165
|
}
|
|
165
|
-
const
|
|
166
|
-
|
|
166
|
+
const pesanMentah = data?.choices?.[0]?.message;
|
|
167
|
+
const jawaban = pesanMentah?.content ?? "";
|
|
168
|
+
const toolCallsMentah = pesanMentah?.tool_calls;
|
|
169
|
+
const toolCalls = Array.isArray(toolCallsMentah)
|
|
170
|
+
? toolCallsMentah
|
|
171
|
+
.filter((t) => !!t && typeof t === "object" && typeof t.function?.name === "string")
|
|
172
|
+
.map((t, i) => ({
|
|
173
|
+
id: typeof t.id === "string" && t.id ? t.id : `panggilan-${i + 1}`,
|
|
174
|
+
type: "function",
|
|
175
|
+
function: {
|
|
176
|
+
name: t.function.name,
|
|
177
|
+
arguments: typeof t.function.arguments === "string" ? t.function.arguments : JSON.stringify(t.function.arguments ?? {}),
|
|
178
|
+
},
|
|
179
|
+
}))
|
|
180
|
+
: [];
|
|
181
|
+
// Respons berisi tool_calls sah walau content kosong (mode agent).
|
|
182
|
+
if (!jawaban && toolCalls.length === 0) {
|
|
167
183
|
const alasan = "respons tidak berisi jawaban (choices kosong)";
|
|
168
184
|
entri.alasanTerakhir = alasan;
|
|
169
185
|
simpanState(opsi.lokasiState, state);
|
|
@@ -182,13 +198,23 @@ export async function tanyaRouter(opsi) {
|
|
|
182
198
|
tokenSumber = "usage";
|
|
183
199
|
}
|
|
184
200
|
else {
|
|
185
|
-
|
|
201
|
+
// Estimasi juga menghitung argumen tool_calls (bagian dari keluaran model).
|
|
202
|
+
const teksTool = toolCalls.map((t) => `${t.function.name} ${t.function.arguments}`).join("\n");
|
|
203
|
+
tokenTerpakai = tokenPromptEstimasi + hitungToken(jawaban) + (teksTool ? hitungToken(teksTool) : 0);
|
|
186
204
|
tokenSumber = "estimasi";
|
|
187
205
|
}
|
|
188
206
|
entri.totalToken += tokenTerpakai;
|
|
189
207
|
entri.alasanTerakhir = null;
|
|
190
208
|
simpanState(opsi.lokasiState, state);
|
|
191
|
-
return {
|
|
209
|
+
return {
|
|
210
|
+
ok: true,
|
|
211
|
+
jawaban,
|
|
212
|
+
...(toolCalls.length > 0 ? { toolCalls, pesanMentah } : {}),
|
|
213
|
+
provider,
|
|
214
|
+
tokenTerpakai,
|
|
215
|
+
tokenSumber,
|
|
216
|
+
catatan,
|
|
217
|
+
};
|
|
192
218
|
}
|
|
193
219
|
const baris = catatan.map((c) => {
|
|
194
220
|
const lagi = c.tersediaLagi ? ` — bisa dicoba lagi ${c.tersediaLagi}` : "";
|
package/package.json
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"dist",
|
|
18
|
+
"scripts",
|
|
18
19
|
"README.md",
|
|
19
20
|
"examples"
|
|
20
21
|
],
|
|
@@ -23,12 +24,13 @@
|
|
|
23
24
|
"name": "@chispear/vibekit",
|
|
24
25
|
"scripts": {
|
|
25
26
|
"build": "tsc -p tsconfig.json",
|
|
27
|
+
"postinstall": "node scripts/banner.js",
|
|
26
28
|
"prepublishOnly": "npm run build",
|
|
27
29
|
"start": "node dist/cli.js",
|
|
28
30
|
"test": "npm run build && node --test test/*.test.mjs"
|
|
29
31
|
},
|
|
30
32
|
"type": "module",
|
|
31
|
-
"version": "0.
|
|
33
|
+
"version": "0.3.0",
|
|
32
34
|
"publishConfig": {
|
|
33
35
|
"access": "public"
|
|
34
36
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Banner CHISPEAR — tampil otomatis setelah package berhasil terpasang
|
|
2
|
+
// (npm postinstall). Tanpa dependensi; hormati NO_COLOR.
|
|
3
|
+
// Salinan mandiri dari seni di src/banner.ts (skrip ini tidak boleh bergantung
|
|
4
|
+
// pada dist/ karena postinstall juga berjalan sebelum build di repo clone) —
|
|
5
|
+
// jaga keduanya tetap sinkron.
|
|
6
|
+
const GLIF = {
|
|
7
|
+
C: [" █████ ", "██ ██", "██ ", "██ ", "██ ██", " █████ "],
|
|
8
|
+
H: ["██ ██", "██ ██", "███████", "██ ██", "██ ██", "██ ██"],
|
|
9
|
+
I: ["███", " █ ", " █ ", " █ ", " █ ", "███"],
|
|
10
|
+
S: ["███████", "██ ", "██████ ", " ██", " ██", "██████ "],
|
|
11
|
+
P: ["██████ ", "██ ██", "██████ ", "██ ", "██ ", "██ "],
|
|
12
|
+
E: ["███████", "██ ", "█████ ", "█████ ", "██ ", "███████"],
|
|
13
|
+
A: [" █████ ", "██ ██", "███████", "██ ██", "██ ██", "██ ██"],
|
|
14
|
+
R: ["██████ ", "██ ██", "██████ ", "██ ██ ", "██ ██", "██ ██"],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const kata = "CHISPEAR";
|
|
18
|
+
const baris = ["", "", "", "", "", ""];
|
|
19
|
+
for (const huruf of kata) {
|
|
20
|
+
const g = GLIF[huruf];
|
|
21
|
+
if (!g) continue;
|
|
22
|
+
for (let i = 0; i < 6; i++) baris[i] += (baris[i] ? " " : "") + g[i];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pakaiWarna = !process.env.NO_COLOR;
|
|
26
|
+
const HIJAU = pakaiWarna ? "\x1b[1;92m" : "";
|
|
27
|
+
const RESET = pakaiWarna ? "\x1b[0m" : "";
|
|
28
|
+
|
|
29
|
+
console.log("");
|
|
30
|
+
for (const b of baris) console.log(HIJAU + b + RESET);
|
|
31
|
+
console.log("");
|
|
32
|
+
console.log(HIJAU + " VibeKit CLI terpasang — router AI hemat tokenmu siap dipakai." + RESET);
|
|
33
|
+
console.log(" Mulai dengan: vibekit init • Bantuan: vibekit --help");
|
|
34
|
+
console.log("");
|