@chispear/vibekit 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emil Kusmayadi (Chispear)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,98 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # VibeKit CLI
2
+
3
+ **Router AI hemat token untuk terminal Anda.** Daftarkan banyak penyedia model AI (GPT/OpenAI, Gemini, DeepSeek, OpenRouter, bahkan model lokal Ollama) dalam satu file konfigurasi — VibeKit memakai penyedia pertama sebagai bawaan, dan **otomatis pindah ke penyedia berikutnya bila kuota terdeteksi habis**. Setiap jawaban diatur ringkas poin-poin penting dan dibatasi tokennya, konteks dipilih & dipangkas sebelum dikirim, dan pemakaian token dicatat per hari per penyedia.
4
+
5
+ > **Token-savvy AI router for your terminal.** Register multiple AI providers in one config file — VibeKit uses the first as default and **fails over automatically when a quota runs out**, keeps answers brief and to the point, trims attached context, and tracks daily per-provider token usage.
6
+
7
+ ## Kenapa VibeKit?
8
+
9
+ - **Failover otomatis** — kuota GPT habis di tengah hari? Permintaan yang sama diulang transparan ke Gemini, lalu DeepSeek. Anda tidak perlu ganti alat atau ganti konfigurasi.
10
+ - **Irit token by design** — system prompt "poin-penting", batas `max_tokens`, lampiran konteks yang diseleksi (file biner & raksasa dilewati, komentar dipangkas, ada laporan hemat token), dan riwayat chat yang dipangkas otomatis.
11
+ - **Anggaran harian per penyedia** — pasang batas token harian; penyedia yang mencapai batas dilewati *sebelum* menabrak kuota beneran.
12
+ - **Satu format untuk semua** — semua penyedia dipanggil lewat API OpenAI-compatible (`/chat/completions`). Penyedia apa pun yang kompatibel bisa ditambahkan hanya lewat konfigurasi.
13
+ - **Lokal & privat** — API key tersimpan di komputer Anda (`~/.vibekit/config.json`), tidak ada server perantara.
14
+
15
+ ## Instalasi
16
+
17
+ ```bash
18
+ npm install -g vibekit # npm
19
+ pnpm add -g vibekit # pnpm
20
+ npx vibekit ask "halo" # tanpa install, langsung coba
21
+ ```
22
+
23
+ Butuh Node.js >= 18.
24
+
25
+ ## Mulai Cepat
26
+
27
+ ```bash
28
+ vibekit init
29
+ ```
30
+
31
+ Perintah ini membuat `~/.vibekit/config.json`:
32
+
33
+ ```json
34
+ {
35
+ "providers": [
36
+ { "nama": "gpt", "baseUrl": "https://api.openai.com/v1", "apiKey": "", "model": "gpt-4o-mini", "anggaranHarian": 500000, "jedaCooldownMenit": 15 },
37
+ { "nama": "gemini", "baseUrl": "https://generativelanguage.googleapis.com/v1beta/openai/", "apiKey": "", "model": "gemini-2.5-flash", "jedaCooldownMenit": 15 },
38
+ { "nama": "deepseek", "baseUrl": "https://api.deepseek.com/v1", "apiKey": "", "model": "deepseek-chat", "jedaCooldownMenit": 15 }
39
+ ],
40
+ "gaya": { "jawaban": "poin-penting", "maksTokenJawaban": 800 }
41
+ }
42
+ ```
43
+
44
+ Isi `apiKey` tiap penyedia (atau gunakan sintaks `"env:NAMA_VAR"` untuk membaca dari variabel lingkungan). **Urutan array = prioritas**: `gpt` dipakai duluan; bila kuotanya habis, lanjut `gemini`, lalu `deepseek`. Tambah penyedia lain (OpenRouter, Ollama lokal) cukup dengan menambah entri — lihat `examples/vibekit.config.json`.
45
+
46
+ Konfigurasi per proyek: file `./vibekit.config.json` di folder proyek **menimpa** konfigurasi global, jadi tiap proyek bisa punya susunan penyedia sendiri.
47
+
48
+ Lalu:
49
+
50
+ ```bash
51
+ vibekit ask "jelaskan apa itu REST API"
52
+ vibekit ask "tinjau kode ini" --file src/app.ts --dir src/
53
+ vibekit chat
54
+ vibekit status
55
+ ```
56
+
57
+ ## Perintah
58
+
59
+ | Perintah | Fungsi |
60
+ |---|---|
61
+ | `vibekit init` | Buat `~/.vibekit/config.json` dari template (tidak menimpa yang sudah ada) |
62
+ | `vibekit ask "<pertanyaan>" [--file <path>]... [--dir <path>]...` | Tanya sekali; lampirkan file/folder sebagai konteks terseleksi. Ringkasan hemat token ditulis ke stderr, jawaban bersih di stdout, ditutup baris info model + token terpakai |
63
+ | `vibekit chat` | Percakapan interaktif; riwayat dipangkas otomatis (maks ±4.000 token, giliran tertua dibuang); `/keluar` untuk berhenti |
64
+ | `vibekit status` | Tabel per penyedia: model, status (aktif / cooldown s.d. jam / anggaran tercapai), token terpakai hari ini |
65
+ | `vibekit --help` / `--version` | Bantuan / versi |
66
+
67
+ ## Cara Failover Bekerja
68
+
69
+ 1. Sebelum memanggil, router memeriksa state lokal (`~/.vibekit/state.json`): penyedia yang masih **cooldown** atau sudah mencapai **anggaran harian** dilewati tanpa dipanggil.
70
+ 2. Saat memanggil, permintaan berisi system prompt hemat sesuai `gaya.jawaban` dan `max_tokens` = `gaya.maksTokenJawaban`. Objek `extraBody` per penyedia (bila ada) digabung ke body — berguna untuk kenop khas model, mis. menekan penalaran.
71
+ 3. Bila respons menandakan kuota habis — HTTP **429/402** atau pola error `insufficient_quota` / `RESOURCE_EXHAUSTED` / saldo — penyedia ditandai **cooldown sampai akhir hari lokal** dan permintaan yang sama diulangi ke penyedia berikutnya. Rate-limit singkat (429 tanpa pola kuota) → cooldown `jedaCooldownMenit` (bawaan 15 menit).
72
+ 4. Error lain (401 kunci salah, 400, jaringan) **tidak** dianggap kuota habis: dicatat apa adanya dan router tetap mencoba penyedia berikutnya.
73
+ 5. Token terpakai dicatat dari field `usage` respons (atau estimasi tokenizer bila tidak ada). Bila semua penyedia gagal/habis, Anda menerima ringkasan status tiap penyedia dan jam paling awal untuk mencoba lagi.
74
+
75
+ ## Penghematan Token
76
+
77
+ - **System prompt ringkas** — gaya `poin-penting` memerintahkan model menjawab hanya poin penting tanpa basa-basi; tersedia juga `normal` dan `detail`.
78
+ - **Batas jawaban keras** — `maksTokenJawaban` menjadi `max_tokens` di setiap permintaan.
79
+ - **Konteks terseleksi** — `--file/--dir` melewati file biner & file > 256 KB, memangkas baris komentar penuh dan baris kosong berlebih, dan membatasi total konteks (±16.000 token) dengan memotong berkas terbesar. Selisih token mentah vs terseleksi selalu ditampilkan.
80
+ - **Anggaran & pencatatan** — pemakaian per penyedia per hari terlihat di `vibekit status`, sehingga boros selalu kelihatan.
81
+
82
+ ## Pengembangan
83
+
84
+ ```bash
85
+ npm install
86
+ npm test # build + uji node:test melawan server tiruan lokal (tanpa API key asli)
87
+ npm run build # tsc → dist/
88
+ ```
89
+
90
+ Keputusan repo: `dist/` masuk `.gitignore` dan dibangun saat publish (`prepublishOnly`), jadi repo tetap bersih; `vibekit.config.json` dan `.vibekit/` juga di-ignore karena dapat berisi API key.
91
+
92
+ ## Status Nama di npm
93
+
94
+ Nama paket **`vibekit` di npm sudah terpakai** oleh proyek lain (per Oktober 2026). Sebelum publish, nama perlu diganti — mis. scoped package `@username/vibekit` atau nama turunan — keputusan ada pada pemilik repo. Nama perintah CLI tetap `vibekit`.
95
+
96
+ ## Lisensi
97
+
98
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ import readline from "node:readline";
3
+ import { buatConfigGlobal, globalConfigPath, muatConfig, statePath, } from "./config.js";
4
+ import { bangunKonteks } from "./context.js";
5
+ import { tanyaRouter } from "./router.js";
6
+ import { formatJam, muatState, sedangCooldown, statePenyedia, } from "./state.js";
7
+ import { formatAngka, hitungToken } from "./token.js";
8
+ const VERSI = "0.1.0";
9
+ const BATAS_TOKEN_RIWAYAT = 4000;
10
+ function bantuan() {
11
+ console.log(`VibeKit CLI v${VERSI} — router AI hemat token dengan failover otomatis
12
+
13
+ Penggunaan:
14
+ vibekit init Buat ~/.vibekit/config.json dari template
15
+ vibekit ask "<pertanyaan>" [opsi] Tanya sekali lewat router
16
+ vibekit chat Percakapan interaktif (perintah /keluar untuk berhenti)
17
+ vibekit status Status & pemakaian token tiap penyedia hari ini
18
+ vibekit --help Tampilkan bantuan ini
19
+ vibekit --version Tampilkan versi
20
+
21
+ Opsi ask:
22
+ --file <path> Lampirkan file sebagai konteks (boleh diulang)
23
+ --dir <path> Lampirkan isi folder sebagai konteks (boleh diulang)
24
+
25
+ Cara kerja singkat:
26
+ Penyedia dicoba sesuai urutan di konfigurasi. Bila kuota penyedia terdeteksi
27
+ habis, permintaan otomatis diulang ke penyedia berikutnya. Setiap jawaban
28
+ diatur ringkas (poin-poin penting) dan dibatasi tokennya agar hemat.
29
+ Konfigurasi proyek ./vibekit.config.json menimpa konfigurasi global.
30
+ `);
31
+ }
32
+ function perintahInit() {
33
+ const dibuat = buatConfigGlobal();
34
+ if (dibuat) {
35
+ console.log(`Konfigurasi dibuat: ${globalConfigPath()}`);
36
+ console.log("Isi kolom \"apiKey\" untuk tiap penyedia yang ingin dipakai (atau pakai sintaks \"env:NAMA_VAR\").");
37
+ console.log("Urutan penyedia = prioritas: yang pertama adalah bawaan, berikutnya cadangan failover.");
38
+ }
39
+ else {
40
+ console.log(`Konfigurasi sudah ada di ${globalConfigPath()} — tidak ditimpa.`);
41
+ console.log("Edit file itu langsung bila ingin mengubah penyedia, model, atau anggaran.");
42
+ }
43
+ return 0;
44
+ }
45
+ function uraiArgAsk(args) {
46
+ const files = [];
47
+ const dirs = [];
48
+ const sisa = [];
49
+ for (let i = 0; i < args.length; i++) {
50
+ const a = args[i];
51
+ if (a === "--file") {
52
+ const v = args[++i];
53
+ if (!v)
54
+ return { error: "Opsi --file membutuhkan path file." };
55
+ files.push(v);
56
+ }
57
+ else if (a === "--dir") {
58
+ const v = args[++i];
59
+ if (!v)
60
+ return { error: "Opsi --dir membutuhkan path folder." };
61
+ dirs.push(v);
62
+ }
63
+ else if (a.startsWith("--file=")) {
64
+ files.push(a.slice("--file=".length));
65
+ }
66
+ else if (a.startsWith("--dir=")) {
67
+ dirs.push(a.slice("--dir=".length));
68
+ }
69
+ else {
70
+ sisa.push(a);
71
+ }
72
+ }
73
+ const pertanyaan = sisa.join(" ").trim();
74
+ if (!pertanyaan)
75
+ return { error: "Pertanyaan kosong. Contoh: vibekit ask \"apa itu API?\"" };
76
+ return { pertanyaan, files, dirs };
77
+ }
78
+ function tampilkanHasil(hasil) {
79
+ if (hasil.ok) {
80
+ console.log(hasil.jawaban);
81
+ const sumber = hasil.tokenSumber === "usage" ? "dari usage API" : "estimasi";
82
+ console.log(`\n— dijawab oleh ${hasil.provider.nama} (${hasil.provider.model}) • ${formatAngka(hasil.tokenTerpakai)} token (${sumber})`);
83
+ for (const c of hasil.catatan) {
84
+ console.error(`[vibekit] dilewati ${c.nama}: ${c.alasan}`);
85
+ }
86
+ return 0;
87
+ }
88
+ console.error(hasil.pesan);
89
+ return 1;
90
+ }
91
+ async function perintahAsk(args) {
92
+ const urai = uraiArgAsk(args);
93
+ if ("error" in urai) {
94
+ console.error(`[vibekit] ${urai.error}`);
95
+ return 2;
96
+ }
97
+ let config;
98
+ try {
99
+ config = muatConfig().config;
100
+ }
101
+ catch (e) {
102
+ console.error(`[vibekit] ${e.message}`);
103
+ return 2;
104
+ }
105
+ let isiUser = urai.pertanyaan;
106
+ const target = [...urai.files, ...urai.dirs];
107
+ if (target.length > 0) {
108
+ const konteks = bangunKonteks(target);
109
+ if (konteks.jumlahFile > 0) {
110
+ const hemat = konteks.tokenMentah > 0
111
+ ? Math.max(0, Math.round((1 - konteks.tokenTerseleksi / konteks.tokenMentah) * 100))
112
+ : 0;
113
+ console.error(`[vibekit] konteks: ${konteks.jumlahFile} file • token mentah ${formatAngka(konteks.tokenMentah)} → terseleksi ${formatAngka(konteks.tokenTerseleksi)} (hemat ${hemat}%)`);
114
+ isiUser = `Konteks proyek:\n\n${konteks.teks}\n\nPertanyaan: ${urai.pertanyaan}`;
115
+ }
116
+ for (const d of konteks.dilewati)
117
+ console.error(`[vibekit] konteks dilewati: ${d}`);
118
+ }
119
+ const hasil = await tanyaRouter({
120
+ config,
121
+ messages: [{ role: "user", content: isiUser }],
122
+ lokasiState: statePath(),
123
+ });
124
+ return tampilkanHasil(hasil);
125
+ }
126
+ async function perintahChat() {
127
+ let config;
128
+ try {
129
+ config = muatConfig().config;
130
+ }
131
+ catch (e) {
132
+ console.error(`[vibekit] ${e.message}`);
133
+ return 2;
134
+ }
135
+ console.log("VibeKit chat — ketik pertanyaan, /keluar untuk berhenti.");
136
+ const riwayat = [];
137
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "kamu> " });
138
+ let rlDitutup = false;
139
+ rl.on("close", () => {
140
+ rlDitutup = true;
141
+ });
142
+ const promptAman = () => {
143
+ if (!rlDitutup) {
144
+ try {
145
+ rl.prompt();
146
+ }
147
+ catch {
148
+ /* readline sudah tertutup (EOF) — abaikan */
149
+ }
150
+ }
151
+ };
152
+ promptAman();
153
+ for await (const barisMentah of rl) {
154
+ const baris = barisMentah.trim();
155
+ if (baris === "/keluar" || baris === "/exit")
156
+ break;
157
+ if (!baris) {
158
+ promptAman();
159
+ continue;
160
+ }
161
+ riwayat.push({ role: "user", content: baris });
162
+ // Jaga riwayat: buang giliran tertua sampai total token riwayat masuk akal.
163
+ while (riwayat.length > 2 &&
164
+ hitungToken(riwayat.map((m) => m.content).join("\n")) > BATAS_TOKEN_RIWAYAT) {
165
+ riwayat.splice(0, 2);
166
+ }
167
+ const hasil = await tanyaRouter({
168
+ config,
169
+ messages: [...riwayat],
170
+ lokasiState: statePath(),
171
+ });
172
+ if (hasil.ok) {
173
+ riwayat.push({ role: "assistant", content: hasil.jawaban });
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`);
179
+ }
180
+ promptAman();
181
+ }
182
+ rl.close();
183
+ return 0;
184
+ }
185
+ function perintahStatus() {
186
+ let config;
187
+ try {
188
+ config = muatConfig().config;
189
+ }
190
+ catch (e) {
191
+ console.error(`[vibekit] ${e.message}`);
192
+ return 2;
193
+ }
194
+ const state = muatState(statePath());
195
+ const sekarang = new Date();
196
+ const baris = [["Penyedia", "Model", "Status", "Token hari ini"]];
197
+ for (const p of config.providers) {
198
+ const entri = statePenyedia(state, p.nama, sekarang);
199
+ let status = "aktif";
200
+ if (sedangCooldown(entri, sekarang) && entri.cooldownUntil) {
201
+ status = `cooldown s.d. ${formatJam(entri.cooldownUntil)}`;
202
+ }
203
+ else if (typeof p.anggaranHarian === "number" && entri.totalToken >= p.anggaranHarian) {
204
+ status = "anggaran harian tercapai";
205
+ }
206
+ baris.push([p.nama, p.model, status, formatAngka(entri.totalToken)]);
207
+ }
208
+ const lebar = baris[0].map((_, i) => Math.max(...baris.map((b) => b[i].length)));
209
+ for (const b of baris) {
210
+ console.log(b.map((sel, i) => sel.padEnd(lebar[i])).join(" "));
211
+ }
212
+ return 0;
213
+ }
214
+ async function utama() {
215
+ const [perintah, ...sisa] = process.argv.slice(2);
216
+ switch (perintah) {
217
+ case "init":
218
+ return perintahInit();
219
+ case "ask":
220
+ return perintahAsk(sisa);
221
+ case "chat":
222
+ return perintahChat();
223
+ case "status":
224
+ return perintahStatus();
225
+ case "--version":
226
+ case "-v":
227
+ console.log(VERSI);
228
+ return 0;
229
+ case "--help":
230
+ case "-h":
231
+ case "help":
232
+ case undefined:
233
+ bantuan();
234
+ return 0;
235
+ default:
236
+ console.error(`[vibekit] Perintah tidak dikenal: ${perintah}\n`);
237
+ bantuan();
238
+ return 2;
239
+ }
240
+ }
241
+ utama()
242
+ .then((kode) => {
243
+ process.exitCode = kode;
244
+ })
245
+ .catch((e) => {
246
+ console.error(`[vibekit] Kesalahan tak terduga: ${e.message}`);
247
+ process.exitCode = 1;
248
+ });
package/dist/config.js ADDED
@@ -0,0 +1,110 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export function configDir() {
5
+ return path.join(os.homedir(), ".vibekit");
6
+ }
7
+ export function globalConfigPath() {
8
+ return path.join(configDir(), "config.json");
9
+ }
10
+ export function statePath() {
11
+ return path.join(configDir(), "state.json");
12
+ }
13
+ export function projectConfigPath(cwd = process.cwd()) {
14
+ return path.join(cwd, "vibekit.config.json");
15
+ }
16
+ const BASE_URL_BAWAAN = {
17
+ gpt: "https://api.openai.com/v1",
18
+ openai: "https://api.openai.com/v1",
19
+ gemini: "https://generativelanguage.googleapis.com/v1beta/openai/",
20
+ google: "https://generativelanguage.googleapis.com/v1beta/openai/",
21
+ deepseek: "https://api.deepseek.com/v1",
22
+ };
23
+ export const TEMPLATE_CONFIG = {
24
+ providers: [
25
+ {
26
+ nama: "gpt",
27
+ baseUrl: "https://api.openai.com/v1",
28
+ apiKey: "",
29
+ model: "gpt-4o-mini",
30
+ anggaranHarian: 500000,
31
+ jedaCooldownMenit: 15,
32
+ },
33
+ {
34
+ nama: "gemini",
35
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
36
+ apiKey: "",
37
+ model: "gemini-2.5-flash",
38
+ jedaCooldownMenit: 15,
39
+ },
40
+ {
41
+ nama: "deepseek",
42
+ baseUrl: "https://api.deepseek.com/v1",
43
+ apiKey: "",
44
+ model: "deepseek-chat",
45
+ jedaCooldownMenit: 15,
46
+ },
47
+ ],
48
+ gaya: { jawaban: "poin-penting", maksTokenJawaban: 800 },
49
+ };
50
+ /** Buat ~/.vibekit/config.json dari template. Kembalikan false bila sudah ada (tidak ditimpa). */
51
+ export function buatConfigGlobal() {
52
+ const target = globalConfigPath();
53
+ if (fs.existsSync(target))
54
+ return false;
55
+ fs.mkdirSync(path.dirname(target), { recursive: true });
56
+ fs.writeFileSync(target, JSON.stringify(TEMPLATE_CONFIG, null, 2) + "\n", "utf8");
57
+ return true;
58
+ }
59
+ function normalisasiProvider(p) {
60
+ const nama = String(p.nama);
61
+ return {
62
+ nama,
63
+ baseUrl: p.baseUrl ?? BASE_URL_BAWAAN[nama.toLowerCase()] ?? "",
64
+ apiKey: p.apiKey ?? "",
65
+ model: p.model ?? "",
66
+ anggaranHarian: typeof p.anggaranHarian === "number" ? p.anggaranHarian : undefined,
67
+ jedaCooldownMenit: typeof p.jedaCooldownMenit === "number" ? p.jedaCooldownMenit : undefined,
68
+ extraBody: p.extraBody,
69
+ };
70
+ }
71
+ /**
72
+ * Muat konfigurasi: file proyek ./vibekit.config.json menimpa global ~/.vibekit/config.json.
73
+ * Lempar Error dengan pesan Bahasa Indonesia bila tidak ada konfigurasi / tidak valid.
74
+ */
75
+ export function muatConfig(cwd = process.cwd()) {
76
+ const proyek = projectConfigPath(cwd);
77
+ const global = globalConfigPath();
78
+ const sumber = fs.existsSync(proyek) ? proyek : fs.existsSync(global) ? global : null;
79
+ if (!sumber) {
80
+ throw new Error("Konfigurasi tidak ditemukan. Jalankan `vibekit init` untuk membuat ~/.vibekit/config.json, " +
81
+ "atau buat file vibekit.config.json di folder proyek ini.");
82
+ }
83
+ let mentah;
84
+ try {
85
+ mentah = JSON.parse(fs.readFileSync(sumber, "utf8"));
86
+ }
87
+ catch (e) {
88
+ throw new Error(`Konfigurasi di ${sumber} bukan JSON yang valid: ${e.message}`);
89
+ }
90
+ const obj = mentah;
91
+ if (!obj || !Array.isArray(obj.providers) || obj.providers.length === 0) {
92
+ throw new Error(`Konfigurasi di ${sumber} harus berisi array "providers" yang tidak kosong.`);
93
+ }
94
+ const config = {
95
+ providers: obj.providers.map((p) => normalisasiProvider(p)),
96
+ gaya: {
97
+ jawaban: obj.gaya?.jawaban ?? "poin-penting",
98
+ maksTokenJawaban: typeof obj.gaya?.maksTokenJawaban === "number" ? obj.gaya.maksTokenJawaban : 800,
99
+ },
100
+ };
101
+ return { config, sumber };
102
+ }
103
+ /** Selesaikan nilai apiKey: sintaks "env:NAMA_VAR" dibaca dari variabel lingkungan. */
104
+ export function selesaikanApiKey(apiKey) {
105
+ if (apiKey.startsWith("env:")) {
106
+ const namaVar = apiKey.slice(4).trim();
107
+ return process.env[namaVar] ?? "";
108
+ }
109
+ return apiKey;
110
+ }
@@ -0,0 +1,115 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { hitungToken } from "./token.js";
4
+ const EKSTENSI_BINER = new Set([
5
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".tiff",
6
+ ".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".class",
7
+ ".zip", ".tar", ".gz", ".rar", ".7z",
8
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
9
+ ".mp3", ".mp4", ".wav", ".mov", ".avi", ".mkv",
10
+ ".woff", ".woff2", ".ttf", ".eot", ".wasm",
11
+ ]);
12
+ const DIR_LEWATI = new Set(["node_modules", ".git", "dist", ".vibekit", ".next", "coverage"]);
13
+ const UKURAN_MAKS_FILE = 256 * 1024; // 256 KB — di atas ini dianggap raksasa, dilewati
14
+ const CAP_TOKEN_KONTEKS = 16_000;
15
+ function terlihatBiner(lokasi, buffer) {
16
+ if (EKSTENSI_BINER.has(path.extname(lokasi).toLowerCase()))
17
+ return true;
18
+ const sampel = buffer.subarray(0, 4096);
19
+ return sampel.includes(0);
20
+ }
21
+ /** Pangkas ringan: buang baris komentar penuh & rapatkan baris kosong berlebih. */
22
+ export function pangkasTeks(teks) {
23
+ const baris = teks.split("\n");
24
+ const hasil = [];
25
+ let kosongBeruntun = 0;
26
+ for (const mentah of baris) {
27
+ const barisRapi = mentah.replace(/\s+$/, "");
28
+ const trim = barisRapi.trim();
29
+ const komentarPenuh = (trim.startsWith("//") && !trim.startsWith("///")) ||
30
+ (trim.startsWith("#") && !trim.startsWith("#!") && trim.length > 1) ||
31
+ (trim.startsWith("/*") && trim.endsWith("*/") && trim.length > 3) ||
32
+ trim === "/*" || trim === "*/" || (trim.startsWith("* ") && trim.length > 2);
33
+ if (komentarPenuh)
34
+ continue;
35
+ if (trim === "") {
36
+ kosongBeruntun++;
37
+ if (kosongBeruntun > 1)
38
+ continue;
39
+ }
40
+ else {
41
+ kosongBeruntun = 0;
42
+ }
43
+ hasil.push(barisRapi);
44
+ }
45
+ return hasil.join("\n").trim();
46
+ }
47
+ function kumpulkanFile(targets) {
48
+ const file = [];
49
+ const dilewati = [];
50
+ const kunjungi = (lokasi) => {
51
+ let stat;
52
+ try {
53
+ stat = fs.statSync(lokasi);
54
+ }
55
+ catch {
56
+ dilewati.push(`${lokasi} (tidak ditemukan)`);
57
+ return;
58
+ }
59
+ if (stat.isDirectory()) {
60
+ const nama = path.basename(lokasi);
61
+ if (DIR_LEWATI.has(nama)) {
62
+ dilewati.push(`${lokasi} (folder dilewati)`);
63
+ return;
64
+ }
65
+ for (const entri of fs.readdirSync(lokasi).sort())
66
+ kunjungi(path.join(lokasi, entri));
67
+ }
68
+ else if (stat.isFile()) {
69
+ file.push(lokasi);
70
+ }
71
+ };
72
+ for (const t of targets)
73
+ kunjungi(t);
74
+ return { file, dilewati };
75
+ }
76
+ /** Bangun teks konteks dari daftar file/folder, dengan seleksi & pemangkasan hemat token. */
77
+ export function bangunKonteks(targets, capToken = CAP_TOKEN_KONTEKS) {
78
+ const { file, dilewati } = kumpulkanFile(targets);
79
+ const berkas = [];
80
+ for (const lokasi of file) {
81
+ const stat = fs.statSync(lokasi);
82
+ if (stat.size > UKURAN_MAKS_FILE) {
83
+ dilewati.push(`${lokasi} (terlalu besar: ${Math.round(stat.size / 1024)} KB)`);
84
+ continue;
85
+ }
86
+ const buffer = fs.readFileSync(lokasi);
87
+ if (terlihatBiner(lokasi, buffer)) {
88
+ dilewati.push(`${lokasi} (biner)`);
89
+ continue;
90
+ }
91
+ const mentah = buffer.toString("utf8");
92
+ const bersih = pangkasTeks(mentah);
93
+ berkas.push({ lokasi, mentah, bersih, tokenMentah: hitungToken(mentah), tokenBersih: hitungToken(bersih) });
94
+ }
95
+ let tokenMentah = berkas.reduce((a, b) => a + b.tokenMentah, 0);
96
+ let tokenTerseleksi = berkas.reduce((a, b) => a + b.tokenBersih, 0);
97
+ // Bila masih melewati cap: potong isi berkas terbesar berulang kali sampai muat.
98
+ let pengaman = 0;
99
+ while (tokenTerseleksi > capToken && berkas.length > 0 && pengaman++ < 200) {
100
+ berkas.sort((a, b) => b.tokenBersih - a.tokenBersih);
101
+ const terbesar = berkas[0];
102
+ if (terbesar.tokenBersih <= 40)
103
+ break;
104
+ const potong = Math.floor(terbesar.bersih.length / 2);
105
+ terbesar.bersih = terbesar.bersih.slice(0, potong) + "\n…[dipotong demi anggaran token]";
106
+ terbesar.tokenBersih = hitungToken(terbesar.bersih);
107
+ tokenTerseleksi = berkas.reduce((a, b) => a + b.tokenBersih, 0);
108
+ }
109
+ const teks = berkas
110
+ .map((b) => `### File: ${b.lokasi}\n\`\`\`\n${b.bersih}\n\`\`\``)
111
+ .join("\n\n");
112
+ tokenMentah = berkas.reduce((a, b) => a + b.tokenMentah, 0);
113
+ tokenTerseleksi = berkas.reduce((a, b) => a + b.tokenBersih, 0);
114
+ return { teks, jumlahFile: berkas.length, tokenMentah, tokenTerseleksi, dilewati };
115
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,25 @@
1
+ /** System prompt hemat token sesuai gaya jawaban pada konfigurasi. */
2
+ export function systemPromptHemat(gaya) {
3
+ switch ((gaya.jawaban || "poin-penting").toLowerCase()) {
4
+ case "detail":
5
+ return [
6
+ "Jawablah dengan jelas dan terstruktur.",
7
+ "Hindari basa-basi, pembuka yang bertele-tele, dan pengulangan pertanyaan.",
8
+ "Bila jawaban panjang diperlukan, gunakan subjudul atau poin agar mudah dipindai.",
9
+ ].join(" ");
10
+ case "normal":
11
+ return [
12
+ "Jawablah secara ringkas dan langsung ke inti.",
13
+ "Tanpa basa-basi, tanpa mengulang pertanyaan, tanpa penjelasan yang tidak diminta.",
14
+ ].join(" ");
15
+ case "poin-penting":
16
+ default:
17
+ return [
18
+ "Jawablah SANGAT ringkas, hanya poin-poin penting, dalam format poin (bullet list) bila cocok.",
19
+ "DILARANG: basa-basi, pembuka/penutup sopan-santun, mengulang atau parafrase pertanyaan,",
20
+ "menjelaskan hal yang tidak ditanyakan, dan kalimat pengisi.",
21
+ "Satu poin = satu ide, sesingkat mungkin sambil tetap akurat dan lengkap pada intinya.",
22
+ "Untuk kode: berikan kodenya saja ditambah penjelasan maksimal satu baris bila perlu.",
23
+ ].join(" ");
24
+ }
25
+ }
package/dist/router.js ADDED
@@ -0,0 +1,180 @@
1
+ import { selesaikanApiKey } from "./config.js";
2
+ import { systemPromptHemat } from "./prompt.js";
3
+ import { akhirHariIni, formatJam, muatState, sedangCooldown, simpanState, statePenyedia, } from "./state.js";
4
+ import { formatAngka, hitungToken } from "./token.js";
5
+ const POLA_KUOTA = /insufficient_quota|resource_exhausted|quota exceeded|exceeded your quota|kuota|insufficient balance|balance is too low|credit balance|billing/i;
6
+ /** Klasifikasikan kegagalan HTTP: kuota habis, rate-limit singkat, atau error lain. */
7
+ export function klasifikasiError(status, bodyTeks) {
8
+ const potongan = bodyTeks.replace(/\s+/g, " ").slice(0, 200);
9
+ if (status === 402 || POLA_KUOTA.test(bodyTeks)) {
10
+ return { jenis: "kuota", ringkas: `kuota/saldo habis (HTTP ${status}) ${potongan}`.trim() };
11
+ }
12
+ if (status === 429) {
13
+ return { jenis: "rate-limit", ringkas: `terkena rate-limit (HTTP 429) ${potongan}`.trim() };
14
+ }
15
+ return { jenis: "lainnya", ringkas: `HTTP ${status} ${potongan}`.trim() };
16
+ }
17
+ /**
18
+ * Mesin router: coba penyedia sesuai urutan prioritas konfigurasi.
19
+ * - Penyedia yang cooldown / anggaran hariannya tercapai dilewati tanpa dipanggil.
20
+ * - Kuota habis (429/402/pola kuota) → cooldown sampai akhir hari lokal, lalu permintaan
21
+ * yang sama diulangi ke penyedia berikutnya secara transparan.
22
+ * - Rate-limit singkat → cooldown jedaCooldownMenit (bawaan 15).
23
+ * - Error lain (401, 400, jaringan) tidak dianggap kuota habis; dicatat dan lanjut ke berikutnya.
24
+ */
25
+ export async function tanyaRouter(opsi) {
26
+ const fetchImpl = opsi.fetchImpl ?? fetch;
27
+ const sekarang = opsi.sekarang ?? new Date();
28
+ const timeoutMs = opsi.timeoutMs ?? 90_000;
29
+ const state = muatState(opsi.lokasiState);
30
+ const catatan = [];
31
+ const system = { role: "system", content: systemPromptHemat(opsi.config.gaya) };
32
+ const semuaPesan = [system, ...opsi.messages];
33
+ const tokenPromptEstimasi = hitungToken(semuaPesan.map((m) => m.content).join("\n"));
34
+ for (const provider of opsi.config.providers) {
35
+ const entri = statePenyedia(state, provider.nama, sekarang);
36
+ if (sedangCooldown(entri, sekarang)) {
37
+ catatan.push({
38
+ nama: provider.nama,
39
+ model: provider.model,
40
+ status: "cooldown",
41
+ alasan: entri.alasanTerakhir ?? "sedang istirahat (cooldown)",
42
+ tersediaLagi: entri.cooldownUntil ? formatJam(entri.cooldownUntil) : undefined,
43
+ });
44
+ continue;
45
+ }
46
+ if (typeof provider.anggaranHarian === "number" && entri.totalToken >= provider.anggaranHarian) {
47
+ catatan.push({
48
+ nama: provider.nama,
49
+ model: provider.model,
50
+ status: "anggaran-tercapai",
51
+ alasan: `anggaran harian tercapai (${formatAngka(entri.totalToken)} / ${formatAngka(provider.anggaranHarian)} token)`,
52
+ tersediaLagi: "besok (reset harian)",
53
+ });
54
+ continue;
55
+ }
56
+ const apiKey = selesaikanApiKey(provider.apiKey ?? "");
57
+ if (!apiKey) {
58
+ catatan.push({
59
+ nama: provider.nama,
60
+ model: provider.model,
61
+ status: "tanpa-kunci",
62
+ alasan: "API key belum diisi di konfigurasi",
63
+ });
64
+ continue;
65
+ }
66
+ if (!provider.baseUrl) {
67
+ catatan.push({
68
+ nama: provider.nama,
69
+ model: provider.model,
70
+ status: "konfigurasi-salah",
71
+ alasan: "baseUrl kosong dan tidak ada bawaan untuk nama penyedia ini",
72
+ });
73
+ continue;
74
+ }
75
+ const url = provider.baseUrl.replace(/\/+$/, "") + "/chat/completions";
76
+ const body = {
77
+ model: provider.model,
78
+ messages: semuaPesan,
79
+ max_tokens: opsi.config.gaya.maksTokenJawaban,
80
+ ...(provider.extraBody ?? {}),
81
+ };
82
+ let respons;
83
+ try {
84
+ const kontroler = new AbortController();
85
+ const pengatur = setTimeout(() => kontroler.abort(), timeoutMs);
86
+ try {
87
+ respons = await fetchImpl(url, {
88
+ method: "POST",
89
+ headers: {
90
+ "Content-Type": "application/json",
91
+ Authorization: `Bearer ${apiKey}`,
92
+ },
93
+ body: JSON.stringify(body),
94
+ signal: kontroler.signal,
95
+ });
96
+ }
97
+ finally {
98
+ clearTimeout(pengatur);
99
+ }
100
+ }
101
+ catch (e) {
102
+ const alasan = `gagal jaringan: ${e.message}`;
103
+ entri.alasanTerakhir = alasan;
104
+ simpanState(opsi.lokasiState, state);
105
+ catatan.push({ nama: provider.nama, model: provider.model, status: "gagal", alasan });
106
+ continue;
107
+ }
108
+ const teks = await respons.text();
109
+ if (!respons.ok) {
110
+ const { jenis, ringkas } = klasifikasiError(respons.status, teks);
111
+ entri.alasanTerakhir = ringkas;
112
+ if (jenis === "kuota") {
113
+ entri.cooldownUntil = akhirHariIni(sekarang).toISOString();
114
+ }
115
+ else if (jenis === "rate-limit") {
116
+ const menit = provider.jedaCooldownMenit ?? 15;
117
+ entri.cooldownUntil = new Date(sekarang.getTime() + menit * 60_000).toISOString();
118
+ }
119
+ simpanState(opsi.lokasiState, state);
120
+ catatan.push({
121
+ nama: provider.nama,
122
+ model: provider.model,
123
+ status: jenis === "lainnya" ? "gagal" : "cooldown",
124
+ alasan: ringkas,
125
+ tersediaLagi: entri.cooldownUntil ? formatJam(entri.cooldownUntil) : undefined,
126
+ });
127
+ continue;
128
+ }
129
+ let data;
130
+ try {
131
+ data = JSON.parse(teks);
132
+ }
133
+ catch {
134
+ const alasan = "respons bukan JSON yang valid";
135
+ entri.alasanTerakhir = alasan;
136
+ simpanState(opsi.lokasiState, state);
137
+ catatan.push({ nama: provider.nama, model: provider.model, status: "gagal", alasan });
138
+ continue;
139
+ }
140
+ const jawaban = data?.choices?.[0]?.message?.content ?? "";
141
+ if (!jawaban) {
142
+ const alasan = "respons tidak berisi jawaban (choices kosong)";
143
+ entri.alasanTerakhir = alasan;
144
+ simpanState(opsi.lokasiState, state);
145
+ catatan.push({ nama: provider.nama, model: provider.model, status: "gagal", alasan });
146
+ continue;
147
+ }
148
+ let tokenTerpakai;
149
+ let tokenSumber;
150
+ const usage = data?.usage;
151
+ if (usage && typeof usage.total_tokens === "number") {
152
+ tokenTerpakai = usage.total_tokens;
153
+ tokenSumber = "usage";
154
+ }
155
+ else if (usage && typeof usage.prompt_tokens === "number" && typeof usage.completion_tokens === "number") {
156
+ tokenTerpakai = usage.prompt_tokens + usage.completion_tokens;
157
+ tokenSumber = "usage";
158
+ }
159
+ else {
160
+ tokenTerpakai = tokenPromptEstimasi + hitungToken(jawaban);
161
+ tokenSumber = "estimasi";
162
+ }
163
+ entri.totalToken += tokenTerpakai;
164
+ entri.alasanTerakhir = null;
165
+ simpanState(opsi.lokasiState, state);
166
+ return { ok: true, jawaban, provider, tokenTerpakai, tokenSumber, catatan };
167
+ }
168
+ const baris = catatan.map((c) => {
169
+ const lagi = c.tersediaLagi ? ` — bisa dicoba lagi ${c.tersediaLagi}` : "";
170
+ return ` • ${c.nama} (${c.model}): ${c.status} — ${c.alasan}${lagi}`;
171
+ });
172
+ const palingAwal = catatan
173
+ .filter((c) => c.tersediaLagi && c.status === "cooldown")
174
+ .map((c) => c.tersediaLagi)
175
+ .sort()[0];
176
+ const pesan = "Semua penyedia gagal atau sedang tidak tersedia:\n" +
177
+ baris.join("\n") +
178
+ (palingAwal ? `\nPenyedia paling awal bisa dicoba lagi sekitar pukul ${palingAwal}.` : "");
179
+ return { ok: false, pesan, catatan };
180
+ }
package/dist/state.js ADDED
@@ -0,0 +1,58 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /** Tanggal lokal pengguna dalam format YYYY-MM-DD. */
4
+ export function tanggalLokal(d = new Date()) {
5
+ const y = d.getFullYear();
6
+ const m = String(d.getMonth() + 1).padStart(2, "0");
7
+ const day = String(d.getDate()).padStart(2, "0");
8
+ return `${y}-${m}-${day}`;
9
+ }
10
+ /** Timestamp akhir hari lokal (23:59:59.999) — cooldown untuk kuota habis. */
11
+ export function akhirHariIni(d = new Date()) {
12
+ const akhir = new Date(d);
13
+ akhir.setHours(23, 59, 59, 999);
14
+ return akhir;
15
+ }
16
+ export function muatState(lokasi) {
17
+ try {
18
+ if (fs.existsSync(lokasi)) {
19
+ const mentah = JSON.parse(fs.readFileSync(lokasi, "utf8"));
20
+ if (mentah && typeof mentah === "object" && mentah.providers)
21
+ return mentah;
22
+ }
23
+ }
24
+ catch {
25
+ /* state rusak → mulai dari kosong */
26
+ }
27
+ return { providers: {} };
28
+ }
29
+ export function simpanState(lokasi, state) {
30
+ fs.mkdirSync(path.dirname(lokasi), { recursive: true });
31
+ const sementara = lokasi + ".tmp";
32
+ fs.writeFileSync(sementara, JSON.stringify(state, null, 2) + "\n", "utf8");
33
+ fs.renameSync(sementara, lokasi);
34
+ }
35
+ /**
36
+ * Ambil state penyedia untuk hari ini. Bila entri berasal dari hari lain,
37
+ * hitungan token direset (cooldown akhir-hari otomatis kedaluwarsa).
38
+ */
39
+ export function statePenyedia(state, nama, sekarang = new Date()) {
40
+ const hariIni = tanggalLokal(sekarang);
41
+ let entri = state.providers[nama];
42
+ if (!entri || entri.tanggal !== hariIni) {
43
+ entri = { tanggal: hariIni, totalToken: 0, cooldownUntil: null, alasanTerakhir: null };
44
+ state.providers[nama] = entri;
45
+ }
46
+ return entri;
47
+ }
48
+ export function sedangCooldown(entri, sekarang = new Date()) {
49
+ if (!entri.cooldownUntil)
50
+ return false;
51
+ return new Date(entri.cooldownUntil).getTime() > sekarang.getTime();
52
+ }
53
+ export function formatJam(iso) {
54
+ const d = new Date(iso);
55
+ const hh = String(d.getHours()).padStart(2, "0");
56
+ const mm = String(d.getMinutes()).padStart(2, "0");
57
+ return `${hh}:${mm}`;
58
+ }
package/dist/token.js ADDED
@@ -0,0 +1,16 @@
1
+ import { encode } from "gpt-tokenizer";
2
+ /** Hitung token teks (estimasi, encoding cl100k_base — cukup akurat untuk OpenAI & estimasi umum). */
3
+ export function hitungToken(teks) {
4
+ if (!teks)
5
+ return 0;
6
+ try {
7
+ return encode(teks).length;
8
+ }
9
+ catch {
10
+ // Cadangan kasar bila tokenizer gagal: ~4 karakter per token.
11
+ return Math.ceil(teks.length / 4);
12
+ }
13
+ }
14
+ export function formatAngka(n) {
15
+ return n.toLocaleString("id-ID");
16
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ {"gaya":{"jawaban":"poin-penting","maksTokenJawaban":800},"providers":[{"anggaranHarian":500000,"apiKey":"","baseUrl":"https://api.openai.com/v1","jedaCooldownMenit":15,"model":"gpt-4o-mini","nama":"gpt"},{"apiKey":"env:GEMINI_API_KEY","baseUrl":"https://generativelanguage.googleapis.com/v1beta/openai/","jedaCooldownMenit":15,"model":"gemini-2.5-flash","nama":"gemini"},{"apiKey":"env:DEEPSEEK_API_KEY","baseUrl":"https://api.deepseek.com/v1","extraBody":{},"jedaCooldownMenit":15,"model":"deepseek-chat","nama":"deepseek"},{"apiKey":"env:OPENROUTER_API_KEY","baseUrl":"https://openrouter.ai/api/v1","model":"openai/gpt-4o-mini","nama":"openrouter"},{"apiKey":"ollama","baseUrl":"http://localhost:11434/v1","model":"llama3.1","nama":"ollama-lokal"}]}
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
- {
2
- "name": "@chispear/vibekit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "bin": {
3
+ "vibekit": "dist/cli.js"
4
+ },
5
+ "dependencies": {
6
+ "gpt-tokenizer": "^2.5.0"
7
+ },
8
+ "description": "VibeKit CLI — router AI hemat token: banyak penyedia model dalam satu konfigurasi, failover otomatis saat kuota habis, jawaban ringkas poin-poin penting.",
9
+ "devDependencies": {
10
+ "@types/node": "^22.0.0",
11
+ "typescript": "^5.6.0"
12
+ },
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "examples"
20
+ ],
21
+ "license": "MIT",
22
+ "main": "dist/cli.js",
23
+ "name": "@chispear/vibekit",
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.json",
26
+ "prepublishOnly": "npm run build",
27
+ "start": "node dist/cli.js",
28
+ "test": "npm run build && node --test test/*.test.mjs"
29
+ },
30
+ "type": "module",
31
+ "version": "0.1.0",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }