opencandle 0.3.0 → 0.4.0

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 (283) hide show
  1. package/assets/logo.svg +187 -0
  2. package/dist/cli.d.ts +1 -1
  3. package/dist/cli.js +38 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config.d.ts +9 -0
  6. package/dist/config.js +13 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/infra/browser.d.ts +10 -0
  9. package/dist/infra/browser.js +1 -0
  10. package/dist/infra/browser.js.map +1 -1
  11. package/dist/infra/native-dependencies.d.ts +1 -0
  12. package/dist/infra/native-dependencies.js +10 -0
  13. package/dist/infra/native-dependencies.js.map +1 -0
  14. package/dist/infra/node-version.d.ts +2 -0
  15. package/dist/infra/node-version.js +23 -0
  16. package/dist/infra/node-version.js.map +1 -0
  17. package/dist/memory/index.d.ts +2 -0
  18. package/dist/memory/index.js +1 -0
  19. package/dist/memory/index.js.map +1 -1
  20. package/dist/memory/sqlite.js +42 -4
  21. package/dist/memory/sqlite.js.map +1 -1
  22. package/dist/memory/storage.d.ts +6 -0
  23. package/dist/memory/storage.js +3 -3
  24. package/dist/memory/storage.js.map +1 -1
  25. package/dist/memory/tool-defaults.d.ts +8 -0
  26. package/dist/memory/tool-defaults.js +59 -0
  27. package/dist/memory/tool-defaults.js.map +1 -0
  28. package/dist/onboarding/connect.d.ts +13 -1
  29. package/dist/onboarding/connect.js +21 -10
  30. package/dist/onboarding/connect.js.map +1 -1
  31. package/dist/onboarding/prompt-user.d.ts +1 -1
  32. package/dist/onboarding/providers.d.ts +7 -0
  33. package/dist/onboarding/providers.js +6 -3
  34. package/dist/onboarding/providers.js.map +1 -1
  35. package/dist/onboarding/tool-helpers.d.ts +1 -1
  36. package/dist/pi/opencandle-extension.d.ts +7 -1
  37. package/dist/pi/opencandle-extension.js +186 -10
  38. package/dist/pi/opencandle-extension.js.map +1 -1
  39. package/dist/pi/session-storage.d.ts +2 -0
  40. package/dist/pi/session-storage.js +5 -0
  41. package/dist/pi/session-storage.js.map +1 -0
  42. package/dist/pi/session.d.ts +4 -1
  43. package/dist/pi/session.js +25 -3
  44. package/dist/pi/session.js.map +1 -1
  45. package/dist/pi/setup.d.ts +1 -1
  46. package/dist/pi/setup.js +1 -1
  47. package/dist/pi/setup.js.map +1 -1
  48. package/dist/pi/tool-adapter.d.ts +2 -2
  49. package/dist/pi/tool-adapter.js +14 -1
  50. package/dist/pi/tool-adapter.js.map +1 -1
  51. package/dist/prompts/context-builder.d.ts +22 -0
  52. package/dist/prompts/context-builder.js +45 -10
  53. package/dist/prompts/context-builder.js.map +1 -1
  54. package/dist/prompts/disclaimer.d.ts +6 -0
  55. package/dist/prompts/disclaimer.js +9 -0
  56. package/dist/prompts/disclaimer.js.map +1 -0
  57. package/dist/prompts/workflow-prompts.d.ts +8 -0
  58. package/dist/prompts/workflow-prompts.js +39 -5
  59. package/dist/prompts/workflow-prompts.js.map +1 -1
  60. package/dist/providers/yahoo-finance.js +70 -33
  61. package/dist/providers/yahoo-finance.js.map +1 -1
  62. package/dist/routing/defaults.js +1 -1
  63. package/dist/routing/defaults.js.map +1 -1
  64. package/dist/routing/index.d.ts +4 -0
  65. package/dist/routing/index.js +3 -0
  66. package/dist/routing/index.js.map +1 -1
  67. package/dist/routing/router-llm-client.d.ts +11 -0
  68. package/dist/routing/router-llm-client.js +42 -0
  69. package/dist/routing/router-llm-client.js.map +1 -0
  70. package/dist/routing/router-prompt.d.ts +2 -0
  71. package/dist/routing/router-prompt.js +138 -0
  72. package/dist/routing/router-prompt.js.map +1 -0
  73. package/dist/routing/router-types.d.ts +62 -0
  74. package/dist/routing/router-types.js +2 -0
  75. package/dist/routing/router-types.js.map +1 -0
  76. package/dist/routing/router.d.ts +10 -0
  77. package/dist/routing/router.js +194 -0
  78. package/dist/routing/router.js.map +1 -0
  79. package/dist/runtime/session-coordinator.d.ts +63 -3
  80. package/dist/runtime/session-coordinator.js +155 -4
  81. package/dist/runtime/session-coordinator.js.map +1 -1
  82. package/dist/runtime/tool-defaults-wrapper.d.ts +3 -0
  83. package/dist/runtime/tool-defaults-wrapper.js +25 -0
  84. package/dist/runtime/tool-defaults-wrapper.js.map +1 -0
  85. package/dist/sentiment/store.js +5 -0
  86. package/dist/sentiment/store.js.map +1 -1
  87. package/dist/system-prompt.js +20 -12
  88. package/dist/system-prompt.js.map +1 -1
  89. package/dist/tool-kit.d.ts +4 -4
  90. package/dist/tools/fundamentals/company-overview.d.ts +1 -1
  91. package/dist/tools/fundamentals/comps.d.ts +1 -1
  92. package/dist/tools/fundamentals/dcf.d.ts +1 -1
  93. package/dist/tools/fundamentals/earnings.d.ts +1 -1
  94. package/dist/tools/fundamentals/financials.d.ts +1 -1
  95. package/dist/tools/fundamentals/sec-filings.d.ts +1 -1
  96. package/dist/tools/index.d.ts +28 -1
  97. package/dist/tools/index.js +27 -0
  98. package/dist/tools/index.js.map +1 -1
  99. package/dist/tools/interaction/ask-user.d.ts +1 -1
  100. package/dist/tools/interaction/twitter-login.d.ts +1 -1
  101. package/dist/tools/macro/fear-greed.d.ts +1 -1
  102. package/dist/tools/macro/fred-data.d.ts +1 -1
  103. package/dist/tools/market/crypto-history.d.ts +1 -1
  104. package/dist/tools/market/crypto-price.d.ts +1 -1
  105. package/dist/tools/market/search-ticker.d.ts +1 -1
  106. package/dist/tools/market/stock-history.d.ts +1 -1
  107. package/dist/tools/market/stock-quote.d.ts +1 -1
  108. package/dist/tools/options/option-chain.d.ts +1 -1
  109. package/dist/tools/options/option-chain.js +4 -1
  110. package/dist/tools/options/option-chain.js.map +1 -1
  111. package/dist/tools/portfolio/correlation.d.ts +1 -1
  112. package/dist/tools/portfolio/predictions.d.ts +1 -1
  113. package/dist/tools/portfolio/risk-analysis.d.ts +1 -1
  114. package/dist/tools/portfolio/tracker.d.ts +1 -1
  115. package/dist/tools/portfolio/watchlist.d.ts +1 -1
  116. package/dist/tools/sentiment/reddit-sentiment.d.ts +1 -1
  117. package/dist/tools/sentiment/sentiment-summary.d.ts +1 -1
  118. package/dist/tools/sentiment/sentiment-trend.d.ts +1 -1
  119. package/dist/tools/sentiment/twitter-sentiment.d.ts +1 -1
  120. package/dist/tools/sentiment/web-search.d.ts +1 -1
  121. package/dist/tools/sentiment/web-sentiment.d.ts +1 -1
  122. package/dist/tools/technical/backtest.d.ts +1 -1
  123. package/dist/tools/technical/indicators.d.ts +1 -1
  124. package/dist/tools/technical/indicators.js +7 -1
  125. package/dist/tools/technical/indicators.js.map +1 -1
  126. package/dist/workflows/options-screener.js +7 -2
  127. package/dist/workflows/options-screener.js.map +1 -1
  128. package/dist/workflows/portfolio-builder.js +3 -3
  129. package/dist/workflows/portfolio-builder.js.map +1 -1
  130. package/gui/server/background-quotes.ts +31 -0
  131. package/gui/server/chat-event-adapter.ts +142 -0
  132. package/gui/server/invoke-tool.ts +89 -0
  133. package/gui/server/live-chat-event-adapter.ts +181 -0
  134. package/gui/server/model-setup.ts +100 -0
  135. package/gui/server/package.json +5 -0
  136. package/gui/server/projector.ts +212 -0
  137. package/gui/server/server.ts +592 -0
  138. package/gui/server/session-actions.ts +31 -0
  139. package/gui/server/tool-metadata.ts +88 -0
  140. package/gui/server/websocket.ts +128 -0
  141. package/gui/server/writer-lock.ts +118 -0
  142. package/gui/shared/chat-events.ts +118 -0
  143. package/gui/shared/event-reducer.ts +186 -0
  144. package/gui/web/dist/assets/CatalogOverlay-D1ImSJTe.js +1 -0
  145. package/gui/web/dist/assets/index-DBrWq43L.css +1 -0
  146. package/gui/web/dist/assets/index-RflHaj0y.js +67 -0
  147. package/gui/web/dist/assets/logo-CWpt6Y2a.svg +187 -0
  148. package/gui/web/dist/index.html +17 -0
  149. package/package.json +44 -18
  150. package/src/analysts/contracts.ts +189 -0
  151. package/src/analysts/orchestrator.ts +300 -0
  152. package/src/cli.ts +205 -0
  153. package/src/config.ts +161 -0
  154. package/src/index.ts +5 -0
  155. package/src/infra/browser.ts +111 -0
  156. package/src/infra/cache.ts +103 -0
  157. package/src/infra/http-client.ts +68 -0
  158. package/src/infra/index.ts +18 -0
  159. package/src/infra/native-dependencies.ts +12 -0
  160. package/src/infra/node-version.ts +24 -0
  161. package/src/infra/open-url.ts +28 -0
  162. package/src/infra/opencandle-paths.ts +64 -0
  163. package/src/infra/rate-limiter.ts +64 -0
  164. package/src/memory/index.ts +10 -0
  165. package/src/memory/manager.ts +159 -0
  166. package/src/memory/preference-extractor.ts +106 -0
  167. package/src/memory/retrieval.ts +70 -0
  168. package/src/memory/sqlite.ts +172 -0
  169. package/src/memory/storage.ts +204 -0
  170. package/src/memory/tool-defaults.ts +87 -0
  171. package/src/memory/types.ts +67 -0
  172. package/src/onboarding/connect.ts +184 -0
  173. package/src/onboarding/credential-interceptor.ts +134 -0
  174. package/src/onboarding/degradation-accumulator.ts +79 -0
  175. package/src/onboarding/prompt-user.ts +85 -0
  176. package/src/onboarding/providers.ts +315 -0
  177. package/src/onboarding/state.ts +218 -0
  178. package/src/onboarding/tool-helpers.ts +111 -0
  179. package/src/onboarding/tool-tags.ts +201 -0
  180. package/src/onboarding/validation.ts +158 -0
  181. package/src/pi/opencandle-extension.ts +724 -0
  182. package/src/pi/session-storage.ts +5 -0
  183. package/src/pi/session.ts +81 -0
  184. package/src/pi/setup.ts +371 -0
  185. package/src/pi/tool-adapter.ts +36 -0
  186. package/src/prompts/context-builder.ts +204 -0
  187. package/src/prompts/disclaimer.ts +9 -0
  188. package/src/prompts/sections.ts +46 -0
  189. package/src/prompts/workflow-prompts.ts +279 -0
  190. package/src/providers/alpha-vantage.ts +292 -0
  191. package/src/providers/coingecko.ts +96 -0
  192. package/src/providers/exa-search.ts +373 -0
  193. package/src/providers/fear-greed.ts +45 -0
  194. package/src/providers/finnhub.ts +124 -0
  195. package/src/providers/fred.ts +83 -0
  196. package/src/providers/index.ts +9 -0
  197. package/src/providers/provider-credential-error.ts +23 -0
  198. package/src/providers/reddit.ts +151 -0
  199. package/src/providers/sec-edgar.ts +96 -0
  200. package/src/providers/twitter.ts +173 -0
  201. package/src/providers/web-search.ts +293 -0
  202. package/src/providers/with-fallback.ts +41 -0
  203. package/src/providers/wrap-provider.ts +64 -0
  204. package/src/providers/yahoo-finance.ts +367 -0
  205. package/src/routing/classify-intent.ts +194 -0
  206. package/src/routing/defaults.ts +29 -0
  207. package/src/routing/entity-extractor.ts +140 -0
  208. package/src/routing/index.ts +26 -0
  209. package/src/routing/router-llm-client.ts +51 -0
  210. package/src/routing/router-prompt.ts +159 -0
  211. package/src/routing/router-types.ts +66 -0
  212. package/src/routing/router.ts +213 -0
  213. package/src/routing/slot-resolver.ts +152 -0
  214. package/src/routing/types.ts +63 -0
  215. package/src/runtime/evidence.ts +77 -0
  216. package/src/runtime/index.ts +55 -0
  217. package/src/runtime/prompt-step.ts +75 -0
  218. package/src/runtime/provider-ids.ts +15 -0
  219. package/src/runtime/provider-tracker.ts +40 -0
  220. package/src/runtime/run-context.ts +22 -0
  221. package/src/runtime/session-coordinator.ts +406 -0
  222. package/src/runtime/tool-defaults-wrapper.ts +35 -0
  223. package/src/runtime/validation.ts +214 -0
  224. package/src/runtime/workflow-events.ts +75 -0
  225. package/src/runtime/workflow-runner.ts +188 -0
  226. package/src/runtime/workflow-types.ts +102 -0
  227. package/src/sentiment/adapters/finnhub.ts +44 -0
  228. package/src/sentiment/adapters/reddit.ts +65 -0
  229. package/src/sentiment/adapters/twitter.ts +36 -0
  230. package/src/sentiment/adapters/web.ts +44 -0
  231. package/src/sentiment/index.ts +58 -0
  232. package/src/sentiment/keywords.ts +9 -0
  233. package/src/sentiment/pipeline.ts +68 -0
  234. package/src/sentiment/scorer.ts +78 -0
  235. package/src/sentiment/store.ts +260 -0
  236. package/src/sentiment/trends.ts +90 -0
  237. package/src/sentiment/types.ts +108 -0
  238. package/src/system-prompt.ts +115 -0
  239. package/src/tool-kit.ts +68 -0
  240. package/src/tools/AGENTS.md +36 -0
  241. package/src/tools/fundamentals/company-overview.ts +54 -0
  242. package/src/tools/fundamentals/comps.ts +156 -0
  243. package/src/tools/fundamentals/dcf.ts +267 -0
  244. package/src/tools/fundamentals/earnings.ts +47 -0
  245. package/src/tools/fundamentals/financials.ts +54 -0
  246. package/src/tools/fundamentals/sec-filings.ts +61 -0
  247. package/src/tools/index.ts +88 -0
  248. package/src/tools/interaction/ask-user.ts +81 -0
  249. package/src/tools/interaction/twitter-login.ts +93 -0
  250. package/src/tools/macro/fear-greed.ts +41 -0
  251. package/src/tools/macro/fred-data.ts +54 -0
  252. package/src/tools/market/crypto-history.ts +51 -0
  253. package/src/tools/market/crypto-price.ts +53 -0
  254. package/src/tools/market/search-ticker.ts +53 -0
  255. package/src/tools/market/stock-history.ts +79 -0
  256. package/src/tools/market/stock-quote.ts +64 -0
  257. package/src/tools/options/greeks.ts +82 -0
  258. package/src/tools/options/option-chain.ts +91 -0
  259. package/src/tools/portfolio/correlation.ts +162 -0
  260. package/src/tools/portfolio/predictions.ts +253 -0
  261. package/src/tools/portfolio/risk-analysis.ts +134 -0
  262. package/src/tools/portfolio/tracker.ts +147 -0
  263. package/src/tools/portfolio/watchlist.ts +153 -0
  264. package/src/tools/sentiment/reddit-sentiment.ts +164 -0
  265. package/src/tools/sentiment/sentiment-summary.ts +256 -0
  266. package/src/tools/sentiment/sentiment-trend.ts +58 -0
  267. package/src/tools/sentiment/twitter-sentiment.ts +96 -0
  268. package/src/tools/sentiment/web-search.ts +150 -0
  269. package/src/tools/sentiment/web-sentiment.ts +76 -0
  270. package/src/tools/technical/backtest.ts +246 -0
  271. package/src/tools/technical/indicators.ts +258 -0
  272. package/src/types/fundamentals.ts +46 -0
  273. package/src/types/index.ts +20 -0
  274. package/src/types/macro.ts +27 -0
  275. package/src/types/market.ts +43 -0
  276. package/src/types/options.ts +35 -0
  277. package/src/types/portfolio.ts +41 -0
  278. package/src/types/sentiment.ts +70 -0
  279. package/src/workflows/compare-assets.ts +39 -0
  280. package/src/workflows/index.ts +4 -0
  281. package/src/workflows/options-screener.ts +49 -0
  282. package/src/workflows/portfolio-builder.ts +52 -0
  283. package/src/workflows/types.ts +4 -0
@@ -0,0 +1,373 @@
1
+ import { cache, TTL, STALE_LIMIT } from "../infra/cache.js";
2
+ import { rateLimiter } from "../infra/rate-limiter.js";
3
+ import { getConfig } from "../config.js";
4
+ import { ProviderCredentialError } from "./provider-credential-error.js";
5
+ import type { WebSearchResult, WebSearchEnvelope } from "../types/sentiment.js";
6
+ import type { WebSearchOpts } from "./web-search.js";
7
+
8
+ const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
9
+ const EXA_API_URL = "https://api.exa.ai/search";
10
+ const TIMEOUT_MS = 5_000;
11
+ const SNIPPET_MAX_CHARS = 300;
12
+ const CONTEXT_MAX_CHARS = 1_000;
13
+
14
+ let requestIdCounter = 0;
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Freshness helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ function freshnessToMs(freshness: WebSearchOpts["freshness"]): number {
21
+ switch (freshness) {
22
+ case "hours": return 60 * 60 * 1000;
23
+ case "day": return 24 * 60 * 60 * 1000;
24
+ case "week": return 7 * 24 * 60 * 60 * 1000;
25
+ case "month": return 30 * 24 * 60 * 60 * 1000;
26
+ }
27
+ }
28
+
29
+ function enrichQueryForMcp(query: string, freshness: WebSearchOpts["freshness"]): string {
30
+ switch (freshness) {
31
+ case "hours": return `${query} past hour`;
32
+ case "day": return `${query} past 24 hours`;
33
+ case "week": return `${query} past week`;
34
+ case "month": return `${query} past month`;
35
+ }
36
+ }
37
+
38
+ function startPublishedDate(freshness: WebSearchOpts["freshness"]): string {
39
+ return new Date(Date.now() - freshnessToMs(freshness)).toISOString();
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // MCP response parsing
44
+ // ---------------------------------------------------------------------------
45
+
46
+ interface McpRpcResponse {
47
+ result?: {
48
+ content?: Array<{ type?: string; text?: string; _meta?: Record<string, unknown> }>;
49
+ };
50
+ error?: {
51
+ code?: number;
52
+ message?: string;
53
+ };
54
+ }
55
+
56
+ function extractJsonRpcPayload(body: string, contentType: string): McpRpcResponse {
57
+ // Path 1: plain JSON response
58
+ if (contentType.includes("application/json")) {
59
+ return JSON.parse(body) as McpRpcResponse;
60
+ }
61
+
62
+ // Path 2: SSE — scan all data: lines for valid JSON-RPC
63
+ const dataLines = body.split("\n").filter((line) => line.startsWith("data:"));
64
+ for (const line of dataLines) {
65
+ const payload = line.slice(5).trim();
66
+ if (!payload) continue;
67
+ try {
68
+ const candidate = JSON.parse(payload) as McpRpcResponse;
69
+ if (candidate?.result || candidate?.error) return candidate;
70
+ } catch {
71
+ // not valid JSON, try next line
72
+ }
73
+ }
74
+
75
+ // Path 3: fallback — try parsing entire body
76
+ try {
77
+ const candidate = JSON.parse(body) as McpRpcResponse;
78
+ if (candidate?.result || candidate?.error) return candidate;
79
+ } catch {
80
+ // not valid JSON
81
+ }
82
+
83
+ throw new Error("Exa MCP returned empty content");
84
+ }
85
+
86
+ interface ParsedResult {
87
+ title: string;
88
+ url: string;
89
+ published: string | null;
90
+ snippet: string;
91
+ }
92
+
93
+ function parseMcpResultBlocks(text: string): ParsedResult[] {
94
+ const blocks = text.split(/\n---\n/).filter((b) => b.trim().length > 0);
95
+ const results: ParsedResult[] = [];
96
+
97
+ for (const block of blocks) {
98
+ const title = block.match(/^Title: (.+)/m)?.[1]?.trim() ?? "";
99
+ const url = block.match(/^URL: (.+)/m)?.[1]?.trim() ?? "";
100
+ if (!url) continue;
101
+
102
+ const published = block.match(/^Published: (.+)/m)?.[1]?.trim() ?? null;
103
+
104
+ // Extract highlights/text content after the header fields
105
+ let content = "";
106
+ const hlMatch = block.match(/^Highlights:\n?/m);
107
+ if (hlMatch?.index != null) {
108
+ content = block.slice(hlMatch.index + hlMatch[0].length).trim();
109
+ }
110
+ const snippet = content.slice(0, SNIPPET_MAX_CHARS);
111
+
112
+ results.push({ title, url, published, snippet });
113
+ }
114
+
115
+ return results;
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Direct API response mapping
120
+ // ---------------------------------------------------------------------------
121
+
122
+ interface ExaApiResult {
123
+ title?: string;
124
+ url?: string;
125
+ publishedDate?: string;
126
+ text?: string;
127
+ highlights?: string[];
128
+ }
129
+
130
+ interface ExaApiResponse {
131
+ results?: ExaApiResult[];
132
+ }
133
+
134
+ function mapApiResults(results: ExaApiResult[]): ParsedResult[] {
135
+ return results
136
+ .filter((r) => r.url)
137
+ .map((r) => ({
138
+ title: r.title ?? "",
139
+ url: r.url!,
140
+ published: r.publishedDate ?? null,
141
+ snippet: (r.highlights?.join(" ") || r.text || "").slice(0, SNIPPET_MAX_CHARS),
142
+ }));
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Freshness post-filter
147
+ // ---------------------------------------------------------------------------
148
+
149
+ function filterByFreshness(
150
+ results: ParsedResult[],
151
+ freshness: WebSearchOpts["freshness"],
152
+ ): ParsedResult[] {
153
+ const cutoff = Date.now() - freshnessToMs(freshness);
154
+ return results.filter((r) => {
155
+ if (!r.published) return true; // benefit of the doubt
156
+ const pubTime = new Date(r.published).getTime();
157
+ return !isNaN(pubTime) && pubTime >= cutoff;
158
+ });
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Common helpers
163
+ // ---------------------------------------------------------------------------
164
+
165
+ function extractDomain(url: string): string {
166
+ try {
167
+ return new URL(url).hostname.replace(/^www\./, "");
168
+ } catch {
169
+ return url;
170
+ }
171
+ }
172
+
173
+ function toWebSearchResults(
174
+ parsed: ParsedResult[],
175
+ category: "news" | "general",
176
+ ): WebSearchResult[] {
177
+ return parsed.map((r) => ({
178
+ title: r.title,
179
+ url: r.url,
180
+ snippet: r.snippet,
181
+ source: extractDomain(r.url),
182
+ published: r.published,
183
+ category,
184
+ }));
185
+ }
186
+
187
+ function exaCacheKey(query: string, opts: WebSearchOpts): string {
188
+ return `web:exa:${query}:${opts.category}:${opts.freshness}:${opts.limit}`;
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // MCP path
193
+ // ---------------------------------------------------------------------------
194
+
195
+ async function exaMcpSearch(
196
+ query: string,
197
+ opts: WebSearchOpts,
198
+ ): Promise<WebSearchEnvelope> {
199
+ const enrichedQuery = enrichQueryForMcp(query, opts.freshness);
200
+
201
+ const response = await fetch(EXA_MCP_URL, {
202
+ method: "POST",
203
+ headers: {
204
+ "Content-Type": "application/json",
205
+ Accept: "application/json, text/event-stream",
206
+ },
207
+ body: JSON.stringify({
208
+ jsonrpc: "2.0",
209
+ id: ++requestIdCounter,
210
+ method: "tools/call",
211
+ params: {
212
+ name: "web_search_exa",
213
+ arguments: {
214
+ query: enrichedQuery,
215
+ numResults: opts.limit,
216
+ livecrawl: "fallback",
217
+ type: "auto",
218
+ contextMaxCharacters: CONTEXT_MAX_CHARS,
219
+ },
220
+ },
221
+ }),
222
+ signal: AbortSignal.timeout(TIMEOUT_MS),
223
+ });
224
+
225
+ // Anti-abuse handling
226
+ const contentType = response.headers.get("content-type") ?? "";
227
+ if (!response.ok) {
228
+ if (response.status === 429) {
229
+ const retryAfter = response.headers.get("retry-after");
230
+ throw new Error(
231
+ `Exa MCP rate limited (429)${retryAfter ? ` — retry after ${retryAfter}s` : ""}`,
232
+ );
233
+ }
234
+ if (response.status === 403) {
235
+ throw new Error("Exa MCP blocked (403) — possible IP-based blocking");
236
+ }
237
+ throw new Error(`Exa MCP HTTP ${response.status} ${response.statusText}`);
238
+ }
239
+
240
+ if (contentType.includes("text/html")) {
241
+ throw new Error("Exa MCP returned HTML instead of JSON-RPC (possible challenge page)");
242
+ }
243
+
244
+ const body = await response.text();
245
+ const payload = extractJsonRpcPayload(body, contentType);
246
+
247
+ if (payload.error) {
248
+ throw new Error(payload.error.message ?? `Exa MCP error: code ${payload.error.code}`);
249
+ }
250
+
251
+ const text = payload.result?.content?.find(
252
+ (item) => item.type === "text" && typeof item.text === "string",
253
+ )?.text;
254
+
255
+ if (!text || text.trim().length === 0) {
256
+ // Legitimate zero results
257
+ return {
258
+ query,
259
+ results: [],
260
+ resultCount: 0,
261
+ fetchedAt: new Date().toISOString(),
262
+ provider: "exa",
263
+ };
264
+ }
265
+
266
+ const parsed = parseMcpResultBlocks(text);
267
+ const filtered = filterByFreshness(parsed, opts.freshness);
268
+ const results = toWebSearchResults(filtered, opts.category);
269
+
270
+ return {
271
+ query,
272
+ results: results.slice(0, opts.limit),
273
+ resultCount: Math.min(results.length, opts.limit),
274
+ fetchedAt: new Date().toISOString(),
275
+ provider: "exa",
276
+ };
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Direct API path
281
+ // ---------------------------------------------------------------------------
282
+
283
+ async function exaApiSearch(
284
+ query: string,
285
+ opts: WebSearchOpts,
286
+ apiKey: string,
287
+ ): Promise<WebSearchEnvelope> {
288
+ const response = await fetch(EXA_API_URL, {
289
+ method: "POST",
290
+ headers: {
291
+ "Content-Type": "application/json",
292
+ Authorization: `Bearer ${apiKey}`,
293
+ },
294
+ body: JSON.stringify({
295
+ query,
296
+ type: "auto",
297
+ numResults: opts.limit,
298
+ startPublishedDate: startPublishedDate(opts.freshness),
299
+ contents: {
300
+ text: { maxCharacters: CONTEXT_MAX_CHARS },
301
+ highlights: true,
302
+ },
303
+ }),
304
+ signal: AbortSignal.timeout(TIMEOUT_MS),
305
+ });
306
+
307
+ if (!response.ok) {
308
+ if (response.status === 401 || response.status === 403) {
309
+ throw new ProviderCredentialError("exa", "stale", response.status);
310
+ }
311
+ const body = await response.text().catch(() => "");
312
+ throw new Error(`Exa API HTTP ${response.status}: ${body.slice(0, 300)}`);
313
+ }
314
+
315
+ const data = (await response.json()) as ExaApiResponse;
316
+ const parsed = mapApiResults(data.results ?? []);
317
+ const filtered = filterByFreshness(parsed, opts.freshness);
318
+ const results = toWebSearchResults(filtered, opts.category);
319
+
320
+ return {
321
+ query,
322
+ results: results.slice(0, opts.limit),
323
+ resultCount: Math.min(results.length, opts.limit),
324
+ fetchedAt: new Date().toISOString(),
325
+ provider: "exa",
326
+ };
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Public entry point
331
+ // ---------------------------------------------------------------------------
332
+
333
+ export async function exaSearch(
334
+ query: string,
335
+ opts: WebSearchOpts,
336
+ ): Promise<WebSearchEnvelope> {
337
+ const key = exaCacheKey(query, opts);
338
+ const cached = cache.get<WebSearchEnvelope>(key);
339
+ if (cached) return cached;
340
+
341
+ try {
342
+ await rateLimiter.acquire("exa");
343
+
344
+ const config = getConfig();
345
+ const envelope = config.exaApiKey
346
+ ? await exaApiSearch(query, opts, config.exaApiKey)
347
+ : await exaMcpSearch(query, opts);
348
+
349
+ cache.set(key, envelope, TTL.WEB_SEARCH);
350
+ return envelope;
351
+ } catch (error) {
352
+ // Re-throw abort errors immediately
353
+ if (error instanceof Error && error.name === "AbortError") throw error;
354
+ if (error instanceof DOMException && error.name === "TimeoutError") throw error;
355
+ // Re-throw credential errors so the tool layer can convert them to the
356
+ // tagged content block. Stale cache must not mask a real auth failure.
357
+ if (error instanceof ProviderCredentialError) throw error;
358
+
359
+ const stale = cache.getStale<WebSearchEnvelope>(key, STALE_LIMIT.WEB_SEARCH);
360
+ if (stale) return stale.value;
361
+ throw error;
362
+ }
363
+ }
364
+
365
+ // Exported for testing
366
+ export {
367
+ parseMcpResultBlocks,
368
+ extractJsonRpcPayload,
369
+ enrichQueryForMcp,
370
+ filterByFreshness,
371
+ mapApiResults,
372
+ };
373
+ export type { ParsedResult, McpRpcResponse, ExaApiResponse };
@@ -0,0 +1,45 @@
1
+ import { httpGet } from "../infra/http-client.js";
2
+ import { cache, TTL, STALE_LIMIT } from "../infra/cache.js";
3
+ import type { FearGreedData } from "../types/sentiment.js";
4
+
5
+ // alternative.me provides a free crypto Fear & Greed index
6
+ // CNN endpoint (production.dataviz.cnn.io) blocks automated requests (HTTP 418)
7
+ const ENDPOINT = "https://api.alternative.me/fng/?limit=3";
8
+
9
+ interface AlternativeMeFngResponse {
10
+ data: Array<{
11
+ value: string;
12
+ value_classification: string;
13
+ timestamp: string;
14
+ }>;
15
+ }
16
+
17
+ export async function getFearGreedIndex(): Promise<FearGreedData> {
18
+ const cacheKey = "feargreed:index";
19
+ const cached = cache.get<FearGreedData>(cacheKey);
20
+ if (cached) return cached;
21
+
22
+ try {
23
+ const data = await httpGet<AlternativeMeFngResponse>(ENDPOINT);
24
+
25
+ const entries = data.data;
26
+ const current = entries[0];
27
+ const value = parseInt(current.value, 10);
28
+
29
+ const result: FearGreedData = {
30
+ value,
31
+ label: current.value_classification,
32
+ timestamp: Date.now(),
33
+ previousClose: entries[1] ? parseInt(entries[1].value, 10) : value,
34
+ weekAgo: null,
35
+ monthAgo: null,
36
+ };
37
+
38
+ cache.set(cacheKey, result, TTL.SENTIMENT);
39
+ return result;
40
+ } catch (error) {
41
+ const stale = cache.getStale<FearGreedData>(cacheKey, STALE_LIMIT.SENTIMENT);
42
+ if (stale) return stale.value;
43
+ throw error;
44
+ }
45
+ }
@@ -0,0 +1,124 @@
1
+ import { httpGet, HttpError } from "../infra/http-client.js";
2
+ import { cache, TTL, STALE_LIMIT } from "../infra/cache.js";
3
+ import { rateLimiter } from "../infra/rate-limiter.js";
4
+ import { ProviderCredentialError } from "./provider-credential-error.js";
5
+
6
+ const FINNHUB_BASE = "https://finnhub.io/api/v1";
7
+
8
+ export interface FinnhubArticle {
9
+ headline: string;
10
+ summary: string;
11
+ source: string;
12
+ datetime: number;
13
+ url: string;
14
+ related: string;
15
+ id: number;
16
+ category: string;
17
+ image: string;
18
+ }
19
+
20
+ // Ticker → company name mapping for relevance filtering
21
+ const TICKER_NAMES: Record<string, string> = {
22
+ AAPL: "apple",
23
+ MSFT: "microsoft",
24
+ GOOGL: "google",
25
+ GOOG: "google",
26
+ AMZN: "amazon",
27
+ META: "meta",
28
+ TSLA: "tesla",
29
+ NVDA: "nvidia",
30
+ TSM: "tsmc",
31
+ BABA: "alibaba",
32
+ NFLX: "netflix",
33
+ AMD: "amd",
34
+ INTC: "intel",
35
+ CRM: "salesforce",
36
+ ORCL: "oracle",
37
+ };
38
+
39
+ export function finnhubDateRange(freshness: "hours" | "day" | "week" | "month"): { from: string; to: string } {
40
+ const now = new Date();
41
+ const to = formatDate(now);
42
+
43
+ switch (freshness) {
44
+ case "hours":
45
+ return { from: to, to };
46
+ case "day": {
47
+ const d = new Date(now);
48
+ d.setDate(d.getDate() - 1);
49
+ return { from: formatDate(d), to };
50
+ }
51
+ case "week": {
52
+ const d = new Date(now);
53
+ d.setDate(d.getDate() - 7);
54
+ return { from: formatDate(d), to };
55
+ }
56
+ case "month": {
57
+ const d = new Date(now);
58
+ d.setDate(d.getDate() - 30);
59
+ return { from: formatDate(d), to };
60
+ }
61
+ }
62
+ }
63
+
64
+ function formatDate(d: Date): string {
65
+ return d.toISOString().slice(0, 10);
66
+ }
67
+
68
+ function cacheKey(symbol: string, from: string, to: string): string {
69
+ return `finnhub:news:${symbol}:${from}:${to}`;
70
+ }
71
+
72
+ export function filterByRelevance(
73
+ articles: FinnhubArticle[],
74
+ symbol: string,
75
+ limit = 20,
76
+ ): FinnhubArticle[] {
77
+ const symLower = symbol.toLowerCase();
78
+ const companyName = TICKER_NAMES[symbol.toUpperCase()];
79
+
80
+ const filtered = articles.filter((a) => {
81
+ const hl = a.headline.toLowerCase();
82
+ const sm = a.summary.toLowerCase();
83
+ if (hl.includes(symLower) || sm.includes(symLower)) return true;
84
+ if (companyName && (hl.includes(companyName) || sm.includes(companyName))) return true;
85
+ return false;
86
+ });
87
+
88
+ // Most recent first, capped
89
+ return filtered
90
+ .sort((a, b) => b.datetime - a.datetime)
91
+ .slice(0, limit);
92
+ }
93
+
94
+ export async function getCompanyNews(
95
+ symbol: string,
96
+ from: string,
97
+ to: string,
98
+ apiKey: string,
99
+ ): Promise<FinnhubArticle[]> {
100
+ const key = cacheKey(symbol, from, to);
101
+ const cached = cache.get<FinnhubArticle[]>(key);
102
+ if (cached) return cached;
103
+
104
+ try {
105
+ await rateLimiter.acquire("finnhub");
106
+
107
+ const url = `${FINNHUB_BASE}/company-news?symbol=${encodeURIComponent(symbol)}&from=${from}&to=${to}&token=${apiKey}`;
108
+ const data = await httpGet<FinnhubArticle[]>(url);
109
+
110
+ const articles = Array.isArray(data) ? data : [];
111
+ const filtered = filterByRelevance(articles, symbol);
112
+
113
+ cache.set(key, filtered, TTL.FINNHUB_NEWS);
114
+ return filtered;
115
+ } catch (error) {
116
+ if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
117
+ throw new ProviderCredentialError("finnhub", "stale", error.status);
118
+ }
119
+
120
+ const stale = cache.getStale<FinnhubArticle[]>(key, STALE_LIMIT.FINNHUB_NEWS);
121
+ if (stale) return stale.value;
122
+ throw error;
123
+ }
124
+ }
@@ -0,0 +1,83 @@
1
+ import { httpGet, HttpError } from "../infra/http-client.js";
2
+ import { cache, TTL, STALE_LIMIT } from "../infra/cache.js";
3
+ import { rateLimiter } from "../infra/rate-limiter.js";
4
+ import { ProviderCredentialError } from "./provider-credential-error.js";
5
+ import type { FredSeries, FredObservation } from "../types/macro.js";
6
+
7
+ const BASE_URL = "https://api.stlouisfed.org/fred";
8
+
9
+ interface FredSeriesResponse {
10
+ seriess: Array<{
11
+ id: string;
12
+ title: string;
13
+ units: string;
14
+ frequency: string;
15
+ last_updated: string;
16
+ }>;
17
+ }
18
+
19
+ interface FredObservationsResponse {
20
+ observations: Array<{
21
+ date: string;
22
+ value: string;
23
+ }>;
24
+ }
25
+
26
+ export async function getSeries(
27
+ seriesId: string,
28
+ apiKey: string,
29
+ limit: number = 60,
30
+ ): Promise<FredSeries> {
31
+ const cacheKey = `fred:series:${seriesId}:${limit}`;
32
+ const cached = cache.get<FredSeries>(cacheKey);
33
+ if (cached) return cached;
34
+
35
+ try {
36
+ await rateLimiter.acquire("fred");
37
+
38
+ // Fetch series metadata and observations in parallel
39
+ const metaUrl = `${BASE_URL}/series?series_id=${seriesId}&api_key=${apiKey}&file_type=json`;
40
+ const obsUrl = `${BASE_URL}/series/observations?series_id=${seriesId}&api_key=${apiKey}&file_type=json&sort_order=desc&limit=${limit}`;
41
+
42
+ const [metaData, obsData] = await Promise.all([
43
+ httpGet<FredSeriesResponse>(metaUrl),
44
+ httpGet<FredObservationsResponse>(obsUrl),
45
+ ]);
46
+
47
+ const meta = metaData.seriess[0];
48
+ const observations: FredObservation[] = obsData.observations
49
+ .filter((o) => o.value !== ".")
50
+ .map((o) => ({
51
+ date: o.date,
52
+ value: parseFloat(o.value),
53
+ }))
54
+ .reverse(); // chronological order
55
+
56
+ const result: FredSeries = {
57
+ id: meta.id,
58
+ title: meta.title,
59
+ observations,
60
+ units: meta.units,
61
+ frequency: meta.frequency,
62
+ lastUpdated: meta.last_updated,
63
+ };
64
+
65
+ cache.set(cacheKey, result, TTL.MACRO);
66
+ return result;
67
+ } catch (error) {
68
+ // FRED historically returns 400 with a body like "Bad Request. The value
69
+ // for variable api_key is not registered..." when the key is invalid. A
70
+ // separate 400 can also indicate a bad series_id, which is NOT a credential
71
+ // problem. We only reclassify as credential-related on 401/403 here to
72
+ // avoid surfacing a `/connect` prompt when the user's real mistake is a
73
+ // typo in a series id. Bad-FRED-key users will still see the raw error
74
+ // message in v1; a body-string check can be added later if this becomes a
75
+ // reported friction.
76
+ if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
77
+ throw new ProviderCredentialError("fred", "stale", error.status);
78
+ }
79
+ const stale = cache.getStale<FredSeries>(cacheKey, STALE_LIMIT.MACRO);
80
+ if (stale) return stale.value;
81
+ throw error;
82
+ }
83
+ }
@@ -0,0 +1,9 @@
1
+ export { getQuote, getHistory, getOptionsChain, clearCrumbCache, getYahooCrumb, computeTimeToExpiry } from "./yahoo-finance.js";
2
+ export { getOverview, getEarnings, getFinancials } from "./alpha-vantage.js";
3
+ export { getSeries } from "./fred.js";
4
+ export { getCryptoPrice, getCryptoHistory } from "./coingecko.js";
5
+ export { getSubredditPosts, scoreSentiment } from "./reddit.js";
6
+ export { searchFilings, type SECFiling } from "./sec-edgar.js";
7
+ export { getFearGreedIndex } from "./fear-greed.js";
8
+ export { searchWeb, ddgSearch, braveSearch, normalizeFinancialQuery } from "./web-search.js";
9
+ export type { WebSearchOpts } from "./web-search.js";
@@ -0,0 +1,23 @@
1
+ // Typed error thrown by providers when they detect a missing or stale credential.
2
+ //
3
+ // Providers remain pure fetchers that throw on failure — this class is the
4
+ // structured hand-off to the tool layer, where `withCredentialCheck` catches
5
+ // the error and emits a tagged tool-result content string.
6
+ //
7
+ // Keep this module dependency-free (aside from the ProviderId type) so it can
8
+ // be imported from anywhere without pulling in config loading or registry
9
+ // side effects.
10
+
11
+ import type { ProviderId } from "../onboarding/providers.js";
12
+
13
+ export class ProviderCredentialError extends Error {
14
+ readonly name = "ProviderCredentialError";
15
+
16
+ constructor(
17
+ readonly provider: ProviderId,
18
+ readonly reason: "missing" | "stale",
19
+ readonly httpStatus?: number,
20
+ ) {
21
+ super(`credential_required:${provider}:${reason}`);
22
+ }
23
+ }