joomha 1.0.0__py3-none-any.whl
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.
- joomha/__init__.py +3 -0
- joomha/cli.py +595 -0
- joomha/config.py +277 -0
- joomha/indexer/__init__.py +1 -0
- joomha/indexer/ast_parser.py +206 -0
- joomha/indexer/git_analyzer.py +96 -0
- joomha/indexer/parsers/__init__.py +1 -0
- joomha/indexer/parsers/base.py +30 -0
- joomha/indexer/parsers/go_parser.py +124 -0
- joomha/indexer/parsers/javascript_parser.py +171 -0
- joomha/indexer/parsers/python_parser.py +144 -0
- joomha/indexer/parsers/rust_parser.py +138 -0
- joomha/indexer/parsers/typescript_parser.py +189 -0
- joomha/indexer/vector_builder.py +135 -0
- joomha/llm/__init__.py +1 -0
- joomha/llm/client.py +176 -0
- joomha/llm/prompt_builder.py +83 -0
- joomha/orchestrator.py +203 -0
- joomha/retriever/__init__.py +1 -0
- joomha/retriever/graph.py +281 -0
- joomha/retriever/vector.py +41 -0
- joomha/ui/__init__.py +1 -0
- joomha/ui/display.py +125 -0
- joomha/ui/input_handler.py +35 -0
- joomha-1.0.0.dist-info/METADATA +237 -0
- joomha-1.0.0.dist-info/RECORD +53 -0
- joomha-1.0.0.dist-info/WHEEL +5 -0
- joomha-1.0.0.dist-info/entry_points.txt +3 -0
- joomha-1.0.0.dist-info/top_level.txt +2 -0
- joomhalab/__init__.py +3 -0
- joomhalab/cli.py +714 -0
- joomhalab/config.py +291 -0
- joomhalab/indexer/__init__.py +1 -0
- joomhalab/indexer/ast_parser.py +206 -0
- joomhalab/indexer/git_analyzer.py +96 -0
- joomhalab/indexer/parsers/__init__.py +1 -0
- joomhalab/indexer/parsers/base.py +30 -0
- joomhalab/indexer/parsers/go_parser.py +124 -0
- joomhalab/indexer/parsers/javascript_parser.py +171 -0
- joomhalab/indexer/parsers/python_parser.py +144 -0
- joomhalab/indexer/parsers/rust_parser.py +138 -0
- joomhalab/indexer/parsers/typescript_parser.py +189 -0
- joomhalab/indexer/vector_builder.py +135 -0
- joomhalab/llm/__init__.py +1 -0
- joomhalab/llm/client.py +206 -0
- joomhalab/llm/prompt_builder.py +130 -0
- joomhalab/orchestrator.py +208 -0
- joomhalab/retriever/__init__.py +1 -0
- joomhalab/retriever/graph.py +281 -0
- joomhalab/retriever/vector.py +41 -0
- joomhalab/ui/__init__.py +1 -0
- joomhalab/ui/display.py +125 -0
- joomhalab/ui/input_handler.py +35 -0
joomha/__init__.py
ADDED
joomha/cli.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
warnings.filterwarnings("ignore", category=UserWarning)
|
|
3
|
+
warnings.filterwarnings("ignore", category=FutureWarning)
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import logging
|
|
7
|
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
|
8
|
+
os.environ["TRANSFORMERS_VERBOSITY"] = "error"
|
|
9
|
+
logging.getLogger("sentence_transformers").setLevel(logging.ERROR)
|
|
10
|
+
logging.getLogger("transformers").setLevel(logging.ERROR)
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
17
|
+
|
|
18
|
+
from joomha import __version__
|
|
19
|
+
from joomha.config import (
|
|
20
|
+
get_api_key,
|
|
21
|
+
set_api_key,
|
|
22
|
+
get_active_provider,
|
|
23
|
+
set_active_provider,
|
|
24
|
+
get_active_model,
|
|
25
|
+
set_active_model,
|
|
26
|
+
get_all_configured_providers,
|
|
27
|
+
get_custom_base_url,
|
|
28
|
+
set_custom_base_url,
|
|
29
|
+
PROVIDER_ENV_KEYS,
|
|
30
|
+
MODEL_REGISTRY,
|
|
31
|
+
OPEN_MODEL_PROVIDERS,
|
|
32
|
+
ensure_joomha_gitignore,
|
|
33
|
+
get_hf_token,
|
|
34
|
+
)
|
|
35
|
+
from joomha.ui.display import (
|
|
36
|
+
show_banner,
|
|
37
|
+
show_answer,
|
|
38
|
+
show_hotspots,
|
|
39
|
+
show_help,
|
|
40
|
+
show_mode_change,
|
|
41
|
+
show_error,
|
|
42
|
+
show_info,
|
|
43
|
+
console as display_console,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Aplikasi CLI Typer
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
app = typer.Typer(
|
|
51
|
+
name="joomha-AI",
|
|
52
|
+
help="AI-powered CLI for understanding any codebase through conversation.",
|
|
53
|
+
add_completion=False,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
config_app = typer.Typer(help="Manage API keys and configuration.")
|
|
57
|
+
app.add_typer(config_app, name="config")
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Konstanta
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
JOOMHA_DIR = ".joomha"
|
|
64
|
+
DB_NAME = "index.db"
|
|
65
|
+
LANCEDB_DIR = "lancedb"
|
|
66
|
+
HISTORY_FILE = "input_history.txt"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Fungsi pembantu
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _get_paths(repo_root: Path):
|
|
74
|
+
"""Dapatkan path file internal"""
|
|
75
|
+
joomha_dir = repo_root / JOOMHA_DIR
|
|
76
|
+
db_path = str(joomha_dir / DB_NAME)
|
|
77
|
+
lancedb_dir = str(joomha_dir / LANCEDB_DIR)
|
|
78
|
+
history_path = str(joomha_dir / HISTORY_FILE)
|
|
79
|
+
return joomha_dir, db_path, lancedb_dir, history_path
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _run_indexing(
|
|
83
|
+
repo_root: Path, joomha_dir: Path, db_path: str, lancedb_dir: str
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Jalankan indexing menyeluruh"""
|
|
86
|
+
from joomha.indexer.ast_parser import init_db, parse_repo
|
|
87
|
+
from joomha.indexer.git_analyzer import analyze_git
|
|
88
|
+
from joomha.indexer.vector_builder import build_vectors
|
|
89
|
+
|
|
90
|
+
joomha_dir.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
|
|
92
|
+
# Pastikan .gitignore dibuat
|
|
93
|
+
ensure_joomha_gitignore(joomha_dir)
|
|
94
|
+
|
|
95
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
|
96
|
+
|
|
97
|
+
with Progress(
|
|
98
|
+
SpinnerColumn(),
|
|
99
|
+
TextColumn("[progress.description]{task.description}"),
|
|
100
|
+
BarColumn(),
|
|
101
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
102
|
+
console=display_console,
|
|
103
|
+
) as progress:
|
|
104
|
+
# 1. Persiapan Database
|
|
105
|
+
task = progress.add_task("[cyan]Inisialisasi database...", total=1)
|
|
106
|
+
conn = init_db(db_path)
|
|
107
|
+
progress.update(task, advance=1, description="[green]✓ Database siap")
|
|
108
|
+
|
|
109
|
+
# Ekstraksi AST
|
|
110
|
+
task = progress.add_task("[cyan]Parsing AST kode sumber...", total=100)
|
|
111
|
+
def ast_cb(count, total):
|
|
112
|
+
progress.update(task, completed=count, total=total)
|
|
113
|
+
if count > 0 and count == total:
|
|
114
|
+
progress.update(task, description=f"[green]✓ AST parsed: {total} file")
|
|
115
|
+
|
|
116
|
+
file_count = parse_repo(repo_root, conn, progress_callback=ast_cb)
|
|
117
|
+
|
|
118
|
+
# Analisis Git
|
|
119
|
+
task = progress.add_task("[cyan]Menganalisis riwayat git...", total=100)
|
|
120
|
+
def git_cb(count, total):
|
|
121
|
+
progress.update(task, completed=count, total=total)
|
|
122
|
+
if count > 0 and count == total:
|
|
123
|
+
progress.update(task, description=f"[green]✓ Git analyzed: {total} commit")
|
|
124
|
+
|
|
125
|
+
commit_count = analyze_git(repo_root, conn, progress_callback=git_cb)
|
|
126
|
+
conn.close()
|
|
127
|
+
|
|
128
|
+
# Pembuatan vektor
|
|
129
|
+
task = progress.add_task(
|
|
130
|
+
"[cyan]Membangun vector embeddings...", total=100
|
|
131
|
+
)
|
|
132
|
+
def vec_cb(count, total):
|
|
133
|
+
progress.update(task, completed=count, total=total)
|
|
134
|
+
if count > 0 and count == total:
|
|
135
|
+
progress.update(task, description=f"[green]✓ Vectors built: {total} chunks")
|
|
136
|
+
|
|
137
|
+
chunk_count = build_vectors(repo_root, lancedb_dir, progress_callback=vec_cb)
|
|
138
|
+
|
|
139
|
+
show_info(
|
|
140
|
+
f"Indexing selesai! "
|
|
141
|
+
f"({file_count} file, {commit_count} commit, {chunk_count} chunks)"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# Pembantu ubah provider
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def _show_provider_menu(orchestrator) -> None:
|
|
150
|
+
"""Tampilkan menu pemilihan provider/model"""
|
|
151
|
+
from rich.table import Table
|
|
152
|
+
|
|
153
|
+
configured = get_all_configured_providers()
|
|
154
|
+
active_provider = orchestrator.llm_client.provider
|
|
155
|
+
active_model = orchestrator.llm_client.model_id
|
|
156
|
+
|
|
157
|
+
if not configured:
|
|
158
|
+
show_error("Belum ada provider yang dikonfigurasi.")
|
|
159
|
+
show_info("Jalankan: joomha config set <provider> <api_key>")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
# Tampilkan status saat ini
|
|
163
|
+
display_console.print(
|
|
164
|
+
f"\n[bold cyan]Provider aktif:[/bold cyan] {active_provider} "
|
|
165
|
+
f"([green]{active_model}[/green])\n"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Tampilkan model provider
|
|
169
|
+
table = Table(title="Provider & Model Tersedia", show_lines=True)
|
|
170
|
+
table.add_column("#", style="dim", width=3)
|
|
171
|
+
table.add_column("Provider", style="cyan")
|
|
172
|
+
table.add_column("Model", style="white")
|
|
173
|
+
table.add_column("Tier", style="dim")
|
|
174
|
+
table.add_column("Status", width=8)
|
|
175
|
+
|
|
176
|
+
idx = 1
|
|
177
|
+
choices = {} # [INFO] idx → (provider, model_id)
|
|
178
|
+
for provider in configured:
|
|
179
|
+
models = MODEL_REGISTRY.get(provider, [{"id": "default", "label": "Default", "tier": "—"}])
|
|
180
|
+
for m in models:
|
|
181
|
+
is_active = (provider == active_provider and m["id"] == active_model)
|
|
182
|
+
status = "[green]● aktif[/green]" if is_active else ""
|
|
183
|
+
table.add_row(
|
|
184
|
+
str(idx),
|
|
185
|
+
provider,
|
|
186
|
+
m["label"],
|
|
187
|
+
m.get("tier", ""),
|
|
188
|
+
status,
|
|
189
|
+
)
|
|
190
|
+
choices[idx] = (provider, m["id"])
|
|
191
|
+
idx += 1
|
|
192
|
+
|
|
193
|
+
display_console.print(table)
|
|
194
|
+
display_console.print(
|
|
195
|
+
"\n[dim]Ketik nomor untuk memilih, ATAU ketik langsung nama model (jika provider mendukung custom ID). ENTER batal:[/dim]"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _handle_provider_switch(user_input: str, orchestrator) -> bool:
|
|
200
|
+
"""Tangani input nomor pemilihan provider"""
|
|
201
|
+
configured = get_all_configured_providers()
|
|
202
|
+
choices = {}
|
|
203
|
+
idx = 1
|
|
204
|
+
for provider in configured:
|
|
205
|
+
models = MODEL_REGISTRY.get(provider, [{"id": "default", "label": "Default", "tier": "—"}])
|
|
206
|
+
for m in models:
|
|
207
|
+
choices[idx] = (provider, m["id"])
|
|
208
|
+
idx += 1
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
choice = int(user_input.strip())
|
|
212
|
+
except ValueError:
|
|
213
|
+
custom_id = user_input.strip()
|
|
214
|
+
if not custom_id:
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
current_provider = orchestrator.llm_client.provider
|
|
218
|
+
configured = get_all_configured_providers()
|
|
219
|
+
|
|
220
|
+
# Deteksi pintar untuk OpenRouter
|
|
221
|
+
# Gunakan ID model OpenRouter
|
|
222
|
+
if "/" in custom_id and "openrouter" in configured and current_provider != "openrouter":
|
|
223
|
+
current_provider = "openrouter"
|
|
224
|
+
|
|
225
|
+
# Anggap custom model ID jika bukan angka
|
|
226
|
+
# Pastikan dukungan model terbuka
|
|
227
|
+
if current_provider in OPEN_MODEL_PROVIDERS:
|
|
228
|
+
orchestrator.llm_client.switch(current_provider, custom_id)
|
|
229
|
+
set_active_provider(current_provider)
|
|
230
|
+
set_active_model(current_provider, custom_id)
|
|
231
|
+
show_info(f"✓ Beralih ke custom model: {current_provider} ({custom_id})")
|
|
232
|
+
return True
|
|
233
|
+
else:
|
|
234
|
+
show_error("Input harus berupa angka dari daftar di atas. Untuk string kustom, gunakan provider seperti OpenRouter.")
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
if choice not in choices:
|
|
238
|
+
show_error(f"Pilihan tidak valid: {choice}")
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
new_provider, new_model = choices[choice]
|
|
242
|
+
try:
|
|
243
|
+
orchestrator.llm_client.switch(new_provider, new_model)
|
|
244
|
+
set_active_provider(new_provider)
|
|
245
|
+
set_active_model(new_provider, new_model)
|
|
246
|
+
show_info(f"✓ Beralih ke: {new_provider} ({new_model})")
|
|
247
|
+
return True
|
|
248
|
+
except ValueError as e:
|
|
249
|
+
show_error(str(e))
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _handle_slash_command(user_input: str, orchestrator) -> Optional[bool]:
|
|
254
|
+
"""[PENANDA]"""
|
|
255
|
+
|
|
256
|
+
cmd = user_input.strip().lower()
|
|
257
|
+
|
|
258
|
+
if cmd in ("/q", "/quit"):
|
|
259
|
+
display_console.print("\n[dim]Sampai jumpa![/dim]")
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
if cmd in ("/help", "/"):
|
|
263
|
+
show_help()
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
if cmd == "/hotspots":
|
|
267
|
+
data = orchestrator.get_hotspots()
|
|
268
|
+
show_hotspots(data)
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
if cmd.startswith("/mode"):
|
|
272
|
+
parts = cmd.split()
|
|
273
|
+
if len(parts) == 2:
|
|
274
|
+
mode = parts[1]
|
|
275
|
+
msg = orchestrator.set_mode(mode)
|
|
276
|
+
if "diubah" in msg:
|
|
277
|
+
show_mode_change(mode)
|
|
278
|
+
else:
|
|
279
|
+
show_error(msg)
|
|
280
|
+
else:
|
|
281
|
+
show_error("Gunakan: /mode vector|graph|compare")
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
if cmd == "/provider":
|
|
285
|
+
_show_provider_menu(orchestrator)
|
|
286
|
+
# Set flag untuk input pilihan angka
|
|
287
|
+
orchestrator._awaiting_provider_choice = True
|
|
288
|
+
return True
|
|
289
|
+
|
|
290
|
+
if cmd == "/info":
|
|
291
|
+
provider = orchestrator.llm_client.provider
|
|
292
|
+
model = orchestrator.llm_client.model_id
|
|
293
|
+
mode = orchestrator.current_mode
|
|
294
|
+
show_info(f"Provider: {provider} │ Model: {model} │ Mode: {mode}")
|
|
295
|
+
return True
|
|
296
|
+
|
|
297
|
+
# Perintah tidak dikenali
|
|
298
|
+
show_error(f"Perintah tidak dikenal: {cmd}. Ketik /help untuk bantuan.")
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
# Sub-perintah konfigurasi
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
@config_app.command("set")
|
|
307
|
+
def config_set(
|
|
308
|
+
provider: str = typer.Argument(
|
|
309
|
+
..., help="Provider: gemini, openai, anthropic, deepseek, openrouter, custom, hf_token"
|
|
310
|
+
),
|
|
311
|
+
key: str = typer.Argument(..., help="API key"),
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Atur API key provider"""
|
|
314
|
+
if provider not in PROVIDER_ENV_KEYS and provider != "hf_token":
|
|
315
|
+
valid = ", ".join(list(PROVIDER_ENV_KEYS.keys()) + ["hf_token"])
|
|
316
|
+
show_error(
|
|
317
|
+
f"Provider tidak valid: {provider}. "
|
|
318
|
+
f"Gunakan: {valid}"
|
|
319
|
+
)
|
|
320
|
+
raise typer.Exit(1)
|
|
321
|
+
set_api_key(provider, key)
|
|
322
|
+
show_info(f"API key untuk '{provider}' berhasil disimpan.")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@config_app.command("model")
|
|
326
|
+
def config_model(
|
|
327
|
+
provider: str = typer.Argument(
|
|
328
|
+
..., help="Provider name (e.g. gemini, openai)"
|
|
329
|
+
),
|
|
330
|
+
model_id: str = typer.Argument(
|
|
331
|
+
..., help="Model ID (e.g. gemini-2.5-pro, gpt-4o)"
|
|
332
|
+
),
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Set model default untuk provider"""
|
|
335
|
+
if provider not in MODEL_REGISTRY:
|
|
336
|
+
show_error(f"Provider tidak dikenal: {provider}")
|
|
337
|
+
raise typer.Exit(1)
|
|
338
|
+
|
|
339
|
+
if provider not in OPEN_MODEL_PROVIDERS:
|
|
340
|
+
valid_ids = [m["id"] for m in MODEL_REGISTRY[provider]]
|
|
341
|
+
if model_id not in valid_ids:
|
|
342
|
+
show_error(f"Model '{model_id}' tidak tersedia untuk {provider}.")
|
|
343
|
+
show_info(f"Pilihan: {', '.join(valid_ids)}")
|
|
344
|
+
raise typer.Exit(1)
|
|
345
|
+
|
|
346
|
+
set_active_model(provider, model_id)
|
|
347
|
+
show_info(f"Model default untuk '{provider}' diubah ke: {model_id}")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@config_app.command("use")
|
|
351
|
+
def config_use(
|
|
352
|
+
provider: str = typer.Argument(
|
|
353
|
+
..., help="Provider to set as active default"
|
|
354
|
+
),
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Atur provider aktif default"""
|
|
357
|
+
if provider not in PROVIDER_ENV_KEYS:
|
|
358
|
+
valid = ", ".join(PROVIDER_ENV_KEYS.keys())
|
|
359
|
+
show_error(f"Provider tidak valid: {provider}. Gunakan: {valid}")
|
|
360
|
+
raise typer.Exit(1)
|
|
361
|
+
if not get_api_key(provider):
|
|
362
|
+
show_error(f"API key belum diset untuk '{provider}'.")
|
|
363
|
+
show_info(f"Jalankan dulu: joomha config set {provider} <key>")
|
|
364
|
+
raise typer.Exit(1)
|
|
365
|
+
set_active_provider(provider)
|
|
366
|
+
model = get_active_model(provider)
|
|
367
|
+
show_info(f"Provider aktif diubah ke: {provider} (model: {model})")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@config_app.command("base-url")
|
|
371
|
+
def config_base_url(
|
|
372
|
+
url: str = typer.Argument(..., help="Base URL for custom provider"),
|
|
373
|
+
) -> None:
|
|
374
|
+
"""Atur URL OpenAI custom"""
|
|
375
|
+
set_custom_base_url(url)
|
|
376
|
+
show_info(f"Custom base URL diset ke: {url}")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@config_app.command("show")
|
|
380
|
+
def config_show() -> None:
|
|
381
|
+
"""Tampilkan semua provider terpasang"""
|
|
382
|
+
from rich.table import Table
|
|
383
|
+
|
|
384
|
+
configured = get_all_configured_providers()
|
|
385
|
+
active = get_active_provider()
|
|
386
|
+
|
|
387
|
+
if not configured:
|
|
388
|
+
show_info("Belum ada provider yang dikonfigurasi.")
|
|
389
|
+
show_info("Jalankan: joomha config set <provider> <api_key>")
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
table = Table(title="Konfigurasi Joomha", show_lines=True)
|
|
393
|
+
table.add_column("Provider", style="cyan")
|
|
394
|
+
table.add_column("API Key", width=12)
|
|
395
|
+
table.add_column("Model Aktif", style="white")
|
|
396
|
+
table.add_column("Status")
|
|
397
|
+
|
|
398
|
+
for provider in PROVIDER_ENV_KEYS:
|
|
399
|
+
has_key = provider in configured
|
|
400
|
+
model = get_active_model(provider) if has_key else "—"
|
|
401
|
+
key_status = "[green]✓ tersedia[/green]" if has_key else "[dim]✗ belum diatur[/dim]"
|
|
402
|
+
active_marker = "[bold green]● AKTIF[/bold green]" if provider == active else ""
|
|
403
|
+
table.add_row(provider, key_status, model, active_marker)
|
|
404
|
+
|
|
405
|
+
# Tambahkan baris untuk HF Token
|
|
406
|
+
hf_token_val = get_hf_token()
|
|
407
|
+
hf_status = "[green]✓ tersedia[/green]" if hf_token_val else "[dim]✗ belum diatur[/dim]"
|
|
408
|
+
table.add_row("hf_token", hf_status, "—", "")
|
|
409
|
+
|
|
410
|
+
display_console.print(table)
|
|
411
|
+
|
|
412
|
+
# Tampilkan URL dasar custom jika ada
|
|
413
|
+
custom_url = get_custom_base_url()
|
|
414
|
+
if custom_url:
|
|
415
|
+
show_info(f"Custom base URL: {custom_url}")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
# Perintah Utama
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
@app.callback(invoke_without_command=True)
|
|
423
|
+
def main(
|
|
424
|
+
ctx: typer.Context,
|
|
425
|
+
reindex: bool = typer.Option(
|
|
426
|
+
False, "--reindex", help="Paksa re-index repositori"
|
|
427
|
+
),
|
|
428
|
+
version: bool = typer.Option(
|
|
429
|
+
False, "--version", "-v", help="Tampilkan versi"
|
|
430
|
+
),
|
|
431
|
+
provider: Optional[str] = typer.Option(
|
|
432
|
+
None, "--provider", "-p", help="Override provider (gemini/openai/anthropic/deepseek/openrouter/custom)"
|
|
433
|
+
),
|
|
434
|
+
model: Optional[str] = typer.Option(
|
|
435
|
+
None, "--model", "-m", help="Override model ID"
|
|
436
|
+
),
|
|
437
|
+
) -> None:
|
|
438
|
+
"""[PENANDA]"""
|
|
439
|
+
|
|
440
|
+
# Lewati REPL jika subcommand digunakan
|
|
441
|
+
if ctx.invoked_subcommand is not None:
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
if version:
|
|
445
|
+
display_console.print(f"joomha v{__version__}")
|
|
446
|
+
raise typer.Exit()
|
|
447
|
+
|
|
448
|
+
repo_root = Path.cwd()
|
|
449
|
+
|
|
450
|
+
# [INFO] ── Pre-flight checks ─────────────────────────────────────────────
|
|
451
|
+
if not (repo_root / ".git").exists():
|
|
452
|
+
show_error("Direktori ini bukan repositori Git.")
|
|
453
|
+
show_info("Jalankan 'joomha' di dalam direktori yang memiliki .git")
|
|
454
|
+
raise typer.Exit(1)
|
|
455
|
+
|
|
456
|
+
# Tentukan provider dari konfigurasi
|
|
457
|
+
active_prov = provider or get_active_provider()
|
|
458
|
+
if not get_api_key(active_prov):
|
|
459
|
+
show_error(f"API key belum diatur untuk provider '{active_prov}'.")
|
|
460
|
+
env_key = PROVIDER_ENV_KEYS.get(active_prov, "")
|
|
461
|
+
if env_key:
|
|
462
|
+
show_info(f"Set via: export {env_key}=<key>")
|
|
463
|
+
show_info(f"Atau: joomha config set {active_prov} <key>")
|
|
464
|
+
|
|
465
|
+
# Tampilkan provider alternatif
|
|
466
|
+
others = get_all_configured_providers()
|
|
467
|
+
if others:
|
|
468
|
+
show_info(f"Provider lain yang sudah dikonfigurasi: {', '.join(others)}")
|
|
469
|
+
show_info(f"Gunakan: joomha --provider {others[0]}")
|
|
470
|
+
else:
|
|
471
|
+
display_console.print("\n[dim]ℹ Ingin menggunakan provider lain (openai, anthropic, deepseek, openrouter)?[/dim]")
|
|
472
|
+
display_console.print("[dim] 1. Simpan key: [/dim][cyan]joomha config set <provider> <key>[/cyan]")
|
|
473
|
+
display_console.print("[dim] 2. Jadikan default: [/dim][cyan]joomha config use <provider>[/cyan]")
|
|
474
|
+
|
|
475
|
+
raise typer.Exit(1)
|
|
476
|
+
|
|
477
|
+
# [INFO] ── Startup ───────────────────────────────────────────────────────
|
|
478
|
+
show_banner()
|
|
479
|
+
|
|
480
|
+
hf_token = get_hf_token()
|
|
481
|
+
if hf_token:
|
|
482
|
+
os.environ["HF_TOKEN"] = hf_token
|
|
483
|
+
|
|
484
|
+
joomha_dir, db_path, lancedb_dir, history_path = _get_paths(repo_root)
|
|
485
|
+
|
|
486
|
+
if reindex or not joomha_dir.exists():
|
|
487
|
+
show_info("Memulai indexing repositori...")
|
|
488
|
+
_run_indexing(repo_root, joomha_dir, db_path, lancedb_dir)
|
|
489
|
+
else:
|
|
490
|
+
# Beri peringatan jika data lawas
|
|
491
|
+
db_file = joomha_dir / "index.db"
|
|
492
|
+
if db_file.exists():
|
|
493
|
+
idx_mtime = db_file.stat().st_mtime
|
|
494
|
+
newest_src = 0.0
|
|
495
|
+
for ext in (".py", ".js", ".ts", ".jsx", ".tsx"):
|
|
496
|
+
for f in repo_root.rglob(f"*{ext}"):
|
|
497
|
+
try:
|
|
498
|
+
mt = f.stat().st_mtime
|
|
499
|
+
if mt > newest_src:
|
|
500
|
+
newest_src = mt
|
|
501
|
+
except OSError:
|
|
502
|
+
pass
|
|
503
|
+
if newest_src > idx_mtime:
|
|
504
|
+
show_info(
|
|
505
|
+
"⚠ Terdeteksi file yang lebih baru dari index. "
|
|
506
|
+
"Pertimbangkan --reindex untuk hasil akurat."
|
|
507
|
+
)
|
|
508
|
+
else:
|
|
509
|
+
show_info("Index ditemukan. Gunakan --reindex untuk memperbarui.")
|
|
510
|
+
else:
|
|
511
|
+
show_info("Index ditemukan. Gunakan --reindex untuk memperbarui.")
|
|
512
|
+
|
|
513
|
+
# [INFO] ── Init orchestrator ─────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
display_console.print("[cyan]Menghidupkan mesin AI (Memuat model dari disk/unduhan, harap tunggu)...[/cyan]")
|
|
517
|
+
from joomha.orchestrator import Orchestrator
|
|
518
|
+
|
|
519
|
+
orchestrator = Orchestrator(
|
|
520
|
+
str(repo_root), db_path, lancedb_dir,
|
|
521
|
+
provider=active_prov, model=model,
|
|
522
|
+
)
|
|
523
|
+
except ValueError as e:
|
|
524
|
+
show_error(str(e))
|
|
525
|
+
raise typer.Exit(1)
|
|
526
|
+
|
|
527
|
+
# [INFO] ── Input session ─────────────────────────────────────────────────
|
|
528
|
+
from joomha.ui.input_handler import create_session
|
|
529
|
+
|
|
530
|
+
session = create_session(history_path)
|
|
531
|
+
|
|
532
|
+
llm_info = orchestrator.llm_client.info()
|
|
533
|
+
show_info(f"Mode: {orchestrator.current_mode} │ LLM: {llm_info}")
|
|
534
|
+
show_info("Ketik pertanyaan, /help untuk bantuan, atau /provider untuk ganti LLM.\n")
|
|
535
|
+
|
|
536
|
+
# [INFO] ── REPL loop ─────────────────────────────────────────────────────
|
|
537
|
+
try:
|
|
538
|
+
while True:
|
|
539
|
+
try:
|
|
540
|
+
mode_label = orchestrator.current_mode
|
|
541
|
+
prov_short = orchestrator.llm_client.provider
|
|
542
|
+
prompt_text = f"[{mode_label}│{prov_short}] ❯ "
|
|
543
|
+
user_input = session.prompt(prompt_text)
|
|
544
|
+
except EOFError:
|
|
545
|
+
break
|
|
546
|
+
|
|
547
|
+
if not user_input.strip():
|
|
548
|
+
continue
|
|
549
|
+
|
|
550
|
+
# Menunggu pilihan provider
|
|
551
|
+
if getattr(orchestrator, '_awaiting_provider_choice', False):
|
|
552
|
+
orchestrator._awaiting_provider_choice = False
|
|
553
|
+
user_input_clean = user_input.strip()
|
|
554
|
+
|
|
555
|
+
# Hapus tanda miring sebelum angka pilihan
|
|
556
|
+
if user_input_clean.startswith("/") and user_input_clean[1:].isdigit():
|
|
557
|
+
user_input_clean = user_input_clean[1:]
|
|
558
|
+
|
|
559
|
+
# Izinkan command lain lolos
|
|
560
|
+
if user_input_clean.startswith("/") and not user_input_clean[1:].isdigit():
|
|
561
|
+
user_input = user_input_clean # Lanjut ke handler perintah slash
|
|
562
|
+
else:
|
|
563
|
+
_handle_provider_switch(user_input_clean, orchestrator)
|
|
564
|
+
continue
|
|
565
|
+
|
|
566
|
+
# Perintah slash
|
|
567
|
+
if user_input.strip().startswith("/"):
|
|
568
|
+
result = _handle_slash_command(user_input, orchestrator)
|
|
569
|
+
if result is False:
|
|
570
|
+
break
|
|
571
|
+
continue
|
|
572
|
+
|
|
573
|
+
# [INFO] Normal question → RAG pipeline
|
|
574
|
+
with Progress(
|
|
575
|
+
SpinnerColumn(),
|
|
576
|
+
TextColumn("[progress.description]{task.description}"),
|
|
577
|
+
console=display_console,
|
|
578
|
+
) as progress:
|
|
579
|
+
task = progress.add_task("[cyan]Berpikir...", total=None)
|
|
580
|
+
response = orchestrator.ask(user_input.strip())
|
|
581
|
+
progress.remove_task(task)
|
|
582
|
+
|
|
583
|
+
show_answer(
|
|
584
|
+
response["answer"],
|
|
585
|
+
response["mode_used"],
|
|
586
|
+
response["latency"],
|
|
587
|
+
response["context_count"],
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
except KeyboardInterrupt:
|
|
591
|
+
display_console.print("\n[dim]Sampai jumpa! [/dim]")
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
if __name__ == "__main__":
|
|
595
|
+
app()
|