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.
Files changed (53) hide show
  1. joomha/__init__.py +3 -0
  2. joomha/cli.py +595 -0
  3. joomha/config.py +277 -0
  4. joomha/indexer/__init__.py +1 -0
  5. joomha/indexer/ast_parser.py +206 -0
  6. joomha/indexer/git_analyzer.py +96 -0
  7. joomha/indexer/parsers/__init__.py +1 -0
  8. joomha/indexer/parsers/base.py +30 -0
  9. joomha/indexer/parsers/go_parser.py +124 -0
  10. joomha/indexer/parsers/javascript_parser.py +171 -0
  11. joomha/indexer/parsers/python_parser.py +144 -0
  12. joomha/indexer/parsers/rust_parser.py +138 -0
  13. joomha/indexer/parsers/typescript_parser.py +189 -0
  14. joomha/indexer/vector_builder.py +135 -0
  15. joomha/llm/__init__.py +1 -0
  16. joomha/llm/client.py +176 -0
  17. joomha/llm/prompt_builder.py +83 -0
  18. joomha/orchestrator.py +203 -0
  19. joomha/retriever/__init__.py +1 -0
  20. joomha/retriever/graph.py +281 -0
  21. joomha/retriever/vector.py +41 -0
  22. joomha/ui/__init__.py +1 -0
  23. joomha/ui/display.py +125 -0
  24. joomha/ui/input_handler.py +35 -0
  25. joomha-1.0.0.dist-info/METADATA +237 -0
  26. joomha-1.0.0.dist-info/RECORD +53 -0
  27. joomha-1.0.0.dist-info/WHEEL +5 -0
  28. joomha-1.0.0.dist-info/entry_points.txt +3 -0
  29. joomha-1.0.0.dist-info/top_level.txt +2 -0
  30. joomhalab/__init__.py +3 -0
  31. joomhalab/cli.py +714 -0
  32. joomhalab/config.py +291 -0
  33. joomhalab/indexer/__init__.py +1 -0
  34. joomhalab/indexer/ast_parser.py +206 -0
  35. joomhalab/indexer/git_analyzer.py +96 -0
  36. joomhalab/indexer/parsers/__init__.py +1 -0
  37. joomhalab/indexer/parsers/base.py +30 -0
  38. joomhalab/indexer/parsers/go_parser.py +124 -0
  39. joomhalab/indexer/parsers/javascript_parser.py +171 -0
  40. joomhalab/indexer/parsers/python_parser.py +144 -0
  41. joomhalab/indexer/parsers/rust_parser.py +138 -0
  42. joomhalab/indexer/parsers/typescript_parser.py +189 -0
  43. joomhalab/indexer/vector_builder.py +135 -0
  44. joomhalab/llm/__init__.py +1 -0
  45. joomhalab/llm/client.py +206 -0
  46. joomhalab/llm/prompt_builder.py +130 -0
  47. joomhalab/orchestrator.py +208 -0
  48. joomhalab/retriever/__init__.py +1 -0
  49. joomhalab/retriever/graph.py +281 -0
  50. joomhalab/retriever/vector.py +41 -0
  51. joomhalab/ui/__init__.py +1 -0
  52. joomhalab/ui/display.py +125 -0
  53. joomhalab/ui/input_handler.py +35 -0
joomha/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """[PENANDA]"""
2
+
3
+ __version__ = "1.0.0"
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()