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,164 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
3
+ import { getSubredditPosts, getPostComments } from "../../providers/reddit.js";
4
+ import { wrapProvider } from "../../providers/wrap-provider.js";
5
+ import type { RedditSentimentResult } from "../../types/sentiment.js";
6
+ import { RedditAdapter } from "../../sentiment/adapters/reddit.js";
7
+ import { getSentimentPipeline } from "../../sentiment/index.js";
8
+ import { getConfig } from "../../config.js";
9
+
10
+ const params = Type.Object({
11
+ subreddit: Type.Optional(
12
+ Type.String({
13
+ description:
14
+ "Subreddit name (e.g. wallstreetbets, stocks). If omitted, searches across default subreddits.",
15
+ }),
16
+ ),
17
+ query: Type.Optional(
18
+ Type.String({
19
+ description:
20
+ "Topic or ticker to filter posts by (e.g. AAPL, bitcoin). Searches titles and post bodies.",
21
+ }),
22
+ ),
23
+ subreddits: Type.Optional(
24
+ Type.Array(Type.String(), {
25
+ description: "Multiple subreddits to search. Overrides single subreddit param.",
26
+ }),
27
+ ),
28
+ limit: Type.Optional(
29
+ Type.Number({ description: "Number of posts per subreddit. Default: 25, max: 100" }),
30
+ ),
31
+ });
32
+
33
+ export const redditSentimentTool: AgentTool<typeof params, RedditSentimentResult> = {
34
+ name: "get_reddit_sentiment",
35
+ label: "Reddit Sentiment",
36
+ description:
37
+ "Analyze sentiment from financial Reddit communities. Supports single subreddit, multi-subreddit, and topic filtering. Returns scored posts with comment analysis and trend context.",
38
+ parameters: params,
39
+ async execute(toolCallId, args) {
40
+ const limit = Math.min(args.limit ?? 25, 100);
41
+ const config = getConfig();
42
+
43
+ // Determine subreddits to search
44
+ let subreddits: string[];
45
+ if (args.subreddits && args.subreddits.length > 0) {
46
+ subreddits = args.subreddits;
47
+ } else if (args.subreddit) {
48
+ subreddits = [args.subreddit];
49
+ } else {
50
+ subreddits = config.sentiment?.defaultSubreddits ?? ["wallstreetbets", "stocks", "investing", "options"];
51
+ }
52
+
53
+ // Fetch from all subreddits
54
+ const allResults: RedditSentimentResult[] = [];
55
+ const warnings: string[] = [];
56
+ for (const sub of subreddits) {
57
+ const providerResult = await wrapProvider("reddit", () => getSubredditPosts(sub, limit));
58
+ if (providerResult.status === "unavailable") {
59
+ warnings.push(`r/${sub}: ${providerResult.reason}`);
60
+ continue;
61
+ }
62
+ allResults.push(providerResult.data);
63
+ }
64
+
65
+ if (allResults.length === 0) {
66
+ return {
67
+ content: [{ type: "text", text: `⚠ Reddit sentiment unavailable (${warnings.join("; ")}).` }],
68
+ details: null as any,
69
+ };
70
+ }
71
+
72
+ // Merge and filter by query if provided
73
+ const adapter = new RedditAdapter();
74
+ let allRecords = allResults.flatMap((r) => adapter.mapPostsToRecords(r, args.query ?? subreddits.join("+")));
75
+
76
+ // Topic filtering
77
+ if (args.query) {
78
+ const queryLower = args.query.toLowerCase();
79
+ allRecords = allRecords.filter((r) =>
80
+ r.text.toLowerCase().includes(queryLower) ||
81
+ (r.title?.toLowerCase().includes(queryLower) ?? false),
82
+ );
83
+ }
84
+
85
+ // Deduplicate by sourceId (crossposts)
86
+ const seen = new Set<string>();
87
+ allRecords = allRecords.filter((r) => {
88
+ if (seen.has(r.sourceId)) return false;
89
+ seen.add(r.sourceId);
90
+ return true;
91
+ });
92
+
93
+ // Fetch comments for top 10 posts by engagement
94
+ const commentsPerPost = config.sentiment?.commentsPerPost ?? 5;
95
+ const topPosts = [...allRecords]
96
+ .sort((a, b) => b.engagement.score - a.engagement.score)
97
+ .slice(0, 10);
98
+
99
+ for (const post of topPosts) {
100
+ const sub = (post.metadata.subreddit as string) ?? subreddits[0];
101
+ if ((post.engagement.replies ?? 0) === 0) continue;
102
+ try {
103
+ const comments = await getPostComments(sub, post.sourceId, commentsPerPost);
104
+ const commentRecords = adapter.mapCommentsToRecords(
105
+ comments,
106
+ post.sourceId,
107
+ sub,
108
+ args.query ?? subreddits.join("+"),
109
+ );
110
+ allRecords.push(...commentRecords);
111
+ } catch {
112
+ // Comment fetch failures are non-fatal
113
+ }
114
+ }
115
+
116
+ // Process through pipeline
117
+ const pipeline = getSentimentPipeline();
118
+ const pipelineResult = await pipeline.processRecords(allRecords, args.query ?? subreddits.join("+"));
119
+
120
+ // Build output using first result as base for backward compatibility
121
+ const firstResult = allResults[0];
122
+ const postRecords = pipelineResult.fresh.filter((r) => !r.metadata.isComment);
123
+ const commentRecords = pipelineResult.fresh.filter((r) => r.metadata.isComment);
124
+ const avgScore = postRecords.length > 0
125
+ ? postRecords.reduce((s, r) => s + r.sentiment.score, 0) / postRecords.length
126
+ : 0;
127
+
128
+ const sentimentLabel =
129
+ avgScore > 0.3 ? "Bullish" :
130
+ avgScore < -0.3 ? "Bearish" :
131
+ avgScore > 0 ? "Leaning Bullish" :
132
+ avgScore < 0 ? "Leaning Bearish" : "Neutral";
133
+
134
+ const subLabel = subreddits.length === 1 ? `r/${subreddits[0]}` : `${subreddits.length} subreddits`;
135
+ const lines = [
136
+ `**Reddit: ${args.query ?? subLabel}** — ${postRecords.length} posts, ${commentRecords.length} comments`,
137
+ `Sentiment: ${avgScore.toFixed(2)} (${sentimentLabel})`,
138
+ ];
139
+
140
+ if (firstResult.topMentions.length > 0) {
141
+ lines.push(`Tickers: ${firstResult.topMentions.map((t) => `$${t}`).join(", ")}`);
142
+ }
143
+
144
+ lines.push("");
145
+ lines.push("Top posts:");
146
+ for (const post of postRecords.slice(0, 10)) {
147
+ const scoreIndicator = post.sentiment.score > 0 ? "🟢" : post.sentiment.score < 0 ? "🔴" : "⚪";
148
+ lines.push(` ${scoreIndicator} ⬆${post.engagement.score} 💬${post.engagement.replies ?? 0} — ${(post.title ?? post.text).slice(0, 100)}`);
149
+ }
150
+
151
+ if (pipelineResult.trend && pipelineResult.trend.length > 0) {
152
+ const t = pipelineResult.trend[0];
153
+ lines.push("");
154
+ lines.push(`Trend: ${t.sparkline} ${t.direction} (${t.delta >= 0 ? "+" : ""}${t.delta.toFixed(2)}, ${t.count} records)`);
155
+ }
156
+
157
+ if (warnings.length > 0) {
158
+ lines.push("");
159
+ lines.push(`⚠ ${warnings.join("; ")}`);
160
+ }
161
+
162
+ return { content: [{ type: "text", text: lines.join("\n") }], details: firstResult };
163
+ },
164
+ };
@@ -0,0 +1,256 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
3
+ import { getSubredditPosts, getPostComments } from "../../providers/reddit.js";
4
+ import { getTwitterSentiment } from "../../providers/twitter.js";
5
+ import { searchWeb } from "../../providers/web-search.js";
6
+ import { getCompanyNews, finnhubDateRange } from "../../providers/finnhub.js";
7
+ import { wrapProvider } from "../../providers/wrap-provider.js";
8
+ import { getConfig } from "../../config.js";
9
+ import { TwitterAdapter } from "../../sentiment/adapters/twitter.js";
10
+ import { RedditAdapter } from "../../sentiment/adapters/reddit.js";
11
+ import { WebAdapter } from "../../sentiment/adapters/web.js";
12
+ import { FinnhubAdapter, extractTickersFromQuery } from "../../sentiment/adapters/finnhub.js";
13
+ import { getSentimentPipeline } from "../../sentiment/index.js";
14
+ import type { SentinelRecord } from "../../sentiment/types.js";
15
+ import { hasCredential } from "../../onboarding/providers.js";
16
+ import { buildSoftDegradedTag } from "../../onboarding/tool-tags.js";
17
+
18
+ const params = Type.Object({
19
+ query: Type.String({ description: "Ticker or topic for cross-source sentiment summary" }),
20
+ hours: Type.Optional(
21
+ Type.Number({ description: "Lookback window in hours for live fetching. Default: 24" }),
22
+ ),
23
+ });
24
+
25
+ export const sentimentSummaryTool: AgentTool<typeof params> = {
26
+ name: "get_sentiment_summary",
27
+ label: "Sentiment Summary",
28
+ description:
29
+ "Cross-source sentiment summary combining Twitter, Reddit, and web/news. Returns per-source scores, aggregate sentiment, and divergence detection.",
30
+ parameters: params,
31
+ async execute(toolCallId, args) {
32
+ const hours = args.hours ?? 24;
33
+ const config = getConfig();
34
+ const warnings: string[] = [];
35
+ const allRecords: SentinelRecord[] = [];
36
+
37
+ const twitterAdapter = new TwitterAdapter();
38
+ const redditAdapter = new RedditAdapter();
39
+ const webAdapter = new WebAdapter();
40
+ const finnhubAdapter = new FinnhubAdapter();
41
+
42
+ // Determine if Finnhub should be included (key configured + ticker in
43
+ // query). `candidateTickers` is extracted unconditionally so we can tell
44
+ // a "no finnhub-mappable ticker in the query" case apart from a "query
45
+ // has tickers but user has no Finnhub key" case — the latter warrants a
46
+ // soft-degraded tag so the LLM surfaces it in the Data gaps section.
47
+ const candidateTickers = extractTickersFromQuery(args.query);
48
+ const finnhubTickers = config.finnhubApiKey ? candidateTickers : [];
49
+ const includeFinnhub = finnhubTickers.length > 0 && Boolean(config.finnhubApiKey);
50
+ const finnhubSoftDegraded =
51
+ candidateTickers.length > 0 && !hasCredential("finnhub");
52
+
53
+ // Finnhub fetch (built separately to avoid mixing promise types in allSettled)
54
+ const finnhubFetch: Promise<import("../../providers/finnhub.js").FinnhubArticle[]> = includeFinnhub
55
+ ? (async () => {
56
+ const { from, to } = finnhubDateRange("day");
57
+ const arrays = await Promise.all(
58
+ finnhubTickers.map((sym) => getCompanyNews(sym, from, to, config.finnhubApiKey!)),
59
+ );
60
+ return arrays.flat();
61
+ })()
62
+ : Promise.resolve([]);
63
+
64
+ // Fetch all sources in parallel
65
+ const [twitterResult, redditResults, webResult, finnhubResult] = await Promise.allSettled([
66
+ // Twitter
67
+ wrapProvider("twitter", () => getTwitterSentiment(args.query, 50, hours)),
68
+ // Reddit — cross-subreddit
69
+ fetchRedditCrossSubreddit(args.query, config.sentiment?.defaultSubreddits ?? ["wallstreetbets", "stocks", "investing", "options"]),
70
+ // Web
71
+ searchWeb(args.query, { freshness: "day", limit: 10, category: "news" }),
72
+ // Finnhub — only when includeFinnhub; otherwise resolves to []
73
+ finnhubFetch,
74
+ ]);
75
+
76
+ // Process Twitter
77
+ if (twitterResult.status === "fulfilled" && twitterResult.value.status === "ok") {
78
+ const records = twitterAdapter.mapToRecords(twitterResult.value.data, args.query);
79
+ allRecords.push(...records);
80
+ } else {
81
+ const reason = twitterResult.status === "rejected"
82
+ ? twitterResult.reason?.message ?? "unknown error"
83
+ : (twitterResult.value as any).reason ?? "unavailable";
84
+ warnings.push(`Twitter: ${reason}`);
85
+ }
86
+
87
+ // Process Reddit
88
+ if (redditResults.status === "fulfilled") {
89
+ const { records: redditRecords, warnings: redditWarnings } = redditResults.value;
90
+ allRecords.push(...redditRecords);
91
+ warnings.push(...redditWarnings);
92
+ } else {
93
+ warnings.push(`Reddit: ${redditResults.reason?.message ?? "unknown error"}`);
94
+ }
95
+
96
+ // Process Web
97
+ if (webResult.status === "fulfilled" && webResult.value.status === "ok") {
98
+ const records = webAdapter.mapToRecords(webResult.value.data, args.query);
99
+ allRecords.push(...records);
100
+ } else {
101
+ const reason = webResult.status === "rejected"
102
+ ? webResult.reason?.message ?? "unknown error"
103
+ : (webResult.value as any).reason ?? "unavailable";
104
+ warnings.push(`Web: ${reason}`);
105
+ }
106
+
107
+ // Process Finnhub (only when included — otherwise resolves to empty array anyway)
108
+ if (includeFinnhub) {
109
+ if (finnhubResult.status === "fulfilled") {
110
+ const articles = finnhubResult.value;
111
+ if (articles.length > 0) {
112
+ const records = finnhubAdapter.mapToRecords(articles, args.query);
113
+ allRecords.push(...records);
114
+ }
115
+ } else {
116
+ warnings.push(`Finnhub: ${finnhubResult.reason?.message ?? "unknown error"}`);
117
+ }
118
+ }
119
+
120
+ const softDegradedPrefix = finnhubSoftDegraded
121
+ ? `${buildSoftDegradedTag({
122
+ provider: "finnhub",
123
+ fallback: "other-sentiment-sources",
124
+ remediation: "run /connect news to enable Finnhub company news",
125
+ })}\n\n`
126
+ : "";
127
+
128
+ if (allRecords.length === 0) {
129
+ return {
130
+ content: [
131
+ {
132
+ type: "text",
133
+ text: `${softDegradedPrefix}⚠ Sentiment summary unavailable for "${args.query}" — no sources returned data.\n${warnings.join("\n")}`,
134
+ },
135
+ ],
136
+ details: null as any,
137
+ };
138
+ }
139
+
140
+ // Score and index through pipeline
141
+ const pipeline = getSentimentPipeline();
142
+ const result = await pipeline.processRecords(allRecords, args.query);
143
+
144
+ // Group by source (exclude comments from per-source averages)
145
+ const bySource: Record<string, { total: number; count: number }> = {};
146
+ for (const rec of result.fresh) {
147
+ if (rec.metadata.isComment) continue;
148
+ if (!bySource[rec.source]) bySource[rec.source] = { total: 0, count: 0 };
149
+ bySource[rec.source].total += rec.sentiment.score;
150
+ bySource[rec.source].count++;
151
+ }
152
+
153
+ const lines: string[] = [];
154
+ lines.push(`**Sentiment summary for "${args.query}"** (last ${hours}h):`);
155
+ lines.push("");
156
+ lines.push("| Source | Score | Count | Signal |");
157
+ lines.push("|--------|-------|-------|--------|");
158
+
159
+ let totalScore = 0;
160
+ let totalCount = 0;
161
+ for (const [source, stats] of Object.entries(bySource)) {
162
+ const avg = stats.count > 0 ? stats.total / stats.count : 0;
163
+ const label = sentimentLabel(avg);
164
+ const sourceName = source === "web" ? "Web/News" : source.charAt(0).toUpperCase() + source.slice(1);
165
+ lines.push(`| ${sourceName} | ${avg >= 0 ? "+" : ""}${avg.toFixed(2)} | ${stats.count} | ${label} |`);
166
+ totalScore += stats.total;
167
+ totalCount += stats.count;
168
+ }
169
+
170
+ const aggregate = totalCount > 0 ? totalScore / totalCount : 0;
171
+ lines.push("");
172
+ lines.push(`**Aggregate:** ${aggregate >= 0 ? "+" : ""}${aggregate.toFixed(2)} (${sentimentLabel(aggregate)})`);
173
+
174
+ // Divergence
175
+ if (result.divergence && result.divergence.detected) {
176
+ lines.push("");
177
+ lines.push(result.divergence.message);
178
+ } else if (result.divergence && !result.divergence.detected) {
179
+ lines.push("");
180
+ lines.push(result.divergence.message);
181
+ }
182
+
183
+ // Trend
184
+ if (result.trend && result.trend.length > 0) {
185
+ const t = result.trend[0];
186
+ lines.push("");
187
+ lines.push(`Trend: ${t.sparkline} ${t.direction} (${t.count} records)`);
188
+ }
189
+
190
+ if (warnings.length > 0) {
191
+ lines.push("");
192
+ lines.push(warnings.map((w) => `⚠ ${w}`).join("\n"));
193
+ }
194
+
195
+ const output = softDegradedPrefix + lines.join("\n");
196
+ return { content: [{ type: "text", text: output }], details: result };
197
+ },
198
+ };
199
+
200
+ async function fetchRedditCrossSubreddit(
201
+ query: string,
202
+ subreddits: string[],
203
+ ): Promise<{ records: SentinelRecord[]; warnings: string[] }> {
204
+ const adapter = new RedditAdapter();
205
+ const records: SentinelRecord[] = [];
206
+ const warnings: string[] = [];
207
+ const config = getConfig();
208
+ const commentsPerPost = config.sentiment?.commentsPerPost ?? 5;
209
+
210
+ for (const sub of subreddits) {
211
+ const result = await wrapProvider("reddit", () => getSubredditPosts(sub, 25));
212
+ if (result.status === "unavailable") {
213
+ warnings.push(`Reddit r/${sub}: ${result.reason}`);
214
+ continue;
215
+ }
216
+ const postRecords = adapter.mapPostsToRecords(result.data, query);
217
+
218
+ // Topic filter
219
+ const queryLower = query.toLowerCase();
220
+ const filtered = postRecords.filter((r) =>
221
+ r.text.toLowerCase().includes(queryLower) ||
222
+ (r.title?.toLowerCase().includes(queryLower) ?? false),
223
+ );
224
+ records.push(...filtered);
225
+
226
+ // Fetch comments for top posts
227
+ const topPosts = [...filtered]
228
+ .sort((a, b) => b.engagement.score - a.engagement.score)
229
+ .slice(0, 3); // fewer per sub since we're searching multiple
230
+ for (const post of topPosts) {
231
+ if ((post.engagement.replies ?? 0) === 0) continue;
232
+ try {
233
+ const comments = await getPostComments(sub, post.sourceId, commentsPerPost);
234
+ records.push(...adapter.mapCommentsToRecords(comments, post.sourceId, sub, query));
235
+ } catch { /* non-fatal */ }
236
+ }
237
+ }
238
+
239
+ // Deduplicate
240
+ const seen = new Set<string>();
241
+ const deduped = records.filter((r) => {
242
+ if (seen.has(r.sourceId)) return false;
243
+ seen.add(r.sourceId);
244
+ return true;
245
+ });
246
+
247
+ return { records: deduped, warnings };
248
+ }
249
+
250
+ function sentimentLabel(score: number): string {
251
+ if (score > 0.3) return "Bullish";
252
+ if (score < -0.3) return "Bearish";
253
+ if (score > 0) return "Leaning Bullish";
254
+ if (score < 0) return "Leaning Bearish";
255
+ return "Neutral";
256
+ }
@@ -0,0 +1,58 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
3
+ import { SentimentStore } from "../../sentiment/store.js";
4
+ import { getSentimentStore } from "../../sentiment/index.js";
5
+ import { computeTrend } from "../../sentiment/trends.js";
6
+
7
+ const params = Type.Object({
8
+ query: Type.String({ description: "Ticker or topic to look up sentiment history" }),
9
+ days: Type.Optional(
10
+ Type.Number({ description: "Number of days of history. Default: 7, max: 30" }),
11
+ ),
12
+ source: Type.Optional(
13
+ Type.Union([Type.Literal("twitter"), Type.Literal("reddit"), Type.Literal("web"), Type.Literal("finnhub")], {
14
+ description: "Filter to a single source. Default: all sources.",
15
+ }),
16
+ ),
17
+ });
18
+
19
+ interface TrendToolResult {
20
+ content: Array<{ type: "text"; text: string }>;
21
+ details: any;
22
+ }
23
+
24
+ export const sentimentTrendTool: AgentTool<typeof params> & {
25
+ executeWithStore: (toolCallId: string, args: { query: string; days?: number; source?: string }, store: SentimentStore) => Promise<TrendToolResult>;
26
+ } = {
27
+ name: "get_sentiment_trend",
28
+ label: "Sentiment Trend",
29
+ description:
30
+ "Query historical sentiment data from the local store. No live API calls — returns trends from previously fetched data. Run a sentiment query first to populate the store.",
31
+ parameters: params,
32
+ async execute(toolCallId, args) {
33
+ const store = getSentimentStore();
34
+ return sentimentTrendTool.executeWithStore(toolCallId, args, store);
35
+ },
36
+ async executeWithStore(_toolCallId, args, store) {
37
+ const days = Math.min(args.days ?? 7, 30);
38
+ const series = store.getTimeSeries(args.query, { days, bucketHours: 24 });
39
+
40
+ if (series.length === 0) {
41
+ return {
42
+ content: [{ type: "text", text: `No historical sentiment data for "${args.query}". Run a sentiment query first to populate the store.` }],
43
+ details: null,
44
+ };
45
+ }
46
+
47
+ const trend = computeTrend(series, (args.source as any) ?? "aggregate");
48
+
49
+ const lines = [
50
+ `**Sentiment trend for "${args.query}"** (${days}d):`,
51
+ "",
52
+ `${trend.sparkline} ${trend.direction} (${trend.delta >= 0 ? "+" : ""}${trend.delta.toFixed(2)})`,
53
+ `Avg: ${trend.avgScore.toFixed(2)} | Records: ${trend.count}`,
54
+ ];
55
+
56
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { trend, series } };
57
+ },
58
+ };
@@ -0,0 +1,96 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
3
+ import { getTwitterSentiment } from "../../providers/twitter.js";
4
+ import { wrapProvider } from "../../providers/wrap-provider.js";
5
+ import type { TwitterSentimentResult } from "../../types/sentiment.js";
6
+ import { TwitterAdapter } from "../../sentiment/adapters/twitter.js";
7
+ import { getSentimentPipeline } from "../../sentiment/index.js";
8
+
9
+ const params = Type.Object({
10
+ query: Type.String({
11
+ description: "Stock ticker (e.g. AAPL) or search term (e.g. 'AAPL earnings call')",
12
+ }),
13
+ limit: Type.Optional(
14
+ Type.Number({ description: "Max tweets to fetch. Default: 50, max: 200" }),
15
+ ),
16
+ hours: Type.Optional(
17
+ Type.Number({ description: "Lookback window in hours. Default: 24" }),
18
+ ),
19
+ });
20
+
21
+ export const twitterSentimentTool: AgentTool<typeof params, TwitterSentimentResult> = {
22
+ name: "get_twitter_sentiment",
23
+ label: "Twitter Sentiment",
24
+ description:
25
+ "Fetch recent tweets for a stock ticker or search query and compute engagement-weighted sentiment. Returns tweet data, sentiment score, and co-mentioned tickers. Requires a Twitter session via trigger_twitter_login.",
26
+ parameters: params,
27
+ async execute(toolCallId, args) {
28
+ const limit = Math.min(args.limit ?? 50, 200);
29
+ const hours = args.hours ?? 24;
30
+
31
+ const providerResult = await wrapProvider("twitter", () =>
32
+ getTwitterSentiment(args.query, limit, hours),
33
+ );
34
+
35
+ if (providerResult.status === "unavailable") {
36
+ const isLoginIssue =
37
+ providerResult.reason.includes("No Twitter session") ||
38
+ providerResult.reason.includes("session expired");
39
+ const text = isLoginIssue
40
+ ? `⚠ Twitter sentiment unavailable: ${providerResult.reason}\n[LOGIN_NEEDED] Use ask_user to confirm, then call trigger_twitter_login. After success, retry this tool.`
41
+ : `⚠ Twitter sentiment unavailable (${providerResult.reason}).`;
42
+ return {
43
+ content: [{ type: "text", text }],
44
+ details: null as any,
45
+ };
46
+ }
47
+
48
+ const result = providerResult.data;
49
+
50
+ const sentimentLabel =
51
+ result.sentimentScore > 0.3 ? "Bullish" :
52
+ result.sentimentScore < -0.3 ? "Bearish" :
53
+ result.sentimentScore > 0 ? "Leaning Bullish" :
54
+ result.sentimentScore < 0 ? "Leaning Bearish" : "Neutral";
55
+
56
+ const lines = [
57
+ `**Twitter: ${result.query}** — ${result.tweetCount} tweets (last ${hours}h, ${result.fetchedAt})`,
58
+ `Sentiment: ${result.sentimentScore.toFixed(2)} (${sentimentLabel}) | Bullish: ${result.bullishCount} | Bearish: ${result.bearishCount}`,
59
+ ];
60
+
61
+ if (result.topMentions.length > 0) {
62
+ lines.push(`Co-mentions: ${result.topMentions.map((t) => `$${t}`).join(", ")}`);
63
+ }
64
+
65
+ lines.push("");
66
+ lines.push("| Author | Tweet | ❤️ | 🔁 | 💬 |");
67
+ lines.push("|--------|-------|----|----|----|");
68
+ const top = result.tweets.slice(0, 15);
69
+ for (const tweet of top) {
70
+ const text = tweet.text.replace(/\|/g, "\\|").replace(/\n/g, " ").slice(0, 100);
71
+ lines.push(`| @${tweet.author} | ${text} | ${tweet.likes} | ${tweet.retweets} | ${tweet.replies} |`);
72
+ }
73
+
74
+ if (providerResult.stale) {
75
+ lines.push("");
76
+ lines.push(`⚠ Stale data (cached at ${providerResult.timestamp})`);
77
+ }
78
+
79
+ // Index in sentiment store and append trend context
80
+ try {
81
+ const adapter = new TwitterAdapter();
82
+ const records = adapter.mapToRecords(result, args.query);
83
+ const pipeline = getSentimentPipeline();
84
+ const pipelineResult = await pipeline.processRecords(records, args.query);
85
+ if (pipelineResult.trend && pipelineResult.trend.length > 0) {
86
+ const t = pipelineResult.trend[0];
87
+ lines.push("");
88
+ lines.push(`Trend: ${t.sparkline} ${t.direction} (${t.delta >= 0 ? "+" : ""}${t.delta.toFixed(2)}, ${t.count} records)`);
89
+ }
90
+ } catch {
91
+ // Sentiment indexing is best-effort — don't fail the tool
92
+ }
93
+
94
+ return { content: [{ type: "text", text: lines.join("\n") }], details: result };
95
+ },
96
+ };