@drico2008/fincli 0.1.3 → 0.1.9

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  FinCLI adalah financial CLI/TUI terminal modern untuk memantau market, mengelola watchlist, portfolio, journal, konfigurasi provider, dan menyiapkan integrasi AI market analysis secara modular.
4
4
 
5
- Status saat ini: Phase 23 MVP sudah aktif di atas fondasi Phase 1 sampai Phase 22.
5
+ Status saat ini: FinCLI MVP aktif dengan TUI, provider chain, AI assistance, web research, portfolio, journal, watchlist, export, dan session history lokal.
6
6
 
7
7
  - Textual TUI satu kolom dengan command palette inline yang bisa discroll; sidebar lama sudah dihapus agar output market lebih lega.
8
8
  - Slash command router dengan command wajib FinCLI v0.1.
@@ -148,12 +148,17 @@ python -m fincli.app.main
148
148
  /journal add BTC-USD bullish "Breakout gagal, tunggu konfirmasi"
149
149
  /journal stats
150
150
  /journal review
151
- /price AAPL
151
+ /history
152
+ /history sessions
153
+ /history save "Riset market pagi"
154
+ /quote AAPL
152
155
  /technical BTC-USD 1d
153
156
  /technical XAUUSD 1d
154
157
  /technical EURUSD 1d
155
158
  /structure BTC-USD 1d
156
159
  /news AAPL
160
+ /web penyebab rupiah melemah hari ini
161
+ /web sources penyebab rupiah melemah hari ini
157
162
  /funda MSFT
158
163
  /yahoo BBRI history 6mo 1d
159
164
  /yahoo BBRI statistics
@@ -177,7 +182,7 @@ python -m fincli.app.main
177
182
  /exit
178
183
  ```
179
184
 
180
- Command `/market`, `/price`, `/quote`, `/technical`, `/structure`, `/news`, dan `/funda` sudah memakai provider chain aktif. Command `/ai` dan `/analyze` sudah memakai AI provider aktif dari `/ai_model` dan `.env`. `/analyze` membawa konteks indikator, struktur pasar, news, dan fundamental ringkas ke prompt AI. `/ai` juga mengambil quote, OHLCV/technical, structure, news, dan fundamental saat user menyebut symbol seperti `AAPL`, `EURUSD`, atau `XAUUSD`.
185
+ Command `/market`, `/quote`, `/technical`, `/structure`, `/news`, dan `/funda` sudah memakai provider chain aktif. Command `/ai` dan `/analyze` sudah memakai AI provider aktif dari `/ai_model` dan `.env`. `/analyze` membawa konteks indikator, struktur pasar, news, dan fundamental ringkas ke prompt AI. `/ai` juga mengambil quote, OHLCV/technical, structure, news, dan fundamental saat user menyebut symbol seperti `AAPL`, `EURUSD`, atau `XAUUSD`.
181
186
 
182
187
  ## AI Chat UX
183
188
 
@@ -207,8 +212,31 @@ AI assistant di dalam FinCLI dipersonalisasi untuk market workflow:
207
212
  - Boleh free chat untuk pertanyaan umum, market, portfolio, journal, provider, dan risk workflow.
208
213
  - Menolak coding/debugging/refactor/pembuatan software di dalam assistant FinCLI agar fokus app tetap jelas.
209
214
  - Jika prompt berisi symbol eksplisit, FinCLI menyisipkan market context dari provider chain aktif sebelum memanggil AI provider.
215
+ - Jika prompt membutuhkan info terkini, FinCLI dapat mengambil konteks web publik dan memasukkannya ke AI prompt.
210
216
  - Tidak membocorkan API key dan tidak mengklaim realtime jika provider aktif hanya delayed/fallback.
211
217
 
218
+ Contoh web-aware freechat:
219
+
220
+ ```text
221
+ apa yang menyebabkan penurunan rupiah terhadap semua mata uang hari ini
222
+ berita terbaru BI rate dan dampaknya ke IHSG
223
+ ```
224
+
225
+ Untuk web search yang dirangkum oleh AI:
226
+
227
+ ```text
228
+ /web penyebab rupiah melemah hari ini
229
+ /web update harga emas dan dollar index
230
+ ```
231
+
232
+ Untuk melihat sumber mentah tanpa ringkasan AI:
233
+
234
+ ```text
235
+ /web sources penyebab rupiah melemah hari ini
236
+ ```
237
+
238
+ FinCLI memakai lightweight HTTP web research, bukan Chrome automation. Ini lebih stabil untuk npm global install dan tidak membuka browser di background. Output tetap harus diverifikasi karena kualitas sumber web bisa berbeda-beda.
239
+
212
240
  ## Interactive AI Model Selector
213
241
 
214
242
  ```text
@@ -474,7 +502,7 @@ FinCLI memakai yfinance untuk akses saham global yang tersedia di Yahoo Finance.
474
502
  Command:
475
503
 
476
504
  ```text
477
- /price BBRI
505
+ /quote BBRI
478
506
  /technical BBRI 1d
479
507
  /analyze BBRI 1d
480
508
  /yahoo BBRI history 6mo 1d
@@ -643,8 +671,14 @@ FinCLI menyimpan data lokal di:
643
671
  ~/.fincli/fincli.log
644
672
  ```
645
673
 
646
- API key tidak disimpan di output terminal dan sebaiknya tetap berada di `.env` atau environment variable.
647
- Untuk install global via npm, API key paling praktis disimpan lewat command FinCLI di `~/.fincli/secrets.env`.
674
+ API key tidak disimpan di output terminal. Untuk install global via npm, jalur utama adalah command FinCLI:
675
+
676
+ ```text
677
+ /ai_model key groq <api_key>
678
+ /news_model key twelvedata <api_key>
679
+ ```
680
+
681
+ Key disimpan lokal di `~/.fincli/secrets.env`, dipakai otomatis untuk semua session FinCLI berikutnya, dan tidak perlu dikonfigurasi ulang. Jika `.env` lokal berisi nilai kosong, FinCLI tetap memakai secret lokal yang sudah tersimpan.
648
682
 
649
683
  ## Test
650
684
 
@@ -663,7 +697,7 @@ Hasil terakhir di environment ini:
663
697
  - `fincli` tidak dikenali: jalankan `pip install -e .` dari root project.
664
698
  - TUI tidak tampil rapi: perbesar terminal desktop.
665
699
  - API key tidak terbaca: gunakan `/ai_model key <provider> <api_key>` atau `/news_model key <provider> <api_key>`, lalu cek `/config` atau `/provider key status`.
666
- - `/price` gagal karena yfinance belum ada: jalankan `pip install -e ".[dev]"` atau `pip install -r requirements.txt`.
700
+ - `/quote` gagal karena yfinance belum ada: jalankan `pip install -e ".[dev]"` atau `pip install -r requirements.txt`.
667
701
  - Config rusak: hapus `~/.fincli/config.json` untuk kembali ke default.
668
702
 
669
703
  ## Roadmap Lanjutan
@@ -1,3 +1,3 @@
1
1
  """FinCLI package."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.9"
@@ -15,6 +15,7 @@ Identity and scope:
15
15
 
16
16
  Financial analysis rules:
17
17
  - Analyze from provided market context first. Do not invent prices, news, fundamentals, provider status, or certainty.
18
+ - If Web Research Context is provided, use it as current public context, mention source URLs, and separate sourced facts from interpretation.
18
19
  - Use probabilistic language: scenario, bias, confirmation, invalidation, risk, caution.
19
20
  - Do not promise profit and do not present aggressive entries as guaranteed signals.
20
21
  - For technical analysis, weigh trend, momentum, volatility, support/resistance, market structure, and data quality.
@@ -155,7 +156,32 @@ def build_fincli_assistant_prompt(user_prompt: str, market_context: str = "") ->
155
156
  f"{user_prompt.strip()}\n\n"
156
157
  "Instruction:\n"
157
158
  "- Answer the user's prompt directly.\n"
158
- "- If market context is present, cite provider/data-quality limitations.\n"
159
+ "- If market or web context is present, cite provider/data-quality limitations and source URLs when available.\n"
159
160
  "- If market context is missing and the user asks about an instrument, say what data is missing.\n"
160
161
  "- Keep the coding boundary enforced.\n"
161
162
  )
163
+
164
+
165
+ def build_web_research_answer_prompt(user_prompt: str, web_context: str) -> str:
166
+ """Build a prompt that turns gathered web context into an answer, not a source dump."""
167
+ context = web_context.strip() or "Web Research: no public web context returned."
168
+ return (
169
+ f"{FINCLI_ASSISTANT_SYSTEM_PROMPT}\n\n"
170
+ "Web Search Skill Result:\n"
171
+ f"{context}\n\n"
172
+ "User Prompt:\n"
173
+ f"{user_prompt.strip()}\n\n"
174
+ "Instruction:\n"
175
+ "- You already have web search context above. Do not answer by only listing articles or links.\n"
176
+ "- Synthesize the sources into a useful explanation/summary for the user.\n"
177
+ "- Prioritize facts found in the web context, then clearly label interpretation.\n"
178
+ "- If sources disagree or are thin, say that the evidence is limited.\n"
179
+ "- Use this output structure when relevant:\n"
180
+ " 1. Ringkasan singkat\n"
181
+ " 2. Poin utama/penyebab\n"
182
+ " 3. Dampak atau implikasi\n"
183
+ " 4. Risiko dan hal yang perlu diverifikasi\n"
184
+ " 5. Sumber singkat\n"
185
+ "- Keep source citations compact: source title or URL only where useful.\n"
186
+ "- Do not provide financial advice or certainty about market direction.\n"
187
+ )
@@ -26,7 +26,7 @@ class TechnicalSummary:
26
26
 
27
27
 
28
28
  def phase_one_indicator_status() -> str:
29
- return "Indicator engine scaffold ready. pandas/numpy/yfinance integration planned for Phase 2."
29
+ return "Indicator engine active: SMA, EMA, RSI, MACD, Bollinger Bands, ATR, volume, support, and resistance."
30
30
 
31
31
 
32
32
  def summarize_technical_indicators(candles: list[Candle]) -> TechnicalSummary:
@@ -20,7 +20,7 @@ class MarketStructureSummary:
20
20
 
21
21
 
22
22
  def phase_one_structure_status() -> str:
23
- return "Market structure scaffold ready. HH/HL/LH/LL detection planned for Phase 2."
23
+ return "Market structure engine active: HH/HL/LH/LL, BOS, CHoCH, liquidity area, and risk zone."
24
24
 
25
25
 
26
26
  def analyze_market_structure(candles: list[Candle], lookback: int = 20) -> MarketStructureSummary:
@@ -22,6 +22,8 @@ COMMANDS: tuple[CommandSpec, ...] = (
22
22
  CommandSpec("/news_model key", "Simpan API key market/news lokal.", "/news_model key finnhub <api_key>", "Provider"),
23
23
  CommandSpec("/market", "Ringkasan market profesional untuk instrumen.", "/market AAPL 1d", "Market"),
24
24
  CommandSpec("/news", "Tampilkan news/fundamental terbaru untuk instrumen.", "/news AAPL", "Market"),
25
+ CommandSpec("/web", "Web search lalu AI merangkum jawaban.", "/web penyebab rupiah melemah hari ini", "Research"),
26
+ CommandSpec("/web sources", "Tampilkan sumber mentah hasil web search.", "/web sources penyebab rupiah melemah hari ini", "Research"),
25
27
  CommandSpec("/technical", "Analisis teknikal instrumen.", "/technical BTC-USD 1d", "Analysis"),
26
28
  CommandSpec("/structure", "Analisis struktur pasar instrumen.", "/structure BTC-USD 1d", "Analysis"),
27
29
  CommandSpec("/funda", "Fundamental ringkas instrumen.", "/funda MSFT", "Market"),
@@ -41,9 +43,13 @@ COMMANDS: tuple[CommandSpec, ...] = (
41
43
  CommandSpec("/journal add", "Tambahkan catatan journal singkat.", '/journal add BTC-USD bullish "Breakout gagal, tunggu konfirmasi"', "Journal"),
42
44
  CommandSpec("/journal stats", "Tampilkan statistik journal.", "/journal stats", "Journal"),
43
45
  CommandSpec("/journal review", "AI review kebiasaan journal.", "/journal review", "Journal"),
46
+ CommandSpec("/history", "Tampilkan command history current session.", "/history", "History"),
47
+ CommandSpec("/history sessions", "Tampilkan daftar session tersimpan.", "/history sessions", "History"),
48
+ CommandSpec("/history show", "Tampilkan detail session tertentu.", "/history show <session_id>", "History"),
49
+ CommandSpec("/history save", "Beri nama current session.", '/history save "Riset IHSG pagi"', "History"),
50
+ CommandSpec("/history delete", "Hapus session tertentu.", "/history delete <session_id>", "History"),
44
51
  CommandSpec("/config", "Tampilkan konfigurasi aktif tanpa membocorkan API key.", "/config"),
45
- CommandSpec("/price", "Tampilkan harga instrumen.", "/price NVDA", "Market"),
46
- CommandSpec("/quote", "Alias harga/quote instrumen.", "/quote NVDA", "Market"),
52
+ CommandSpec("/quote", "Tampilkan harga/quote instrumen.", "/quote NVDA", "Market"),
47
53
  CommandSpec("/scan", "Scanner watchlist dengan filter indikator.", "/scan watchlist rsi<30", "Market"),
48
54
  CommandSpec("/calendar", "Economic calendar provider/fallback.", "/calendar week US high", "Market"),
49
55
  CommandSpec("/provider status", "Tampilkan status provider aktif.", "/provider status", "Provider"),
@@ -52,8 +58,8 @@ COMMANDS: tuple[CommandSpec, ...] = (
52
58
  CommandSpec("/provider key status", "Tampilkan status API key market provider.", "/provider key status", "Provider"),
53
59
  CommandSpec("/cache stats", "Tampilkan statistik cache market persistent.", "/cache stats", "System"),
54
60
  CommandSpec("/cache clear", "Bersihkan runtime dan persistent market cache.", "/cache clear", "System"),
55
- CommandSpec("/export journal", "Export journal. Phase 1 menyiapkan command.", "/export journal", "Export"),
56
- CommandSpec("/export portfolio", "Export portfolio. Phase 1 menyiapkan command.", "/export portfolio", "Export"),
61
+ CommandSpec("/export journal", "Export journal ke CSV/JSON.", "/export journal csv journal.csv", "Export"),
62
+ CommandSpec("/export portfolio", "Export portfolio ke CSV/JSON.", "/export portfolio json portfolio.json", "Export"),
57
63
  CommandSpec("/clear", "Bersihkan output terminal.", "/clear"),
58
64
  CommandSpec("/exit", "Keluar dari aplikasi.", "/exit"),
59
65
  )
@@ -6,16 +6,19 @@ import asyncio
6
6
  from concurrent.futures import ThreadPoolExecutor
7
7
  from dataclasses import dataclass
8
8
  from datetime import date
9
+ import io
9
10
  import os
10
11
  import shlex
11
12
  from typing import Any
12
13
 
14
+ from rich.console import Console
13
15
  from rich.panel import Panel
14
16
  from rich.table import Table
15
17
 
16
18
  from fincli.app.cli.commands import CommandRegistry
17
19
  from fincli.app.analysis.analyzer import build_market_analysis_prompt, build_technical_ai_summary
18
20
  from fincli.app.analysis.assistant_context import (
21
+ build_web_research_answer_prompt,
19
22
  build_fincli_assistant_prompt,
20
23
  coding_refusal,
21
24
  extract_market_symbols,
@@ -37,6 +40,7 @@ from fincli.app.modules.journal_analytics import JournalStats, build_journal_rev
37
40
  from fincli.app.modules.journal import JournalService
38
41
  from fincli.app.modules.portfolio import PortfolioService
39
42
  from fincli.app.modules.scanner import ScanResult, scan_symbols
43
+ from fincli.app.modules.session_history import SessionHistoryService
40
44
  from fincli.app.modules.transactions import TransactionService
41
45
  from fincli.app.modules.watchlist import WatchlistService
42
46
  from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
@@ -46,12 +50,19 @@ from fincli.app.providers.market.manager import MarketProviderManager
46
50
  from fincli.app.providers.market.yfinance_provider import YahooTable, YFinanceProvider
47
51
  from fincli.app.services.market_data import MarketDataService
48
52
  from fincli.app.services.market_overview import MarketOverview, build_market_overview
53
+ from fincli.app.services.web_research import (
54
+ WebResearchService,
55
+ WebSearchResult,
56
+ build_web_research_context,
57
+ should_use_web_research,
58
+ )
49
59
  from fincli.app.storage.cache import TTLCache
50
60
  from fincli.app.storage.config import ConfigManager
51
61
  from fincli.app.storage.database import FinCLIDatabase
52
62
  from fincli.app.storage.market_cache import MarketCache
53
63
  from fincli.app.storage.secrets import save_secret
54
64
  from fincli.app.utils.errors import CommandError, FinCLIError
65
+ from fincli.app.utils.formatting import AIResponseView, MarkdownBlock
55
66
 
56
67
 
57
68
  @dataclass(slots=True)
@@ -86,8 +97,16 @@ class CommandRouter:
86
97
  self.portfolio = PortfolioService(self.db)
87
98
  self.transactions = TransactionService(self.db, self.portfolio)
88
99
  self.journal = JournalService(self.db)
100
+ self.history = SessionHistoryService(self.db)
101
+ self.session_id = self.history.start_session()
102
+ self.web_research = WebResearchService()
89
103
 
90
104
  def route(self, raw: str) -> CommandResult:
105
+ result = self._route(raw)
106
+ self._record_history(raw, result)
107
+ return result
108
+
109
+ def _route(self, raw: str) -> CommandResult:
91
110
  raw = raw.strip()
92
111
  if not raw:
93
112
  return CommandResult(Panel("Ketik /help untuk melihat command.", title="FinCLI"))
@@ -117,6 +136,8 @@ class CommandRouter:
117
136
  return CommandResult("Keluar dari FinCLI.", should_exit=True)
118
137
  if root == "/config":
119
138
  return CommandResult(self._config_panel())
139
+ if root == "/history":
140
+ return self._history(args)
120
141
  if root == "/ai_model":
121
142
  return self._ai_model(args)
122
143
  if root == "/news_model":
@@ -133,8 +154,8 @@ class CommandRouter:
133
154
  return self._tx(args)
134
155
  if root == "/journal":
135
156
  return self._journal(args)
136
- if root in {"/price", "/quote"}:
137
- return self._price(args)
157
+ if root == "/quote":
158
+ return self._quote(args)
138
159
  if root == "/market":
139
160
  return self._market(args)
140
161
  if root == "/technical":
@@ -143,6 +164,8 @@ class CommandRouter:
143
164
  return self._structure(args)
144
165
  if root == "/news":
145
166
  return self._news(args)
167
+ if root == "/web":
168
+ return self._web(args)
146
169
  if root == "/funda":
147
170
  return self._fundamentals(args)
148
171
  if root == "/yahoo":
@@ -180,6 +203,57 @@ class CommandRouter:
180
203
  table.add_row(command.name, command.group, command.description, command.example)
181
204
  return table
182
205
 
206
+ def _record_history(self, raw: str, result: CommandResult) -> None:
207
+ normalized = raw.strip().lower()
208
+ if not normalized or normalized.startswith("/history"):
209
+ return
210
+ try:
211
+ preview = _render_history_preview(result.renderable)
212
+ self.history.record_event(self.session_id, raw, result.status, preview)
213
+ except FinCLIError:
214
+ return
215
+
216
+ def _history(self, args: list[str]) -> CommandResult:
217
+ action = args[0].lower() if args else "current"
218
+ if action in {"current", "show"}:
219
+ session_id = self.session_id if action == "current" or len(args) == 1 else args[1]
220
+ if action == "show" and len(args) < 2:
221
+ raise CommandError("Format: /history show <session_id>")
222
+ session = self.history.get_session(session_id)
223
+ if not session:
224
+ raise CommandError(f"Session tidak ditemukan: {session_id}")
225
+ events = self.history.get_events(session_id)
226
+ return CommandResult(_format_session_events(session, events, current=session_id == self.session_id))
227
+ if action in {"sessions", "list"}:
228
+ sessions = self.history.list_sessions()
229
+ return CommandResult(_format_sessions(sessions, self.session_id))
230
+ if action == "save":
231
+ title = " ".join(args[1:]).strip()
232
+ if not title:
233
+ raise CommandError("Format: /history save <session_title>")
234
+ self.history.save_session(self.session_id, title)
235
+ return CommandResult(Panel(f"Current session disimpan sebagai: {title}", title="History", border_style="green"))
236
+ if action == "delete":
237
+ if len(args) < 2:
238
+ raise CommandError("Format: /history delete <session_id>")
239
+ if args[1] == self.session_id:
240
+ self.history.clear_events(self.session_id)
241
+ self.history.save_session(self.session_id, "FinCLI session")
242
+ return CommandResult(Panel("Current session dikosongkan.", title="History", border_style="yellow"))
243
+ self.history.delete_session(args[1])
244
+ return CommandResult(Panel(f"Session dihapus: {args[1]}", title="History", border_style="green"))
245
+ if action == "clear":
246
+ target = args[1].lower() if len(args) >= 2 else "current"
247
+ if target == "all":
248
+ self.history.clear_all()
249
+ self.session_id = self.history.start_session()
250
+ return CommandResult(Panel("Semua history session dihapus. Session baru dibuat.", title="History"))
251
+ self.history.clear_events(self.session_id)
252
+ return CommandResult(Panel("Current session history dikosongkan.", title="History"))
253
+ raise CommandError(
254
+ "Format: /history [current|sessions|show <id>|save <title>|delete <id>|clear current|clear all]"
255
+ )
256
+
183
257
  def _dashboard(self) -> Table:
184
258
  return _format_dashboard(
185
259
  provider_chain=[provider.name for provider in self.market_service.providers],
@@ -220,11 +294,16 @@ class CommandRouter:
220
294
  if info is None:
221
295
  raise CommandError(f"AI provider tidak dikenal: {provider}")
222
296
  save_secret(info.env_key, args[2])
223
- if self.config.settings.ai_provider == provider:
224
- self.ai_provider = AIProviderManager().create(provider)
297
+ model = self.config.settings.ai_model if self.config.settings.ai_provider == provider else info.default_model
298
+ self.config.set_ai_model(provider, model)
299
+ self.ai_provider = AIProviderManager().create(provider)
225
300
  return CommandResult(
226
301
  Panel(
227
- f"API key AI untuk {provider} disimpan di ~/.fincli/secrets.env.\nKey tidak ditampilkan di terminal.",
302
+ (
303
+ f"API key AI untuk {provider} disimpan global di ~/.fincli/secrets.env.\n"
304
+ f"Provider aktif disimpan: {provider} / {model}.\n"
305
+ "Key tidak ditampilkan di terminal dan dipakai lintas session."
306
+ ),
228
307
  title="AI API Key Saved",
229
308
  border_style="green",
230
309
  )
@@ -260,13 +339,17 @@ class CommandRouter:
260
339
  save_secret(env_keys[0], args[2])
261
340
  if provider == "custom" and len(args) >= 4:
262
341
  save_secret("MARKET_DATA_BASE_URL", args[3])
342
+ self.config.set_market_provider_priority([provider, *self._priority_tail(provider)])
263
343
  self._refresh_market_service()
264
344
  self.cache.clear()
265
345
  extra = "\nBase URL custom juga disimpan." if provider == "custom" and len(args) >= 4 else ""
266
346
  return CommandResult(
267
347
  Panel(
268
- f"API key market/news untuk {provider} disimpan di ~/.fincli/secrets.env.{extra}\n"
269
- "Key tidak ditampilkan di terminal.",
348
+ (
349
+ f"API key market/news untuk {provider} disimpan global di ~/.fincli/secrets.env.{extra}\n"
350
+ f"Provider market/news aktif disimpan: {provider}.\n"
351
+ "Key tidak ditampilkan di terminal dan dipakai lintas session."
352
+ ),
270
353
  title="Market API Key Saved",
271
354
  border_style="green",
272
355
  )
@@ -481,7 +564,9 @@ class CommandRouter:
481
564
  response = self._run_async(self.ai_provider.complete(AIRequest(prompt=prompt, model=self.config.settings.ai_model)))
482
565
  if not isinstance(response, AIResponse):
483
566
  raise CommandError("AI provider mengembalikan data tidak valid.")
484
- return CommandResult(f"Journal Review\n{_format_ai_response(response)}\n\nDisclaimer: bukan nasihat keuangan.")
567
+ return CommandResult(
568
+ MarkdownBlock("Journal Review", _format_ai_response(response), "Disclaimer: bukan nasihat keuangan.")
569
+ )
485
570
 
486
571
  if args[0].lower() == "add":
487
572
  if len(args) < 3:
@@ -511,9 +596,9 @@ class CommandRouter:
511
596
  table.add_row("-", "-", "-", 'Belum ada journal. Gunakan /journal add BTC-USD bullish "Alasan entry"', "-")
512
597
  return table
513
598
 
514
- def _price(self, args: list[str]) -> CommandResult:
599
+ def _quote(self, args: list[str]) -> CommandResult:
515
600
  if not args:
516
- raise CommandError("Format: /price <symbol>")
601
+ raise CommandError("Format: /quote <symbol>")
517
602
  symbol = args[0].upper()
518
603
  cache_key = f"quote:{symbol}"
519
604
  cached = self.cache.get(cache_key)
@@ -595,6 +680,9 @@ class CommandRouter:
595
680
  return CommandResult(_format_ai_response(response))
596
681
 
597
682
  market_context = self._freechat_market_context(prompt)
683
+ web_context = self._freechat_web_context(prompt)
684
+ if web_context:
685
+ market_context = f"{market_context}\n\n{web_context}".strip()
598
686
  assistant_prompt = build_fincli_assistant_prompt(prompt, market_context)
599
687
  request = AIRequest(prompt=assistant_prompt, model=self.config.settings.ai_model)
600
688
  response = self._run_async(self.ai_provider.complete(request))
@@ -602,6 +690,26 @@ class CommandRouter:
602
690
  raise CommandError("AI provider mengembalikan data tidak valid.")
603
691
  return CommandResult(_format_ai_response(response))
604
692
 
693
+ def _web(self, args: list[str]) -> CommandResult:
694
+ if not args:
695
+ raise CommandError("Format: /web <query>")
696
+ if args[0].lower() in {"sources", "source", "raw"}:
697
+ source_query = " ".join(args[1:]).strip()
698
+ if not source_query:
699
+ raise CommandError("Format: /web sources <query>")
700
+ results = self._run_async(self.web_research.research(source_query, limit=5))
701
+ return CommandResult(_format_web_results(source_query, results))
702
+
703
+ query = " ".join(args)
704
+ results = self._run_async(self.web_research.research(query, limit=5))
705
+ context = build_web_research_context(results)
706
+ assistant_prompt = build_web_research_answer_prompt(query, context)
707
+ request = AIRequest(prompt=assistant_prompt, model=self.config.settings.ai_model)
708
+ response = self._run_async(self.ai_provider.complete(request))
709
+ if not isinstance(response, AIResponse):
710
+ raise CommandError("AI provider mengembalikan data tidak valid.")
711
+ return CommandResult(_format_ai_response(response))
712
+
605
713
  def _analyze(self, args: list[str]) -> CommandResult:
606
714
  if not args:
607
715
  raise CommandError("Format: /analyze <symbol> [timeframe]")
@@ -618,7 +726,9 @@ class CommandRouter:
618
726
  response = self._run_async(self.ai_provider.complete(request))
619
727
  if not isinstance(response, AIResponse):
620
728
  raise CommandError("AI provider mengembalikan data tidak valid.")
621
- return CommandResult(f"AI Market Analysis: {symbol}\n{_format_ai_response(response)}\n\nDisclaimer: bukan nasihat keuangan.")
729
+ return CommandResult(
730
+ MarkdownBlock(f"AI Market Analysis: {symbol}", _format_ai_response(response), "Disclaimer: bukan nasihat keuangan.")
731
+ )
622
732
 
623
733
  def _scan(self, args: list[str]) -> CommandResult:
624
734
  if not args or args[0].lower() != "watchlist":
@@ -757,6 +867,21 @@ class CommandRouter:
757
867
  sections.append(self._symbol_freechat_context(symbol))
758
868
  return "\n\n".join(sections)
759
869
 
870
+ def _freechat_web_context(self, prompt: str) -> str:
871
+ if not should_use_web_research(prompt):
872
+ return ""
873
+ cache_key = f"web:{prompt.lower()[:180]}"
874
+ cached = self.cache.get(cache_key)
875
+ if isinstance(cached, str):
876
+ return cached
877
+ try:
878
+ results = self._run_async(self.web_research.research(prompt, limit=3))
879
+ except FinCLIError as exc:
880
+ return f"Web Research: unavailable ({exc})"
881
+ context = build_web_research_context(results)
882
+ self.cache.set(cache_key, context)
883
+ return context
884
+
760
885
  def _symbol_freechat_context(self, symbol: str) -> str:
761
886
  lines = [f"Symbol: {symbol}"]
762
887
  try:
@@ -879,6 +1004,63 @@ def _format_quote(quote: Quote) -> str:
879
1004
  )
880
1005
 
881
1006
 
1007
+ def _format_sessions(sessions: list[dict[str, object]], current_session_id: str) -> Table:
1008
+ table = Table(title="FinCLI Sessions", expand=True)
1009
+ table.add_column("Current", justify="center", width=7)
1010
+ table.add_column("Session ID", style="cyan", no_wrap=True)
1011
+ table.add_column("Title", style="white")
1012
+ table.add_column("Events", justify="right")
1013
+ table.add_column("Updated", style="dim")
1014
+ for session in sessions:
1015
+ session_id = str(session["id"])
1016
+ table.add_row(
1017
+ "*" if session_id == current_session_id else "",
1018
+ session_id,
1019
+ str(session["title"]),
1020
+ str(session["event_count"]),
1021
+ str(session["updated_at"]),
1022
+ )
1023
+ if not sessions:
1024
+ table.add_row("-", "-", "Belum ada session.", "0", "-")
1025
+ table.caption = "/history current | /history show <session_id> | /history delete <session_id>"
1026
+ return table
1027
+
1028
+
1029
+ def _format_session_events(session: dict[str, object], events: list[dict[str, object]], current: bool = False) -> Table:
1030
+ marker = "current" if current else "saved"
1031
+ table = Table(title=f"Session {session['id']} ({marker}) - {session['title']}", expand=True)
1032
+ table.add_column("#", justify="right", width=4)
1033
+ table.add_column("Time", style="dim", no_wrap=True)
1034
+ table.add_column("Status", style="cyan", no_wrap=True)
1035
+ table.add_column("Command", style="white")
1036
+ table.add_column("Output Preview", style="dim")
1037
+ for event in events:
1038
+ table.add_row(
1039
+ str(event["id"]),
1040
+ str(event["created_at"]),
1041
+ str(event["status"]),
1042
+ str(event["command"]),
1043
+ str(event["output_preview"] or "")[:180],
1044
+ )
1045
+ if not events:
1046
+ table.add_row("-", "-", "-", "Belum ada command di session ini.", "")
1047
+ table.caption = "/history sessions | /history save <title> | /history clear current"
1048
+ return table
1049
+
1050
+
1051
+ def _render_history_preview(renderable: Any) -> str:
1052
+ if renderable is None:
1053
+ return ""
1054
+ if isinstance(renderable, str):
1055
+ return renderable[:1200]
1056
+ console = Console(width=100, record=True, force_terminal=False, file=io.StringIO())
1057
+ try:
1058
+ console.print(renderable)
1059
+ return console.export_text(clear=False).strip()[:1200]
1060
+ except Exception:
1061
+ return str(renderable)[:1200]
1062
+
1063
+
882
1064
  def _format_dashboard(
883
1065
  provider_chain: list[str],
884
1066
  watchlist_rows: list[dict[str, object]],
@@ -1218,6 +1400,21 @@ def _format_news(symbol: str, items: list[NewsItem]) -> str:
1218
1400
  return "\n".join(lines)
1219
1401
 
1220
1402
 
1403
+ def _format_web_results(query: str, results: list[WebSearchResult]) -> Table:
1404
+ table = Table(title=f"Web Research: {query}", expand=True)
1405
+ table.add_column("#", justify="right", width=3)
1406
+ table.add_column("Title", style="cyan", overflow="fold")
1407
+ table.add_column("Snippet / Extract", overflow="fold")
1408
+ table.add_column("URL", style="dim", overflow="fold")
1409
+ for index, result in enumerate(results, start=1):
1410
+ extract = result.content[:500] if result.content else result.snippet
1411
+ table.add_row(str(index), result.title, extract or "-", result.url)
1412
+ if not results:
1413
+ table.add_row("-", "No results", "Search providers returned no public context.", "-")
1414
+ table.caption = "Web context is public web data; verify source quality before using it for financial decisions."
1415
+ return table
1416
+
1417
+
1221
1418
  def _format_fundamentals(snapshot: FundamentalSnapshot) -> str:
1222
1419
  return (
1223
1420
  f"Fundamental Snapshot: {snapshot.symbol}\n"
@@ -1277,12 +1474,8 @@ def _format_fundamental_context(snapshot: FundamentalSnapshot) -> str:
1277
1474
  )
1278
1475
 
1279
1476
 
1280
- def _format_ai_response(response: AIResponse) -> str:
1281
- return (
1282
- f"Provider: {response.provider}\n"
1283
- f"Model: {response.model}\n"
1284
- f"Response:\n{response.content}"
1285
- )
1477
+ def _format_ai_response(response: AIResponse) -> AIResponseView:
1478
+ return AIResponseView(response)
1286
1479
 
1287
1480
 
1288
1481
  def _fmt(value: float | None) -> str:
@@ -1300,5 +1493,5 @@ class UnavailableAIProvider:
1300
1493
  async def complete(self, request: AIRequest) -> AIResponse:
1301
1494
  raise CommandError(
1302
1495
  f"AI provider {self.name} belum siap dipakai.",
1303
- "Set API key di .env dan gunakan provider client Phase 2 lanjutan, atau injeksi provider untuk testing.",
1496
+ "Gunakan /ai_model untuk memilih provider dan /ai_model key <provider> <api_key> untuk menyimpan API key.",
1304
1497
  )