opencandle 0.6.0 → 0.7.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 (154) hide show
  1. package/README.md +10 -3
  2. package/dist/cli.js +36 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.js +13 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/infra/index.d.ts +0 -1
  8. package/dist/infra/index.js +0 -1
  9. package/dist/infra/index.js.map +1 -1
  10. package/dist/onboarding/connect.d.ts +2 -2
  11. package/dist/onboarding/connect.js +10 -3
  12. package/dist/onboarding/connect.js.map +1 -1
  13. package/dist/onboarding/provider-status.d.ts +48 -0
  14. package/dist/onboarding/provider-status.js +285 -0
  15. package/dist/onboarding/provider-status.js.map +1 -0
  16. package/dist/onboarding/providers.d.ts +85 -8
  17. package/dist/onboarding/providers.js +87 -9
  18. package/dist/onboarding/providers.js.map +1 -1
  19. package/dist/onboarding/state.d.ts +1 -0
  20. package/dist/onboarding/state.js +5 -0
  21. package/dist/onboarding/state.js.map +1 -1
  22. package/dist/onboarding/tool-tags.d.ts +12 -1
  23. package/dist/onboarding/tool-tags.js +31 -1
  24. package/dist/onboarding/tool-tags.js.map +1 -1
  25. package/dist/onboarding/validation.d.ts +2 -2
  26. package/dist/onboarding/validation.js.map +1 -1
  27. package/dist/pi/opencandle-extension.js +91 -15
  28. package/dist/pi/opencandle-extension.js.map +1 -1
  29. package/dist/pi/tool-adapter.d.ts +4 -1
  30. package/dist/pi/tool-adapter.js +5 -4
  31. package/dist/pi/tool-adapter.js.map +1 -1
  32. package/dist/prompts/context-builder.js +1 -1
  33. package/dist/prompts/policy-cards.js +1 -1
  34. package/dist/prompts/policy-cards.js.map +1 -1
  35. package/dist/providers/external-tool-error.d.ts +10 -0
  36. package/dist/providers/external-tool-error.js +21 -0
  37. package/dist/providers/external-tool-error.js.map +1 -0
  38. package/dist/providers/reddit-cli.d.ts +36 -0
  39. package/dist/providers/reddit-cli.js +201 -0
  40. package/dist/providers/reddit-cli.js.map +1 -0
  41. package/dist/providers/reddit.d.ts +1 -1
  42. package/dist/providers/reddit.js +7 -35
  43. package/dist/providers/reddit.js.map +1 -1
  44. package/dist/providers/twitter-cli.d.ts +40 -0
  45. package/dist/providers/twitter-cli.js +153 -0
  46. package/dist/providers/twitter-cli.js.map +1 -0
  47. package/dist/providers/twitter.d.ts +0 -8
  48. package/dist/providers/twitter.js +4 -54
  49. package/dist/providers/twitter.js.map +1 -1
  50. package/dist/providers/wrap-provider.js +30 -0
  51. package/dist/providers/wrap-provider.js.map +1 -1
  52. package/dist/providers/yahoo-finance.js +53 -32
  53. package/dist/providers/yahoo-finance.js.map +1 -1
  54. package/dist/routing/planning.d.ts +1 -1
  55. package/dist/routing/planning.js.map +1 -1
  56. package/dist/runtime/answer-contracts.d.ts +1 -1
  57. package/dist/runtime/answer-contracts.js +12 -1
  58. package/dist/runtime/answer-contracts.js.map +1 -1
  59. package/dist/runtime/tool-defaults-wrapper.js +6 -2
  60. package/dist/runtime/tool-defaults-wrapper.js.map +1 -1
  61. package/dist/sentiment/index.d.ts +1 -0
  62. package/dist/sentiment/index.js +1 -0
  63. package/dist/sentiment/index.js.map +1 -1
  64. package/dist/sentiment/insights.d.ts +17 -0
  65. package/dist/sentiment/insights.js +206 -0
  66. package/dist/sentiment/insights.js.map +1 -0
  67. package/dist/sentiment/pipeline.js +13 -1
  68. package/dist/sentiment/pipeline.js.map +1 -1
  69. package/dist/sentiment/scorer.d.ts +2 -0
  70. package/dist/sentiment/scorer.js +10 -1
  71. package/dist/sentiment/scorer.js.map +1 -1
  72. package/dist/sentiment/types.d.ts +2 -0
  73. package/dist/sentiment/types.js.map +1 -1
  74. package/dist/system-prompt.js +3 -7
  75. package/dist/system-prompt.js.map +1 -1
  76. package/dist/tools/index.d.ts +5 -2
  77. package/dist/tools/index.js +8 -8
  78. package/dist/tools/index.js.map +1 -1
  79. package/dist/tools/sentiment/insight-format.d.ts +2 -0
  80. package/dist/tools/sentiment/insight-format.js +36 -0
  81. package/dist/tools/sentiment/insight-format.js.map +1 -0
  82. package/dist/tools/sentiment/query-match.d.ts +3 -0
  83. package/dist/tools/sentiment/query-match.js +113 -0
  84. package/dist/tools/sentiment/query-match.js.map +1 -0
  85. package/dist/tools/sentiment/reddit-sentiment.d.ts +12 -1
  86. package/dist/tools/sentiment/reddit-sentiment.js +263 -117
  87. package/dist/tools/sentiment/reddit-sentiment.js.map +1 -1
  88. package/dist/tools/sentiment/sentiment-summary.d.ts +9 -1
  89. package/dist/tools/sentiment/sentiment-summary.js +217 -201
  90. package/dist/tools/sentiment/sentiment-summary.js.map +1 -1
  91. package/dist/tools/sentiment/twitter-sentiment.d.ts +11 -1
  92. package/dist/tools/sentiment/twitter-sentiment.js +187 -64
  93. package/dist/tools/sentiment/twitter-sentiment.js.map +1 -1
  94. package/dist/tools/sentiment/web-sentiment.js +4 -0
  95. package/dist/tools/sentiment/web-sentiment.js.map +1 -1
  96. package/dist/types/sentiment.d.ts +52 -0
  97. package/gui/server/invoke-tool.ts +17 -3
  98. package/gui/server/model-setup.ts +10 -3
  99. package/gui/server/projector.ts +6 -2
  100. package/gui/server/server.ts +18 -0
  101. package/gui/server/tool-metadata.ts +80 -16
  102. package/gui/server/ws-hub.ts +19 -0
  103. package/gui/web/dist/assets/CatalogOverlay-CgeY5Pkp.js +1 -0
  104. package/gui/web/dist/assets/index-C6W_2eAn.js +69 -0
  105. package/gui/web/dist/assets/{index-2KZtKBmu.css → index-hwbx24a5.css} +1 -1
  106. package/gui/web/dist/index.html +2 -2
  107. package/package.json +5 -6
  108. package/src/cli.ts +41 -0
  109. package/src/config.ts +27 -0
  110. package/src/infra/index.ts +0 -1
  111. package/src/onboarding/connect.ts +20 -4
  112. package/src/onboarding/provider-status.ts +410 -0
  113. package/src/onboarding/providers.ts +148 -18
  114. package/src/onboarding/state.ts +9 -0
  115. package/src/onboarding/tool-tags.ts +45 -2
  116. package/src/onboarding/validation.ts +2 -2
  117. package/src/pi/opencandle-extension.ts +115 -17
  118. package/src/pi/tool-adapter.ts +14 -4
  119. package/src/prompts/context-builder.ts +1 -1
  120. package/src/prompts/policy-cards.ts +1 -1
  121. package/src/providers/external-tool-error.ts +20 -0
  122. package/src/providers/reddit-cli.ts +317 -0
  123. package/src/providers/reddit.ts +7 -63
  124. package/src/providers/twitter-cli.ts +233 -0
  125. package/src/providers/twitter.ts +4 -73
  126. package/src/providers/wrap-provider.ts +34 -0
  127. package/src/providers/yahoo-finance.ts +65 -32
  128. package/src/routing/planning.ts +1 -0
  129. package/src/runtime/answer-contracts.ts +23 -2
  130. package/src/runtime/tool-defaults-wrapper.ts +12 -2
  131. package/src/sentiment/index.ts +1 -0
  132. package/src/sentiment/insights.ts +269 -0
  133. package/src/sentiment/pipeline.ts +13 -1
  134. package/src/sentiment/scorer.ts +12 -1
  135. package/src/sentiment/types.ts +3 -0
  136. package/src/system-prompt.ts +3 -7
  137. package/src/tools/index.ts +9 -8
  138. package/src/tools/sentiment/insight-format.ts +50 -0
  139. package/src/tools/sentiment/query-match.ts +117 -0
  140. package/src/tools/sentiment/reddit-sentiment.ts +354 -141
  141. package/src/tools/sentiment/sentiment-summary.ts +283 -237
  142. package/src/tools/sentiment/twitter-sentiment.ts +262 -78
  143. package/src/tools/sentiment/web-sentiment.ts +4 -0
  144. package/src/types/sentiment.ts +59 -0
  145. package/dist/infra/browser.d.ts +0 -35
  146. package/dist/infra/browser.js +0 -105
  147. package/dist/infra/browser.js.map +0 -1
  148. package/dist/tools/interaction/twitter-login.d.ts +0 -8
  149. package/dist/tools/interaction/twitter-login.js +0 -87
  150. package/dist/tools/interaction/twitter-login.js.map +0 -1
  151. package/gui/web/dist/assets/CatalogOverlay-eJ2cBk33.js +0 -1
  152. package/gui/web/dist/assets/index-CveNgtDg.js +0 -69
  153. package/src/infra/browser.ts +0 -113
  154. package/src/tools/interaction/twitter-login.ts +0 -105
@@ -1,11 +1,23 @@
1
1
  import type { AgentTool } from "@earendil-works/pi-agent-core";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import { Type } from "@sinclair/typebox";
3
4
  import { getConfig } from "../../config.js";
5
+ import { promptUser } from "../../onboarding/prompt-user.js";
6
+ import { getProvider } from "../../onboarding/providers.js";
7
+ import {
8
+ loadOnboardingState,
9
+ markProviderNeverAsk,
10
+ saveOnboardingState,
11
+ } from "../../onboarding/state.js";
12
+ import { buildExternalToolRequiredTag, buildSkippedTag } from "../../onboarding/tool-tags.js";
4
13
  import { getPostComments, getSubredditPosts } from "../../providers/reddit.js";
5
14
  import { wrapProvider } from "../../providers/wrap-provider.js";
6
15
  import { RedditAdapter } from "../../sentiment/adapters/reddit.js";
7
16
  import { getSentimentPipeline } from "../../sentiment/index.js";
17
+ import type { AskUserHandler } from "../../types/index.js";
8
18
  import type { RedditSentimentResult } from "../../types/sentiment.js";
19
+ import { formatInsightSection } from "./insight-format.js";
20
+ import { recordMatchesSentimentQuery, sentimentQueryTerms } from "./query-match.js";
9
21
  import { renderUntrustedText, untrustedContentHeader } from "./untrusted-text.js";
10
22
 
11
23
  const params = Type.Object({
@@ -31,160 +43,361 @@ const params = Type.Object({
31
43
  ),
32
44
  });
33
45
 
34
- export const redditSentimentTool: AgentTool<typeof params, RedditSentimentResult> = {
35
- name: "get_reddit_sentiment",
36
- label: "Reddit Sentiment",
37
- description:
38
- "Analyze sentiment from financial Reddit communities. Supports single subreddit, multi-subreddit, and topic filtering. Returns scored posts with comment analysis and trend context.",
39
- parameters: params,
40
- async execute(_toolCallId, args) {
41
- const limit = Math.min(args.limit ?? 25, 100);
42
- const config = getConfig();
43
-
44
- // Determine subreddits to search
45
- let subreddits: string[];
46
- if (args.subreddits && args.subreddits.length > 0) {
47
- subreddits = args.subreddits;
48
- } else if (args.subreddit) {
49
- subreddits = [args.subreddit];
50
- } else {
51
- subreddits = config.sentiment?.defaultSubreddits ?? [
52
- "wallstreetbets",
53
- "stocks",
54
- "investing",
55
- "options",
56
- ];
57
- }
46
+ const INSTALL_CONTINUE_LABEL = "Continue after installing Reddit";
47
+ const SESSION_CONTINUE_LABEL = "Continue after logging in to Reddit";
48
+ const SKIP_ONCE_LABEL = "Skip Reddit once";
49
+ const ALWAYS_SKIP_LABEL = "Always skip Reddit";
50
+
51
+ interface ExtensionContextWithAskUserHandler extends Partial<ExtensionContext> {
52
+ askUserHandler?: AskUserHandler;
53
+ }
54
+
55
+ interface RedditSentimentToolOptions {
56
+ askUserHandler?: AskUserHandler;
57
+ }
58
+
59
+ type RedditToolDetails = RedditSentimentResult | null;
60
+
61
+ export function createRedditSentimentTool(
62
+ options: RedditSentimentToolOptions = {},
63
+ ): AgentTool<typeof params, RedditToolDetails> {
64
+ return {
65
+ name: "get_reddit_sentiment",
66
+ label: "Reddit Sentiment",
67
+ description:
68
+ "Analyze sentiment from financial Reddit communities. Supports single subreddit, multi-subreddit, and topic filtering. Returns scored posts with comment analysis and trend context.",
69
+ parameters: params,
70
+ async execute(_toolCallId, args, _signal, _onUpdate, ctx?: ExtensionContext) {
71
+ const limit = Math.min(args.limit ?? 25, 100);
72
+ const config = getConfig();
73
+ const state = loadOnboardingState();
74
+ const descriptor = getProvider("reddit");
75
+ if (state.providers.reddit?.status === "never_ask") {
76
+ return skippedRedditResult(
77
+ "You previously asked not to be reminded about Reddit.",
78
+ "run opencandle doctor --enable reddit to re-enable Reddit sentiment (silenced)",
79
+ true,
80
+ );
81
+ }
58
82
 
59
- // Fetch from all subreddits
60
- const allResults: RedditSentimentResult[] = [];
61
- const warnings: string[] = [];
62
- for (const sub of subreddits) {
63
- const providerResult = await wrapProvider("reddit", () => getSubredditPosts(sub, limit));
64
- if (providerResult.status === "unavailable") {
65
- warnings.push(`r/${sub}: ${providerResult.reason}`);
66
- continue;
83
+ // Determine subreddits to search
84
+ let subreddits: string[];
85
+ if (args.subreddits && args.subreddits.length > 0) {
86
+ subreddits = args.subreddits;
87
+ } else if (args.subreddit) {
88
+ subreddits = [args.subreddit];
89
+ } else {
90
+ subreddits = config.sentiment?.defaultSubreddits ?? [
91
+ "wallstreetbets",
92
+ "stocks",
93
+ "investing",
94
+ "options",
95
+ ];
67
96
  }
68
- allResults.push(providerResult.data);
69
- }
70
97
 
71
- if (allResults.length === 0) {
72
- return {
73
- content: [
74
- { type: "text", text: `⚠ Reddit sentiment unavailable (${warnings.join("; ")}).` },
75
- ],
76
- details: null as any,
77
- };
78
- }
98
+ let { allResults, warnings } = await fetchRedditSubreddits(subreddits, limit, args.query);
99
+ let retriedAfterContinue = false;
100
+ let promptedSetupIssue: ReturnType<typeof classifyRedditSetupIssue> = null;
101
+
102
+ if (allResults.length === 0) {
103
+ const setupIssue = classifyRedditSetupIssue(warnings);
104
+ if (setupIssue) {
105
+ promptedSetupIssue = setupIssue;
106
+ const askUserHandler =
107
+ options.askUserHandler ??
108
+ (ctx as ExtensionContextWithAskUserHandler | undefined)?.askUserHandler;
109
+ const canAsk = askUserHandler != null || ctx?.hasUI === true;
110
+ if (canAsk) {
111
+ const promptResult = await promptUser(
112
+ (ctx ?? { hasUI: false }) as ExtensionContext,
113
+ {
114
+ question:
115
+ setupIssue === "not_installed"
116
+ ? "Reddit sentiment needs the local rdt-cli command before it can fetch discussions. Install command: uv tool install rdt-cli. How would you like to proceed?"
117
+ : "Reddit sentiment needs an active Reddit browser session for rdt-cli. Login command: rdt login. How would you like to proceed?",
118
+ questionType: "select",
119
+ options: [
120
+ setupIssue === "not_installed" ? INSTALL_CONTINUE_LABEL : SESSION_CONTINUE_LABEL,
121
+ SKIP_ONCE_LABEL,
122
+ ALWAYS_SKIP_LABEL,
123
+ ],
124
+ reason: "Reddit sentiment needs rdt-cli and a Reddit browser session.",
125
+ },
126
+ askUserHandler,
127
+ );
79
128
 
80
- // Merge and filter by query if provided
81
- const adapter = new RedditAdapter();
82
- let allRecords = allResults.flatMap((r) =>
83
- adapter.mapPostsToRecords(r, args.query ?? subreddits.join("+")),
84
- );
85
-
86
- // Topic filtering
87
- if (args.query) {
88
- const queryLower = args.query.toLowerCase();
89
- allRecords = allRecords.filter(
90
- (r) =>
91
- r.text.toLowerCase().includes(queryLower) ||
92
- (r.title?.toLowerCase().includes(queryLower) ?? false),
129
+ if (!promptResult.cancelled && promptResult.answer?.startsWith("Continue")) {
130
+ retriedAfterContinue = true;
131
+ ({ allResults, warnings } = await fetchRedditSubreddits(
132
+ subreddits,
133
+ limit,
134
+ args.query,
135
+ ));
136
+ } else if (!promptResult.cancelled && promptResult.answer === ALWAYS_SKIP_LABEL) {
137
+ saveOnboardingState(markProviderNeverAsk(state, "reddit"));
138
+ return skippedRedditResult(
139
+ `User chose to always skip ${descriptor.displayName} data. Do not ask about Reddit setup again unless the user explicitly re-enables it.`,
140
+ "run opencandle doctor --enable reddit to re-enable Reddit sentiment (silenced)",
141
+ true,
142
+ );
143
+ } else {
144
+ return skippedRedditResult(
145
+ `User chose to skip ${descriptor.displayName} data for this request. Do not ask about Reddit setup again in this turn.`,
146
+ "user chose to skip Reddit for this request",
147
+ false,
148
+ );
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ if (allResults.length === 0) {
155
+ const setupIssue = classifyRedditSetupIssue(warnings);
156
+ if (retriedAfterContinue && (!setupIssue || setupIssue === promptedSetupIssue)) {
157
+ return unavailableRedditResult(warnings);
158
+ }
159
+ if (setupIssue) {
160
+ const tag = buildExternalToolRequiredTag({
161
+ provider: "reddit",
162
+ reason: setupIssue,
163
+ installCmd: "uv tool install rdt-cli",
164
+ loginCmd: "rdt login",
165
+ fallback: "twitter-web-news",
166
+ });
167
+ const guidance =
168
+ setupIssue === "not_installed"
169
+ ? "Reddit sentiment requires the local rdt-cli tool. Install it with `uv tool install rdt-cli`, then run `rdt login` if needed."
170
+ : "Reddit sentiment requires an active Reddit browser session for rdt-cli. Run `rdt login` or refresh your Reddit browser login.";
171
+ return {
172
+ content: [{ type: "text", text: `${tag}\n\n${guidance}` }],
173
+ details: null,
174
+ };
175
+ }
176
+ return unavailableRedditResult(warnings);
177
+ }
178
+
179
+ // Merge records returned by the provider. rdt-cli handles query-bearing
180
+ // requests server-side; the local filter remains a defensive post-filter.
181
+ const adapter = new RedditAdapter();
182
+ let allRecords = allResults.flatMap((r) =>
183
+ adapter.mapPostsToRecords(r, args.query ?? subreddits.join("+")),
93
184
  );
94
- }
185
+ const queryTerms = args.query ? sentimentQueryTerms(args.query) : null;
95
186
 
96
- // Deduplicate by sourceId (crossposts)
97
- const seen = new Set<string>();
98
- allRecords = allRecords.filter((r) => {
99
- if (seen.has(r.sourceId)) return false;
100
- seen.add(r.sourceId);
101
- return true;
102
- });
103
-
104
- // Fetch comments for top 10 posts by engagement
105
- const commentsPerPost = config.sentiment?.commentsPerPost ?? 5;
106
- const topPosts = [...allRecords]
107
- .sort((a, b) => b.engagement.score - a.engagement.score)
108
- .slice(0, 10);
109
-
110
- for (const post of topPosts) {
111
- const sub = (post.metadata.subreddit as string) ?? subreddits[0];
112
- if ((post.engagement.replies ?? 0) === 0) continue;
113
- try {
114
- const comments = await getPostComments(sub, post.sourceId, commentsPerPost);
115
- const commentRecords = adapter.mapCommentsToRecords(
116
- comments,
117
- post.sourceId,
118
- sub,
119
- args.query ?? subreddits.join("+"),
120
- );
121
- allRecords.push(...commentRecords);
122
- } catch {
123
- // Comment fetch failures are non-fatal
187
+ // Topic filtering
188
+ if (queryTerms) {
189
+ allRecords = allRecords.filter((record) => recordMatchesSentimentQuery(record, queryTerms));
124
190
  }
125
- }
126
191
 
127
- // Process through pipeline
128
- const pipeline = getSentimentPipeline();
129
- const pipelineResult = await pipeline.processRecords(
130
- allRecords,
131
- args.query ?? subreddits.join("+"),
132
- );
133
-
134
- // Build output using first result as base for backward compatibility
135
- const firstResult = allResults[0];
136
- const postRecords = pipelineResult.fresh.filter((r) => !r.metadata.isComment);
137
- const commentRecords = pipelineResult.fresh.filter((r) => r.metadata.isComment);
138
- const avgScore =
139
- postRecords.length > 0
140
- ? postRecords.reduce((s, r) => s + r.sentiment.score, 0) / postRecords.length
141
- : 0;
142
-
143
- const sentimentLabel =
144
- avgScore > 0.3
145
- ? "Bullish"
146
- : avgScore < -0.3
147
- ? "Bearish"
148
- : avgScore > 0
149
- ? "Leaning Bullish"
150
- : avgScore < 0
151
- ? "Leaning Bearish"
152
- : "Neutral";
153
-
154
- const subLabel =
155
- subreddits.length === 1 ? `r/${subreddits[0]}` : `${subreddits.length} subreddits`;
156
- const lines = [
157
- `**Reddit: ${args.query ?? subLabel}** — ${postRecords.length} posts, ${commentRecords.length} comments`,
158
- `Sentiment: ${avgScore.toFixed(2)} (${sentimentLabel})`,
159
- ];
160
-
161
- if (firstResult.topMentions.length > 0) {
162
- lines.push(`Tickers: ${firstResult.topMentions.map((t) => `$${t}`).join(", ")}`);
163
- }
192
+ // Deduplicate by sourceId (crossposts)
193
+ const seen = new Set<string>();
194
+ allRecords = allRecords.filter((r) => {
195
+ if (seen.has(r.sourceId)) return false;
196
+ seen.add(r.sourceId);
197
+ return true;
198
+ });
199
+
200
+ // Fetch comments for top 10 posts by engagement
201
+ const commentsPerPost = config.sentiment?.commentsPerPost ?? 5;
202
+ const topPosts = [...allRecords]
203
+ .sort((a, b) => b.engagement.score - a.engagement.score)
204
+ .slice(0, 10);
164
205
 
165
- lines.push("");
166
- lines.push(untrustedContentHeader("Reddit posts"));
167
- for (const post of postRecords.slice(0, 10)) {
168
- const scoreIndicator =
169
- post.sentiment.score > 0 ? "🟢" : post.sentiment.score < 0 ? "🔴" : "⚪";
170
- lines.push(
171
- ` ${scoreIndicator} ⬆${post.engagement.score} 💬${post.engagement.replies ?? 0} — ${renderUntrustedText(post.title ?? post.text, 100)}`,
206
+ for (const post of topPosts) {
207
+ const sub = (post.metadata.subreddit as string) ?? subreddits[0];
208
+ if ((post.engagement.replies ?? 0) === 0) continue;
209
+ try {
210
+ const comments = await getPostComments(sub, post.sourceId, commentsPerPost);
211
+ const commentRecords = adapter.mapCommentsToRecords(
212
+ comments,
213
+ post.sourceId,
214
+ sub,
215
+ args.query ?? subreddits.join("+"),
216
+ );
217
+ allRecords.push(
218
+ ...(queryTerms
219
+ ? commentRecords.filter((record) => recordMatchesSentimentQuery(record, queryTerms))
220
+ : commentRecords),
221
+ );
222
+ } catch {
223
+ // Comment fetch failures are non-fatal
224
+ }
225
+ }
226
+
227
+ // Process through pipeline
228
+ const pipeline = getSentimentPipeline();
229
+ const pipelineResult = await pipeline.processRecords(
230
+ allRecords,
231
+ args.query ?? subreddits.join("+"),
172
232
  );
173
- }
174
233
 
175
- if (pipelineResult.trend && pipelineResult.trend.length > 0) {
176
- const t = pipelineResult.trend[0];
177
- lines.push("");
178
- lines.push(
179
- `Trend: ${t.sparkline} ${t.direction} (${t.delta >= 0 ? "+" : ""}${t.delta.toFixed(2)}, ${t.count} records)`,
234
+ // Build output using first result as base for backward compatibility
235
+ const firstResult = allResults[0];
236
+ const postRecords = pipelineResult.fresh.filter((r) => !r.metadata.isComment);
237
+ const commentRecords = pipelineResult.fresh.filter((r) => r.metadata.isComment);
238
+ const sentimentRecords = pipelineResult.fresh;
239
+ const rawPostsById = new Map(
240
+ allResults.flatMap((result) => result.posts.map((post) => [post.id, post] as const)),
180
241
  );
181
- }
242
+ const aggregatePosts: RedditSentimentResult["posts"] = postRecords.map((record) => {
243
+ const rawPost = rawPostsById.get(record.sourceId);
244
+ return {
245
+ id: record.sourceId,
246
+ title: rawPost?.title ?? record.title ?? "",
247
+ selftext: rawPost?.selftext ?? "",
248
+ author: rawPost?.author ?? record.author ?? "unknown",
249
+ score: rawPost?.score ?? record.engagement.score,
250
+ comments: rawPost?.comments ?? record.engagement.replies ?? 0,
251
+ url: rawPost?.url ?? record.url,
252
+ created: rawPost?.created ?? record.publishedAt ?? record.fetchedAt,
253
+ };
254
+ });
255
+ const aggregateTopMentions = [
256
+ ...new Set(postRecords.flatMap((record) => record.sentiment.tickers)),
257
+ ];
258
+ const avgScore =
259
+ sentimentRecords.length > 0
260
+ ? sentimentRecords.reduce((s, r) => s + r.sentiment.score, 0) / sentimentRecords.length
261
+ : 0;
262
+
263
+ const sentimentLabel =
264
+ avgScore > 0.3
265
+ ? "Bullish"
266
+ : avgScore < -0.3
267
+ ? "Bearish"
268
+ : avgScore > 0
269
+ ? "Leaning Bullish"
270
+ : avgScore < 0
271
+ ? "Leaning Bearish"
272
+ : "Neutral";
273
+
274
+ const subLabel =
275
+ subreddits.length === 1 ? `r/${subreddits[0]}` : `${subreddits.length} subreddits`;
276
+ const lines = [
277
+ `**Reddit: ${args.query ?? subLabel}** — ${postRecords.length} posts, ${commentRecords.length} comments`,
278
+ `Sentiment: ${avgScore.toFixed(2)} (${sentimentLabel})`,
279
+ ];
280
+
281
+ const details: RedditSentimentResult = {
282
+ ...firstResult,
283
+ subreddit: subreddits.length === 1 ? firstResult.subreddit : subreddits.join(","),
284
+ postCount: postRecords.length,
285
+ posts: aggregatePosts,
286
+ topMentions: aggregateTopMentions,
287
+ sentimentScore: avgScore,
288
+ bullishCount: sentimentRecords.filter((record) => record.sentiment.score > 0).length,
289
+ bearishCount: sentimentRecords.filter((record) => record.sentiment.score < 0).length,
290
+ insight: pipelineResult.insight,
291
+ records: pipelineResult.fresh,
292
+ };
293
+
294
+ if (pipelineResult.insight) {
295
+ lines.push(...formatInsightSection(pipelineResult.insight));
296
+ }
297
+
298
+ if (aggregateTopMentions.length > 0) {
299
+ lines.push(`Tickers: ${aggregateTopMentions.map((t) => `$${t}`).join(", ")}`);
300
+ }
182
301
 
183
- if (warnings.length > 0) {
184
302
  lines.push("");
185
- lines.push(`⚠ ${warnings.join("; ")}`);
303
+ lines.push(untrustedContentHeader("Reddit posts"));
304
+ for (const post of postRecords.slice(0, 10)) {
305
+ const scoreIndicator =
306
+ post.sentiment.score > 0 ? "🟢" : post.sentiment.score < 0 ? "🔴" : "⚪";
307
+ lines.push(
308
+ ` ${scoreIndicator} ⬆${post.engagement.score} 💬${post.engagement.replies ?? 0} — ${renderUntrustedText(post.title ?? post.text, 100)}`,
309
+ );
310
+ }
311
+
312
+ if (pipelineResult.trend && pipelineResult.trend.length > 0) {
313
+ const t = pipelineResult.trend[0];
314
+ lines.push("");
315
+ lines.push(
316
+ `Trend: ${t.sparkline} ${t.direction} (${t.delta >= 0 ? "+" : ""}${t.delta.toFixed(2)}, ${t.count} records)`,
317
+ );
318
+ }
319
+
320
+ if (warnings.length > 0) {
321
+ lines.push("");
322
+ lines.push(`⚠ ${warnings.join("; ")}`);
323
+ }
324
+
325
+ return { content: [{ type: "text", text: lines.join("\n") }], details };
326
+ },
327
+ };
328
+ }
329
+
330
+ export const redditSentimentTool = createRedditSentimentTool();
331
+
332
+ function unavailableRedditResult(warnings: readonly string[]): {
333
+ content: [{ type: "text"; text: string }];
334
+ details: null;
335
+ } {
336
+ return {
337
+ content: [{ type: "text", text: `⚠ Reddit sentiment unavailable (${warnings.join("; ")}).` }],
338
+ details: null,
339
+ };
340
+ }
341
+
342
+ async function fetchRedditSubreddits(
343
+ subreddits: readonly string[],
344
+ limit: number,
345
+ query: string | undefined,
346
+ ): Promise<{ allResults: RedditSentimentResult[]; warnings: string[] }> {
347
+ const allResults: RedditSentimentResult[] = [];
348
+ const warnings: string[] = [];
349
+ for (const sub of subreddits) {
350
+ const providerResult = await wrapProvider("reddit", () => getSubredditPosts(sub, limit, query));
351
+ if (providerResult.status === "unavailable") {
352
+ warnings.push(`r/${sub}: ${providerResult.reason}`);
353
+ continue;
186
354
  }
355
+ allResults.push(providerResult.data);
356
+ }
357
+ return { allResults, warnings };
358
+ }
359
+
360
+ function skippedRedditResult(
361
+ message: string,
362
+ remediation: string,
363
+ silenced: boolean,
364
+ ): { content: [{ type: "text"; text: string }]; details: null } {
365
+ return {
366
+ content: [
367
+ {
368
+ type: "text",
369
+ text: `${buildSkippedTag({
370
+ provider: "reddit",
371
+ reason: "credential_not_provided",
372
+ remediation,
373
+ silenced,
374
+ })}\n\n${message}`,
375
+ },
376
+ ],
377
+ details: null,
378
+ };
379
+ }
187
380
 
188
- return { content: [{ type: "text", text: lines.join("\n") }], details: firstResult };
189
- },
190
- };
381
+ function classifyRedditSetupIssue(
382
+ warnings: readonly string[],
383
+ ): "not_installed" | "session_missing" | "session_stale" | null {
384
+ const combined = warnings.join("\n").toLowerCase();
385
+ if (combined.includes("not installed") || combined.includes("enoent")) {
386
+ return "not_installed";
387
+ }
388
+ if (
389
+ combined.includes("no reddit cookies") ||
390
+ combined.includes("not authenticated") ||
391
+ combined.includes("rdt login")
392
+ ) {
393
+ return "session_missing";
394
+ }
395
+ if (
396
+ combined.includes("401") ||
397
+ combined.includes("unauthorized") ||
398
+ combined.includes("expired")
399
+ ) {
400
+ return "session_stale";
401
+ }
402
+ return null;
403
+ }