@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.
@@ -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"))
@@ -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"))
@@ -1,11 +1,12 @@
1
- """OpenRouter provider placeholder for Phase 2."""
1
+ """OpenRouter 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 OpenRouterProvider(BaseAIProvider):
8
- name = "openrouter"
7
+ from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("OpenRouter client belum diimplementasi di Phase 1.")
9
+
10
+ class OpenRouterProvider(OpenAICompatibleProvider):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__("openrouter", "https://openrouter.ai/api/v1", api_key or os.getenv("OPENROUTER_API_KEY"))
@@ -1,11 +1,12 @@
1
- """Together AI provider placeholder for Phase 2."""
1
+ """Together AI 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 TogetherProvider(BaseAIProvider):
8
- name = "together"
7
+ from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("Together AI client belum diimplementasi di Phase 1.")
9
+
10
+ class TogetherProvider(OpenAICompatibleProvider):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__("together", "https://api.together.xyz/v1", api_key or os.getenv("TOGETHER_API_KEY"))
@@ -32,7 +32,7 @@ MARKET_PROVIDERS: dict[str, MarketProviderInfo] = {
32
32
  name="custom",
33
33
  realtime=True,
34
34
  status="configured",
35
- notes="Custom API provider scaffold. Requires MARKET_DATA_API_KEY.",
35
+ notes="Custom REST API provider. Requires MARKET_DATA_API_KEY and MARKET_DATA_BASE_URL.",
36
36
  ),
37
37
  "finnhub": MarketProviderInfo(
38
38
  name="finnhub",
@@ -1,4 +1,4 @@
1
- """News provider placeholder for Phase 2."""
1
+ """Base news-only provider adapter."""
2
2
 
3
3
  from fincli.app.providers.market.base import BaseMarketProvider, Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
4
4
  from fincli.app.utils.errors import ProviderError
@@ -14,7 +14,7 @@ class NewsProvider(BaseMarketProvider):
14
14
  raise ProviderError("News provider tidak menyediakan OHLCV.")
15
15
 
16
16
  async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
17
- raise ProviderError("News fetching belum diimplementasi di Phase 2.")
17
+ raise ProviderError("News provider belum dikonfigurasi untuk mengambil berita.", "Gunakan /news_model untuk memilih provider market/news aktif.")
18
18
 
19
19
  async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
20
20
  raise ProviderError("News provider tidak menyediakan fundamental.")
@@ -23,6 +23,6 @@ class NewsProvider(BaseMarketProvider):
23
23
  return ProviderStatus(
24
24
  name=self.name,
25
25
  realtime=False,
26
- status="configured",
27
- message="News fetching pending in Phase 2.",
26
+ status="unavailable",
27
+ message="News-only adapter belum memiliki source aktif. Gunakan /news_model untuk provider aktual.",
28
28
  )
@@ -1,4 +1,4 @@
1
- """yfinance fallback provider placeholder for Phase 2."""
1
+ """yfinance fallback provider for delayed public market data."""
2
2
 
3
3
  import asyncio
4
4
  from dataclasses import dataclass
@@ -0,0 +1,267 @@
1
+ """Lightweight web research service for AI assistance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from html import unescape
7
+ from html.parser import HTMLParser
8
+ import re
9
+ from urllib.parse import parse_qs, quote_plus, unquote, urlparse
10
+ from xml.etree import ElementTree
11
+
12
+ import httpx
13
+
14
+ from fincli.app.utils.errors import ProviderError
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class WebSearchResult:
19
+ title: str
20
+ url: str
21
+ snippet: str = ""
22
+ content: str = ""
23
+
24
+
25
+ class WebResearchService:
26
+ """Search and fetch public web pages without browser automation."""
27
+
28
+ def __init__(self, client: httpx.AsyncClient | None = None, timeout_seconds: float = 6.0) -> None:
29
+ self._client = client
30
+ self.timeout_seconds = timeout_seconds
31
+
32
+ async def research(self, query: str, limit: int = 3) -> list[WebSearchResult]:
33
+ normalized = query.strip()
34
+ if not normalized:
35
+ return []
36
+ search_results = await self.search(normalized, limit=limit)
37
+ enriched: list[WebSearchResult] = []
38
+ for result in search_results[:limit]:
39
+ content = await self.fetch_text(result.url)
40
+ enriched.append(
41
+ WebSearchResult(
42
+ title=result.title,
43
+ url=result.url,
44
+ snippet=result.snippet,
45
+ content=content,
46
+ )
47
+ )
48
+ return enriched
49
+
50
+ async def search(self, query: str, limit: int = 5) -> list[WebSearchResult]:
51
+ errors: list[str] = []
52
+ for searcher in (self._search_duckduckgo, self._search_google_news):
53
+ try:
54
+ results = await searcher(query, limit)
55
+ except ProviderError as exc:
56
+ errors.append(str(exc))
57
+ continue
58
+ if results:
59
+ return results
60
+ detail = "\n".join(f"- {error}" for error in errors) if errors else "Tidak ada hasil publik."
61
+ raise ProviderError(
62
+ "Semua web search provider gagal atau kosong.",
63
+ f"{detail}\nCoba ulangi, sederhanakan query, atau cek koneksi/DNS.",
64
+ )
65
+
66
+ async def _search_duckduckgo(self, query: str, limit: int) -> list[WebSearchResult]:
67
+ html = await self._get_text(f"https://duckduckgo.com/html/?q={quote_plus(query)}")
68
+ parser = _DuckDuckGoParser()
69
+ parser.feed(html)
70
+ results: list[WebSearchResult] = []
71
+ seen: set[str] = set()
72
+ for item in parser.results:
73
+ target = _clean_duckduckgo_url(item.url)
74
+ if not target or target in seen:
75
+ continue
76
+ seen.add(target)
77
+ results.append(WebSearchResult(title=_clean_text(item.title), url=target, snippet=_clean_text(item.snippet)))
78
+ if len(results) >= limit:
79
+ break
80
+ return results
81
+
82
+ async def _search_google_news(self, query: str, limit: int) -> list[WebSearchResult]:
83
+ rss = await self._get_text(f"https://news.google.com/rss/search?q={quote_plus(query)}&hl=id&gl=ID&ceid=ID:id")
84
+ try:
85
+ root = ElementTree.fromstring(rss)
86
+ except ElementTree.ParseError as exc:
87
+ raise ProviderError("Google News RSS tidak valid.") from exc
88
+
89
+ results: list[WebSearchResult] = []
90
+ seen: set[str] = set()
91
+ for item in root.findall(".//item"):
92
+ title = _clean_text(item.findtext("title") or "")
93
+ url = _clean_text(item.findtext("link") or "")
94
+ snippet = _clean_text(_html_to_text(item.findtext("description") or ""))
95
+ if not title or not url or url in seen:
96
+ continue
97
+ seen.add(url)
98
+ results.append(WebSearchResult(title=title, url=url, snippet=snippet))
99
+ if len(results) >= limit:
100
+ break
101
+ return results
102
+
103
+ async def fetch_text(self, url: str, max_chars: int = 2400) -> str:
104
+ if not url.startswith(("http://", "https://")):
105
+ return ""
106
+ try:
107
+ html = await self._get_text(url)
108
+ except ProviderError:
109
+ return ""
110
+ text = _html_to_text(html)
111
+ return text[:max_chars]
112
+
113
+ async def _get_text(self, url: str) -> str:
114
+ headers = {
115
+ "User-Agent": "FinCLI/0.1 web research (+https://www.npmjs.com/package/@drico2008/fincli)",
116
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7",
117
+ }
118
+ close_client = self._client is None
119
+ client = self._client or httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True, headers=headers)
120
+ try:
121
+ response = await client.get(url, headers=headers)
122
+ response.raise_for_status()
123
+ return response.text
124
+ except httpx.TimeoutException as exc:
125
+ raise ProviderError("Web research timeout.", f"URL: {url}") from exc
126
+ except httpx.HTTPStatusError as exc:
127
+ raise ProviderError(f"Web research gagal: HTTP {exc.response.status_code}.", f"URL: {url}") from exc
128
+ except httpx.RequestError as exc:
129
+ raise ProviderError(f"Web research gagal terhubung: {exc}.", f"URL: {url}") from exc
130
+ finally:
131
+ if close_client:
132
+ await client.aclose()
133
+
134
+
135
+ def should_use_web_research(prompt: str) -> bool:
136
+ """Detect prompts that benefit from current public web context."""
137
+ normalized = prompt.lower()
138
+ keywords = (
139
+ "terkini",
140
+ "terbaru",
141
+ "hari ini",
142
+ "sekarang",
143
+ "saat ini",
144
+ "update",
145
+ "berita",
146
+ "news",
147
+ "web",
148
+ "search",
149
+ "cari",
150
+ "penyebab",
151
+ "mengapa",
152
+ "kenapa",
153
+ "rupiah",
154
+ "inflasi",
155
+ "suku bunga",
156
+ "bank indonesia",
157
+ "fed",
158
+ "dollar",
159
+ "dolar",
160
+ "yield",
161
+ "minyak",
162
+ "emas",
163
+ )
164
+ return any(keyword in normalized for keyword in keywords)
165
+
166
+
167
+ def build_web_research_context(results: list[WebSearchResult]) -> str:
168
+ if not results:
169
+ return "Web Research: no public web context returned."
170
+ sections = ["Web Research Context:"]
171
+ for index, result in enumerate(results, start=1):
172
+ sections.extend(
173
+ [
174
+ f"{index}. {result.title}",
175
+ f"URL: {result.url}",
176
+ f"Snippet: {result.snippet or 'N/A'}",
177
+ f"Extract: {result.content or 'N/A'}",
178
+ ]
179
+ )
180
+ return "\n".join(sections)
181
+
182
+
183
+ class _DuckResult:
184
+ def __init__(self) -> None:
185
+ self.title = ""
186
+ self.url = ""
187
+ self.snippet = ""
188
+
189
+
190
+ class _DuckDuckGoParser(HTMLParser):
191
+ def __init__(self) -> None:
192
+ super().__init__()
193
+ self.results: list[_DuckResult] = []
194
+ self._current: _DuckResult | None = None
195
+ self._capture: str | None = None
196
+ self._buffer: list[str] = []
197
+
198
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
199
+ attr = dict(attrs)
200
+ classes = attr.get("class", "")
201
+ if tag == "a" and "result__a" in classes:
202
+ self._current = _DuckResult()
203
+ self._current.url = attr.get("href", "") or ""
204
+ self._capture = "title"
205
+ self._buffer = []
206
+ elif self._current is not None and tag in {"a", "div"} and "result__snippet" in classes:
207
+ self._capture = "snippet"
208
+ self._buffer = []
209
+
210
+ def handle_data(self, data: str) -> None:
211
+ if self._capture:
212
+ self._buffer.append(data)
213
+
214
+ def handle_endtag(self, tag: str) -> None:
215
+ if self._current is None or self._capture is None:
216
+ return
217
+ if self._capture == "title" and tag == "a":
218
+ self._current.title = _clean_text(" ".join(self._buffer))
219
+ self._capture = None
220
+ self._buffer = []
221
+ elif self._capture == "snippet" and tag in {"a", "div"}:
222
+ self._current.snippet = _clean_text(" ".join(self._buffer))
223
+ self.results.append(self._current)
224
+ self._current = None
225
+ self._capture = None
226
+ self._buffer = []
227
+
228
+
229
+ class _TextExtractor(HTMLParser):
230
+ def __init__(self) -> None:
231
+ super().__init__()
232
+ self.parts: list[str] = []
233
+ self._skip_depth = 0
234
+
235
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
236
+ if tag in {"script", "style", "noscript", "svg"}:
237
+ self._skip_depth += 1
238
+
239
+ def handle_endtag(self, tag: str) -> None:
240
+ if tag in {"script", "style", "noscript", "svg"} and self._skip_depth:
241
+ self._skip_depth -= 1
242
+
243
+ def handle_data(self, data: str) -> None:
244
+ if not self._skip_depth:
245
+ cleaned = _clean_text(data)
246
+ if cleaned:
247
+ self.parts.append(cleaned)
248
+
249
+
250
+ def _html_to_text(html: str) -> str:
251
+ extractor = _TextExtractor()
252
+ extractor.feed(html)
253
+ return _clean_text(" ".join(extractor.parts))
254
+
255
+
256
+ def _clean_text(value: str) -> str:
257
+ text = unescape(value)
258
+ text = re.sub(r"\s+", " ", text)
259
+ return text.strip()
260
+
261
+
262
+ def _clean_duckduckgo_url(url: str) -> str:
263
+ parsed = urlparse(url)
264
+ if parsed.netloc.endswith("duckduckgo.com") and parsed.path.startswith("/l/"):
265
+ target = parse_qs(parsed.query).get("uddg", [""])[0]
266
+ return unquote(target)
267
+ return url
@@ -1,4 +1,4 @@
1
- """Lightweight in-memory cache scaffold for provider responses."""
1
+ """Lightweight in-memory TTL cache for provider responses."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -16,7 +16,7 @@ class CacheEntry(Generic[T]):
16
16
 
17
17
 
18
18
  class TTLCache(Generic[T]):
19
- """Simple TTL cache for Phase 1. Provider modules can replace this later."""
19
+ """Simple runtime TTL cache used by router and provider workflows."""
20
20
 
21
21
  def __init__(self, default_ttl: int = 300) -> None:
22
22
  self.default_ttl = default_ttl
@@ -80,6 +80,23 @@ class FinCLIDatabase:
80
80
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
81
81
  PRIMARY KEY (namespace, cache_key)
82
82
  );
83
+
84
+ CREATE TABLE IF NOT EXISTS sessions (
85
+ id TEXT PRIMARY KEY,
86
+ title TEXT NOT NULL,
87
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
88
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
89
+ );
90
+
91
+ CREATE TABLE IF NOT EXISTS session_events (
92
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ session_id TEXT NOT NULL,
94
+ command TEXT NOT NULL,
95
+ status TEXT NOT NULL,
96
+ output_preview TEXT DEFAULT '',
97
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
98
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
99
+ );
83
100
  """
84
101
  )
85
102
  except sqlite3.Error as exc:
@@ -24,7 +24,7 @@ def load_local_secrets(path: Path | None = None, *, override: bool = False) -> N
24
24
  key, value = stripped.split("=", 1)
25
25
  key = key.strip()
26
26
  value = _unquote(value.strip())
27
- if key and (override or key not in os.environ):
27
+ if key and (override or key not in os.environ or os.environ.get(key, "") == ""):
28
28
  os.environ[key] = value
29
29
 
30
30
 
@@ -72,8 +72,11 @@ def read_secrets(path: Path | None = None) -> dict[str, str]:
72
72
  def secret_source(env_key: str, path: Path | None = None) -> str:
73
73
  """Return a display-safe source for a secret."""
74
74
  path = path or SECRETS_FILE
75
+ current = os.getenv(env_key)
76
+ if not current:
77
+ return "-"
75
78
  if env_key in os.environ:
76
- if env_key in read_secrets(path):
79
+ if read_secrets(path).get(env_key) == current:
77
80
  return "~/.fincli/secrets.env"
78
81
  return "environment/.env"
79
82
  return "-"
@@ -52,4 +52,4 @@ def format_thinking_message(message: str) -> Text:
52
52
 
53
53
 
54
54
  def format_ai_message(message: str) -> Markdown:
55
- return Markdown(f"* {message}")
55
+ return Markdown(message)