@drico2008/fincli 0.1.2 → 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.
Files changed (36) hide show
  1. package/README.md +81 -7
  2. package/fincli/__init__.py +1 -1
  3. package/fincli/app/analysis/assistant_context.py +27 -1
  4. package/fincli/app/analysis/indicators.py +1 -1
  5. package/fincli/app/analysis/market_structure.py +1 -1
  6. package/fincli/app/cli/commands.py +12 -4
  7. package/fincli/app/cli/router.py +253 -13
  8. package/fincli/app/modules/session_history.py +113 -0
  9. package/fincli/app/providers/ai/anthropic_provider.py +8 -7
  10. package/fincli/app/providers/ai/gemini_provider.py +8 -7
  11. package/fincli/app/providers/ai/groq_provider.py +8 -7
  12. package/fincli/app/providers/ai/http_provider.py +3 -3
  13. package/fincli/app/providers/ai/huggingface_provider.py +8 -7
  14. package/fincli/app/providers/ai/openai_provider.py +8 -7
  15. package/fincli/app/providers/ai/openrouter_provider.py +8 -7
  16. package/fincli/app/providers/ai/together_provider.py +8 -7
  17. package/fincli/app/providers/market/custom_provider.py +2 -2
  18. package/fincli/app/providers/market/finnhub_provider.py +1 -1
  19. package/fincli/app/providers/market/manager.py +6 -5
  20. package/fincli/app/providers/market/news_provider.py +4 -4
  21. package/fincli/app/providers/market/twelvedata_provider.py +1 -1
  22. package/fincli/app/providers/market/yfinance_provider.py +1 -1
  23. package/fincli/app/services/web_research.py +267 -0
  24. package/fincli/app/storage/cache.py +2 -2
  25. package/fincli/app/storage/config.py +3 -4
  26. package/fincli/app/storage/config_paths.py +9 -0
  27. package/fincli/app/storage/database.py +17 -0
  28. package/fincli/app/storage/secrets.py +104 -0
  29. package/fincli/app/tui/components.py +1 -1
  30. package/fincli/app/tui/layout.py +8 -7
  31. package/fincli/app/tui/market_provider_selector.py +42 -2
  32. package/fincli/app/tui/model_selector.py +97 -55
  33. package/fincli/app/utils/formatting.py +50 -0
  34. package/npm/bin/fincli.js +9 -2
  35. package/package.json +1 -1
  36. package/pyproject.toml +1 -1
@@ -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,11 +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
63
+ from fincli.app.storage.secrets import save_secret
53
64
  from fincli.app.utils.errors import CommandError, FinCLIError
65
+ from fincli.app.utils.formatting import AIResponseView, MarkdownBlock
54
66
 
55
67
 
56
68
  @dataclass(slots=True)
@@ -85,8 +97,16 @@ class CommandRouter:
85
97
  self.portfolio = PortfolioService(self.db)
86
98
  self.transactions = TransactionService(self.db, self.portfolio)
87
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()
88
103
 
89
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:
90
110
  raw = raw.strip()
91
111
  if not raw:
92
112
  return CommandResult(Panel("Ketik /help untuk melihat command.", title="FinCLI"))
@@ -116,6 +136,8 @@ class CommandRouter:
116
136
  return CommandResult("Keluar dari FinCLI.", should_exit=True)
117
137
  if root == "/config":
118
138
  return CommandResult(self._config_panel())
139
+ if root == "/history":
140
+ return self._history(args)
119
141
  if root == "/ai_model":
120
142
  return self._ai_model(args)
121
143
  if root == "/news_model":
@@ -132,8 +154,8 @@ class CommandRouter:
132
154
  return self._tx(args)
133
155
  if root == "/journal":
134
156
  return self._journal(args)
135
- if root in {"/price", "/quote"}:
136
- return self._price(args)
157
+ if root == "/quote":
158
+ return self._quote(args)
137
159
  if root == "/market":
138
160
  return self._market(args)
139
161
  if root == "/technical":
@@ -142,6 +164,8 @@ class CommandRouter:
142
164
  return self._structure(args)
143
165
  if root == "/news":
144
166
  return self._news(args)
167
+ if root == "/web":
168
+ return self._web(args)
145
169
  if root == "/funda":
146
170
  return self._fundamentals(args)
147
171
  if root == "/yahoo":
@@ -179,6 +203,57 @@ class CommandRouter:
179
203
  table.add_row(command.name, command.group, command.description, command.example)
180
204
  return table
181
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
+
182
257
  def _dashboard(self) -> Table:
183
258
  return _format_dashboard(
184
259
  provider_chain=[provider.name for provider in self.market_service.providers],
@@ -211,6 +286,28 @@ class CommandRouter:
211
286
  if len(args) == 0:
212
287
  current = self.config.settings
213
288
  return CommandResult(Panel(f"{current.ai_provider} / {current.ai_model}", title="Active AI Model"))
289
+ if args[0].lower() == "key":
290
+ if len(args) < 3:
291
+ raise CommandError("Format: /ai_model key <provider> <api_key>")
292
+ provider = args[1].lower()
293
+ info = AIProviderManager().get(provider)
294
+ if info is None:
295
+ raise CommandError(f"AI provider tidak dikenal: {provider}")
296
+ save_secret(info.env_key, args[2])
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)
300
+ return CommandResult(
301
+ Panel(
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
+ ),
307
+ title="AI API Key Saved",
308
+ border_style="green",
309
+ )
310
+ )
214
311
  if len(args) < 2:
215
312
  raise CommandError("Format: /ai_model <provider> <model>")
216
313
  self.config.set_ai_model(args[0], args[1])
@@ -232,6 +329,31 @@ class CommandRouter:
232
329
  title="Active Data Provider",
233
330
  )
234
331
  )
332
+ if args[0].lower() == "key":
333
+ if len(args) < 3:
334
+ raise CommandError("Format: /news_model key <provider> <api_key> [base_url untuk custom]")
335
+ provider = args[1].lower()
336
+ env_keys = _market_provider_secret_keys(provider)
337
+ if not env_keys:
338
+ raise CommandError(f"Provider {provider} tidak membutuhkan API key atau tidak dikenal.")
339
+ save_secret(env_keys[0], args[2])
340
+ if provider == "custom" and len(args) >= 4:
341
+ save_secret("MARKET_DATA_BASE_URL", args[3])
342
+ self.config.set_market_provider_priority([provider, *self._priority_tail(provider)])
343
+ self._refresh_market_service()
344
+ self.cache.clear()
345
+ extra = "\nBase URL custom juga disimpan." if provider == "custom" and len(args) >= 4 else ""
346
+ return CommandResult(
347
+ Panel(
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
+ ),
353
+ title="Market API Key Saved",
354
+ border_style="green",
355
+ )
356
+ )
235
357
  self.config.set_market_provider(args[0])
236
358
  self.config.set_news_provider(args[0])
237
359
  self.config.set_market_provider_priority([args[0], *self._priority_tail(args[0])])
@@ -442,7 +564,9 @@ class CommandRouter:
442
564
  response = self._run_async(self.ai_provider.complete(AIRequest(prompt=prompt, model=self.config.settings.ai_model)))
443
565
  if not isinstance(response, AIResponse):
444
566
  raise CommandError("AI provider mengembalikan data tidak valid.")
445
- 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
+ )
446
570
 
447
571
  if args[0].lower() == "add":
448
572
  if len(args) < 3:
@@ -472,9 +596,9 @@ class CommandRouter:
472
596
  table.add_row("-", "-", "-", 'Belum ada journal. Gunakan /journal add BTC-USD bullish "Alasan entry"', "-")
473
597
  return table
474
598
 
475
- def _price(self, args: list[str]) -> CommandResult:
599
+ def _quote(self, args: list[str]) -> CommandResult:
476
600
  if not args:
477
- raise CommandError("Format: /price <symbol>")
601
+ raise CommandError("Format: /quote <symbol>")
478
602
  symbol = args[0].upper()
479
603
  cache_key = f"quote:{symbol}"
480
604
  cached = self.cache.get(cache_key)
@@ -556,6 +680,9 @@ class CommandRouter:
556
680
  return CommandResult(_format_ai_response(response))
557
681
 
558
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()
559
686
  assistant_prompt = build_fincli_assistant_prompt(prompt, market_context)
560
687
  request = AIRequest(prompt=assistant_prompt, model=self.config.settings.ai_model)
561
688
  response = self._run_async(self.ai_provider.complete(request))
@@ -563,6 +690,26 @@ class CommandRouter:
563
690
  raise CommandError("AI provider mengembalikan data tidak valid.")
564
691
  return CommandResult(_format_ai_response(response))
565
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
+
566
713
  def _analyze(self, args: list[str]) -> CommandResult:
567
714
  if not args:
568
715
  raise CommandError("Format: /analyze <symbol> [timeframe]")
@@ -579,7 +726,9 @@ class CommandRouter:
579
726
  response = self._run_async(self.ai_provider.complete(request))
580
727
  if not isinstance(response, AIResponse):
581
728
  raise CommandError("AI provider mengembalikan data tidak valid.")
582
- 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
+ )
583
732
 
584
733
  def _scan(self, args: list[str]) -> CommandResult:
585
734
  if not args or args[0].lower() != "watchlist":
@@ -718,6 +867,21 @@ class CommandRouter:
718
867
  sections.append(self._symbol_freechat_context(symbol))
719
868
  return "\n\n".join(sections)
720
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
+
721
885
  def _symbol_freechat_context(self, symbol: str) -> str:
722
886
  lines = [f"Symbol: {symbol}"]
723
887
  try:
@@ -840,6 +1004,63 @@ def _format_quote(quote: Quote) -> str:
840
1004
  )
841
1005
 
842
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
+
843
1064
  def _format_dashboard(
844
1065
  provider_chain: list[str],
845
1066
  watchlist_rows: list[dict[str, object]],
@@ -1121,6 +1342,14 @@ def _format_provider_key_status(manager: MarketProviderManager) -> Table:
1121
1342
  return table
1122
1343
 
1123
1344
 
1345
+ def _market_provider_secret_keys(provider: str) -> tuple[str, ...]:
1346
+ return {
1347
+ "custom": ("MARKET_DATA_API_KEY", "MARKET_DATA_BASE_URL"),
1348
+ "finnhub": ("FINNHUB_API_KEY",),
1349
+ "twelvedata": ("TWELVE_DATA_API_KEY",),
1350
+ }.get(provider.lower(), ())
1351
+
1352
+
1124
1353
  def _format_transactions(rows: list[dict[str, object]]) -> Table:
1125
1354
  table = Table(title="Transaction Ledger", expand=True)
1126
1355
  table.add_column("ID", justify="right")
@@ -1171,6 +1400,21 @@ def _format_news(symbol: str, items: list[NewsItem]) -> str:
1171
1400
  return "\n".join(lines)
1172
1401
 
1173
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
+
1174
1418
  def _format_fundamentals(snapshot: FundamentalSnapshot) -> str:
1175
1419
  return (
1176
1420
  f"Fundamental Snapshot: {snapshot.symbol}\n"
@@ -1230,12 +1474,8 @@ def _format_fundamental_context(snapshot: FundamentalSnapshot) -> str:
1230
1474
  )
1231
1475
 
1232
1476
 
1233
- def _format_ai_response(response: AIResponse) -> str:
1234
- return (
1235
- f"Provider: {response.provider}\n"
1236
- f"Model: {response.model}\n"
1237
- f"Response:\n{response.content}"
1238
- )
1477
+ def _format_ai_response(response: AIResponse) -> AIResponseView:
1478
+ return AIResponseView(response)
1239
1479
 
1240
1480
 
1241
1481
  def _fmt(value: float | None) -> str:
@@ -1253,5 +1493,5 @@ class UnavailableAIProvider:
1253
1493
  async def complete(self, request: AIRequest) -> AIResponse:
1254
1494
  raise CommandError(
1255
1495
  f"AI provider {self.name} belum siap dipakai.",
1256
- "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.",
1257
1497
  )
@@ -0,0 +1,113 @@
1
+ """Session history service for FinCLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ import re
7
+ from uuid import uuid4
8
+
9
+ from fincli.app.storage.database import FinCLIDatabase
10
+
11
+
12
+ _AI_NEWS_KEY_PATTERN = re.compile(r"^/(ai_model|news_model)\s+key\s+(\S+)\s+(.+)$", re.IGNORECASE)
13
+ _PROVIDER_KEY_PATTERN = re.compile(r"^/provider\s+key\s+(\S+)\s+(.+)$", re.IGNORECASE)
14
+ _SECRET_VALUE_PATTERNS = (
15
+ re.compile(r"(?i)(api[_ -]?key|token|secret|password)\s*[:=]\s*\S+"),
16
+ )
17
+
18
+
19
+ class SessionHistoryService:
20
+ """Persist local command sessions and sanitized command events."""
21
+
22
+ def __init__(self, db: FinCLIDatabase) -> None:
23
+ self.db = db
24
+
25
+ def start_session(self, title: str = "FinCLI session") -> str:
26
+ session_id = uuid4().hex[:12]
27
+ now = _now()
28
+ self.db.execute(
29
+ "INSERT INTO sessions (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
30
+ (session_id, title, now, now),
31
+ )
32
+ return session_id
33
+
34
+ def save_session(self, session_id: str, title: str) -> None:
35
+ self.db.execute(
36
+ "UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?",
37
+ (title.strip() or "FinCLI session", _now(), session_id),
38
+ )
39
+
40
+ def record_event(self, session_id: str, command: str, status: str, output_preview: str = "") -> None:
41
+ sanitized_command = sanitize_history_text(command.strip())
42
+ sanitized_output = sanitize_history_text(output_preview.strip())[:1200]
43
+ if not sanitized_command:
44
+ return
45
+ self.db.execute(
46
+ """
47
+ INSERT INTO session_events (session_id, command, status, output_preview, created_at)
48
+ VALUES (?, ?, ?, ?, ?)
49
+ """,
50
+ (session_id, sanitized_command, status, sanitized_output, _now()),
51
+ )
52
+ self.db.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", (_now(), session_id))
53
+
54
+ def list_sessions(self, limit: int = 20) -> list[dict[str, object]]:
55
+ rows = self.db.query(
56
+ """
57
+ SELECT s.id, s.title, s.created_at, s.updated_at, COUNT(e.id) AS event_count
58
+ FROM sessions s
59
+ LEFT JOIN session_events e ON e.session_id = s.id
60
+ GROUP BY s.id
61
+ ORDER BY s.updated_at DESC
62
+ LIMIT ?
63
+ """,
64
+ (limit,),
65
+ )
66
+ return [dict(row) for row in rows]
67
+
68
+ def get_events(self, session_id: str, limit: int = 100) -> list[dict[str, object]]:
69
+ rows = self.db.query(
70
+ """
71
+ SELECT id, command, status, output_preview, created_at
72
+ FROM session_events
73
+ WHERE session_id = ?
74
+ ORDER BY id ASC
75
+ LIMIT ?
76
+ """,
77
+ (session_id, limit),
78
+ )
79
+ return [dict(row) for row in rows]
80
+
81
+ def get_session(self, session_id: str) -> dict[str, object] | None:
82
+ rows = self.db.query("SELECT id, title, created_at, updated_at FROM sessions WHERE id = ?", (session_id,))
83
+ return dict(rows[0]) if rows else None
84
+
85
+ def delete_session(self, session_id: str) -> int:
86
+ self.db.execute("DELETE FROM session_events WHERE session_id = ?", (session_id,))
87
+ self.db.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
88
+ return 1
89
+
90
+ def clear_events(self, session_id: str) -> None:
91
+ self.db.execute("DELETE FROM session_events WHERE session_id = ?", (session_id,))
92
+ self.db.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", (_now(), session_id))
93
+
94
+ def clear_all(self) -> None:
95
+ self.db.execute("DELETE FROM session_events")
96
+ self.db.execute("DELETE FROM sessions")
97
+
98
+
99
+ def sanitize_history_text(value: str) -> str:
100
+ sanitized = value
101
+ match = _AI_NEWS_KEY_PATTERN.match(sanitized)
102
+ if match:
103
+ return f"/{match.group(1)} key {match.group(2)} <redacted>"
104
+ match = _PROVIDER_KEY_PATTERN.match(sanitized)
105
+ if match:
106
+ return f"/provider key {match.group(1)} <redacted>"
107
+ for pattern in _SECRET_VALUE_PATTERNS:
108
+ sanitized = pattern.sub(lambda match: f"{match.group(1)}=<redacted>", sanitized)
109
+ return sanitized
110
+
111
+
112
+ def _now() -> str:
113
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
@@ -1,11 +1,12 @@
1
- """Anthropic provider placeholder for Phase 2."""
1
+ """Anthropic provider compatibility wrapper."""
2
2
 
3
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
- from fincli.app.utils.errors import ProviderError
3
+ from __future__ import annotations
5
4
 
5
+ import os
6
6
 
7
- class AnthropicProvider(BaseAIProvider):
8
- name = "anthropic"
7
+ from fincli.app.providers.ai.http_provider import AnthropicProviderHTTP
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("Anthropic client belum diimplementasi di Phase 1.")
9
+
10
+ class AnthropicProvider(AnthropicProviderHTTP):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__(api_key or os.getenv("ANTHROPIC_API_KEY"))
@@ -1,11 +1,12 @@
1
- """Gemini provider placeholder for Phase 2."""
1
+ """Gemini provider compatibility wrapper."""
2
2
 
3
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
- from fincli.app.utils.errors import ProviderError
3
+ from __future__ import annotations
5
4
 
5
+ import os
6
6
 
7
- class GeminiProvider(BaseAIProvider):
8
- name = "gemini"
7
+ from fincli.app.providers.ai.http_provider import GeminiProviderHTTP
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("Gemini client belum diimplementasi di Phase 1.")
9
+
10
+ class GeminiProvider(GeminiProviderHTTP):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__(api_key or os.getenv("GEMINI_API_KEY"))
@@ -1,11 +1,12 @@
1
- """Groq provider placeholder for Phase 2."""
1
+ """Groq provider compatibility wrapper."""
2
2
 
3
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
- from fincli.app.utils.errors import ProviderError
3
+ from __future__ import annotations
5
4
 
5
+ import os
6
6
 
7
- class GroqProvider(BaseAIProvider):
8
- name = "groq"
7
+ from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("Groq client belum diimplementasi di Phase 1.")
9
+
10
+ class GroqProvider(OpenAICompatibleProvider):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__("groq", "https://api.groq.com/openai/v1", api_key or os.getenv("GROQ_API_KEY"))
@@ -27,7 +27,7 @@ class OpenAICompatibleProvider(BaseAIProvider):
27
27
  if not self.api_key:
28
28
  raise ProviderError(
29
29
  f"API key untuk provider {self.name} belum diatur.",
30
- "Isi API key di .env lalu jalankan ulang FinCLI.",
30
+ f"Gunakan /ai_model key {self.name} <api_key> atau set environment variable.",
31
31
  )
32
32
 
33
33
  payload = {
@@ -69,7 +69,7 @@ class GeminiProviderHTTP(BaseAIProvider):
69
69
 
70
70
  async def complete(self, request: AIRequest) -> AIResponse:
71
71
  if not self.api_key:
72
- raise ProviderError("API key untuk provider gemini belum diatur.")
72
+ raise ProviderError("API key untuk provider gemini belum diatur.", "Gunakan /ai_model key gemini <api_key>.")
73
73
  url = f"{self.base_url}/models/{request.model}:generateContent?key={self.api_key}"
74
74
  payload = {"contents": [{"parts": [{"text": request.prompt}]}]}
75
75
  try:
@@ -100,7 +100,7 @@ class AnthropicProviderHTTP(BaseAIProvider):
100
100
 
101
101
  async def complete(self, request: AIRequest) -> AIResponse:
102
102
  if not self.api_key:
103
- raise ProviderError("API key untuk provider anthropic belum diatur.")
103
+ raise ProviderError("API key untuk provider anthropic belum diatur.", "Gunakan /ai_model key anthropic <api_key>.")
104
104
  payload = {
105
105
  "model": request.model,
106
106
  "max_tokens": 1200,
@@ -1,11 +1,12 @@
1
- """HuggingFace provider placeholder for Phase 2."""
1
+ """HuggingFace provider compatibility wrapper."""
2
2
 
3
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
- from fincli.app.utils.errors import ProviderError
3
+ from __future__ import annotations
5
4
 
5
+ import os
6
6
 
7
- class HuggingFaceProvider(BaseAIProvider):
8
- name = "huggingface"
7
+ from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("HuggingFace client belum diimplementasi di Phase 1.")
9
+
10
+ class HuggingFaceProvider(OpenAICompatibleProvider):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__("huggingface", "https://router.huggingface.co/v1", api_key or os.getenv("HUGGINGFACE_API_KEY"))
@@ -1,11 +1,12 @@
1
- """OpenAI provider placeholder for Phase 2."""
1
+ """OpenAI provider compatibility wrapper."""
2
2
 
3
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
- from fincli.app.utils.errors import ProviderError
3
+ from __future__ import annotations
5
4
 
5
+ import os
6
6
 
7
- class OpenAIProvider(BaseAIProvider):
8
- name = "openai"
7
+ from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("OpenAI client belum diimplementasi di Phase 1.")
9
+
10
+ class OpenAIProvider(OpenAICompatibleProvider):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__("openai", "https://api.openai.com/v1", api_key or os.getenv("OPENAI_API_KEY"))