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,13 +1,11 @@
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";
4
5
  import { hasCredential } from "../../onboarding/providers.js";
5
6
  import { buildSoftDegradedTag } from "../../onboarding/tool-tags.js";
6
7
  import { finnhubDateRange, getCompanyNews } from "../../providers/finnhub.js";
7
- import { getPostComments, getSubredditPosts } from "../../providers/reddit.js";
8
- import { getTwitterSentiment } from "../../providers/twitter.js";
9
8
  import { searchWeb } from "../../providers/web-search.js";
10
- import { wrapProvider } from "../../providers/wrap-provider.js";
11
9
  import { getQuote } from "../../providers/yahoo-finance.js";
12
10
  import { extractTickersFromQuery, FinnhubAdapter } from "../../sentiment/adapters/finnhub.js";
13
11
  import { RedditAdapter } from "../../sentiment/adapters/reddit.js";
@@ -15,6 +13,10 @@ import { TwitterAdapter } from "../../sentiment/adapters/twitter.js";
15
13
  import { WebAdapter } from "../../sentiment/adapters/web.js";
16
14
  import { getSentimentPipeline } from "../../sentiment/index.js";
17
15
  import type { SentinelRecord } from "../../sentiment/types.js";
16
+ import type { AskUserHandler } from "../../types/index.js";
17
+ import { formatInsightSection } from "./insight-format.js";
18
+ import { createRedditSentimentTool } from "./reddit-sentiment.js";
19
+ import { createTwitterSentimentTool } from "./twitter-sentiment.js";
18
20
 
19
21
  const params = Type.Object({
20
22
  query: Type.String({ description: "Ticker or topic for cross-source sentiment summary" }),
@@ -23,200 +25,271 @@ const params = Type.Object({
23
25
  ),
24
26
  });
25
27
 
26
- export const sentimentSummaryTool: AgentTool<typeof params> = {
27
- name: "get_sentiment_summary",
28
- label: "Sentiment Summary",
29
- description:
30
- "Cross-source sentiment summary combining Twitter, Reddit, and web/news. Returns per-source scores, aggregate sentiment, and divergence detection.",
31
- parameters: params,
32
- async execute(_toolCallId, args) {
33
- const hours = args.hours ?? 24;
34
- const config = getConfig();
35
- const warnings: string[] = [];
36
- const allRecords: SentinelRecord[] = [];
37
-
38
- const twitterAdapter = new TwitterAdapter();
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 = candidateTickers.length > 0 && !hasCredential("finnhub");
51
-
52
- // Finnhub fetch (built separately to avoid mixing promise types in allSettled)
53
- const finnhubFetch: Promise<import("../../providers/finnhub.js").FinnhubArticle[]> =
54
- 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(
70
- args.query,
71
- config.sentiment?.defaultSubreddits ?? ["wallstreetbets", "stocks", "investing", "options"],
72
- ),
73
- // Web
74
- searchWeb(args.query, { freshness: "day", limit: 10, category: "news" }),
75
- // Finnhub — only when includeFinnhub; otherwise resolves to []
76
- finnhubFetch,
77
- ]);
78
-
79
- // Process Twitter
80
- if (twitterResult.status === "fulfilled" && twitterResult.value.status === "ok") {
81
- const records = twitterAdapter.mapToRecords(twitterResult.value.data, args.query);
82
- allRecords.push(...records);
83
- } else {
84
- const reason =
85
- twitterResult.status === "rejected"
86
- ? (twitterResult.reason?.message ?? "unknown error")
87
- : ((twitterResult.value as any).reason ?? "unavailable");
88
- warnings.push(`Twitter: ${reason}`);
89
- }
90
-
91
- // Process Reddit
92
- if (redditResults.status === "fulfilled") {
93
- const { records: redditRecords, warnings: redditWarnings } = redditResults.value;
94
- allRecords.push(...redditRecords);
95
- warnings.push(...redditWarnings);
96
- } else {
97
- warnings.push(`Reddit: ${redditResults.reason?.message ?? "unknown error"}`);
98
- }
99
-
100
- // Process Web
101
- if (webResult.status === "fulfilled" && webResult.value.status === "ok") {
102
- const records = webAdapter.mapToRecords(webResult.value.data, args.query);
103
- allRecords.push(...records);
104
- } else {
105
- const reason =
106
- webResult.status === "rejected"
107
- ? (webResult.reason?.message ?? "unknown error")
108
- : ((webResult.value as any).reason ?? "unavailable");
109
- warnings.push(`Web: ${reason}`);
110
- }
111
-
112
- // Process Finnhub (only when included — otherwise resolves to empty array anyway)
113
- if (includeFinnhub) {
114
- if (finnhubResult.status === "fulfilled") {
115
- const articles = finnhubResult.value;
116
- if (articles.length > 0) {
117
- const records = finnhubAdapter.mapToRecords(articles, args.query);
118
- allRecords.push(...records);
28
+ type ToolTextContent = { type: string; text?: string };
29
+ type SetupAwareExecute<TParams, TDetails> = (
30
+ toolCallId: string,
31
+ params: TParams,
32
+ signal?: AbortSignal,
33
+ onUpdate?: undefined,
34
+ ctx?: ExtensionContext,
35
+ ) => Promise<{ content: ToolTextContent[]; details: TDetails }>;
36
+
37
+ interface SentimentSummaryToolOptions {
38
+ askUserHandler?: AskUserHandler;
39
+ }
40
+
41
+ export function createSentimentSummaryTool(
42
+ options: SentimentSummaryToolOptions = {},
43
+ ): AgentTool<typeof params> {
44
+ return {
45
+ name: "get_sentiment_summary",
46
+ label: "Sentiment Summary",
47
+ description:
48
+ "Cross-source sentiment summary combining Twitter, Reddit, and web/news. Returns per-source scores, aggregate sentiment, and divergence detection.",
49
+ parameters: params,
50
+ async execute(_toolCallId, args, _signal, _onUpdate, ctx?: ExtensionContext) {
51
+ const hours = args.hours ?? 24;
52
+ const config = getConfig();
53
+ const warnings: string[] = [];
54
+ const allRecords: SentinelRecord[] = [];
55
+
56
+ const twitterAdapter = new TwitterAdapter();
57
+ const webAdapter = new WebAdapter();
58
+ const finnhubAdapter = new FinnhubAdapter();
59
+
60
+ // Determine if Finnhub should be included (key configured + ticker in
61
+ // query). `candidateTickers` is extracted unconditionally so we can tell
62
+ // a "no finnhub-mappable ticker in the query" case apart from a "query
63
+ // has tickers but user has no Finnhub key" case — the latter warrants a
64
+ // soft-degraded tag so the LLM surfaces it in the Data gaps section.
65
+ const candidateTickers = extractTickersFromQuery(args.query);
66
+ const finnhubTickers = config.finnhubApiKey ? candidateTickers : [];
67
+ const includeFinnhub = finnhubTickers.length > 0 && Boolean(config.finnhubApiKey);
68
+ const finnhubSoftDegraded = candidateTickers.length > 0 && !hasCredential("finnhub");
69
+
70
+ const fetchFinnhub = async (): Promise<
71
+ import("../../providers/finnhub.js").FinnhubArticle[]
72
+ > => {
73
+ if (!includeFinnhub) return [];
74
+ const { from, to } = finnhubDateRange("day");
75
+ const arrays = await Promise.all(
76
+ finnhubTickers.map((sym) => getCompanyNews(sym, from, to, config.finnhubApiKey!)),
77
+ );
78
+ return arrays.flat();
79
+ };
80
+
81
+ // External-tool sources may need ask_user setup. Run them through the
82
+ // source-specific tools so install/login/skip preferences behave the same
83
+ // in cross-source summaries as they do in direct source requests.
84
+ try {
85
+ const twitterTool = createTwitterSentimentTool({
86
+ askUserHandler: options.askUserHandler,
87
+ });
88
+ const executeTwitter = twitterTool.execute as unknown as SetupAwareExecute<
89
+ { query: string; limit: number; hours: number },
90
+ NonNullable<Awaited<ReturnType<typeof twitterTool.execute>>["details"]> | null
91
+ >;
92
+ const twitterResult = await executeTwitter(
93
+ "summary-twitter",
94
+ { query: args.query, limit: 50, hours },
95
+ undefined,
96
+ undefined,
97
+ ctx,
98
+ );
99
+ if (twitterResult.details) {
100
+ allRecords.push(...twitterAdapter.mapToRecords(twitterResult.details, args.query));
101
+ } else {
102
+ warnings.push(sourceToolWarning("Twitter", toolText(twitterResult.content)));
119
103
  }
120
- } else {
121
- warnings.push(`Finnhub: ${finnhubResult.reason?.message ?? "unknown error"}`);
104
+ } catch (err) {
105
+ warnings.push(`Twitter: ${err instanceof Error ? err.message : String(err)}`);
122
106
  }
123
- }
124
-
125
- const softDegradedPrefix = finnhubSoftDegraded
126
- ? `${buildSoftDegradedTag({
127
- provider: "finnhub",
128
- fallback: "other-sentiment-sources",
129
- remediation: "run /connect news to enable Finnhub company news",
130
- })}\n\n`
131
- : "";
132
107
 
133
- if (allRecords.length === 0) {
134
- return {
135
- content: [
108
+ try {
109
+ const redditTool = createRedditSentimentTool({
110
+ askUserHandler: options.askUserHandler,
111
+ });
112
+ const executeReddit = redditTool.execute as unknown as SetupAwareExecute<
113
+ { query: string; subreddits: string[] },
114
+ NonNullable<Awaited<ReturnType<typeof redditTool.execute>>["details"]> | null
115
+ >;
116
+ const redditResult = await executeReddit(
117
+ "summary-reddit",
136
118
  {
137
- type: "text",
138
- text: `${softDegradedPrefix}⚠ Sentiment summary unavailable for "${args.query}" no sources returned data.\n${warnings.join("\n")}`,
119
+ query: args.query,
120
+ subreddits: config.sentiment?.defaultSubreddits ?? [
121
+ "wallstreetbets",
122
+ "stocks",
123
+ "investing",
124
+ "options",
125
+ ],
139
126
  },
140
- ],
141
- details: null as any,
142
- };
143
- }
144
-
145
- // Score and index through pipeline
146
- const pipeline = getSentimentPipeline();
147
- const result = await pipeline.processRecords(allRecords, args.query);
148
-
149
- // Group by source (exclude comments from per-source averages)
150
- const bySource: Record<string, { total: number; count: number }> = {};
151
- for (const rec of result.fresh) {
152
- if (rec.metadata.isComment) continue;
153
- if (!bySource[rec.source]) bySource[rec.source] = { total: 0, count: 0 };
154
- bySource[rec.source].total += rec.sentiment.score;
155
- bySource[rec.source].count++;
156
- }
157
-
158
- const lines: string[] = [];
159
- lines.push(`**Sentiment summary for "${args.query}"** (last ${hours}h):`);
160
- lines.push("");
161
- lines.push("| Source | Score | Count | Signal |");
162
- lines.push("|--------|-------|-------|--------|");
163
-
164
- let totalScore = 0;
165
- let totalCount = 0;
166
- for (const [source, stats] of Object.entries(bySource)) {
167
- const avg = stats.count > 0 ? stats.total / stats.count : 0;
168
- const label = sentimentLabel(avg);
169
- const sourceName =
170
- source === "web" ? "Web/News" : source.charAt(0).toUpperCase() + source.slice(1);
171
- lines.push(
172
- `| ${sourceName} | ${avg >= 0 ? "+" : ""}${avg.toFixed(2)} | ${stats.count} | ${label} |`,
173
- );
174
- totalScore += stats.total;
175
- totalCount += stats.count;
176
- }
177
-
178
- const aggregate = totalCount > 0 ? totalScore / totalCount : 0;
179
- lines.push("");
180
- lines.push(
181
- `**Aggregate:** ${aggregate >= 0 ? "+" : ""}${aggregate.toFixed(2)} (${sentimentLabel(aggregate)})`,
182
- );
183
-
184
- const priceContext = await buildPriceContext(candidateTickers[0], aggregate);
185
- if (priceContext) {
186
- lines.push("");
187
- lines.push(priceContext);
188
- }
127
+ undefined,
128
+ undefined,
129
+ ctx,
130
+ );
131
+ if (redditResult.details) {
132
+ allRecords.push(
133
+ ...(redditResult.details.records ??
134
+ new RedditAdapter().mapPostsToRecords(redditResult.details, args.query)),
135
+ );
136
+ } else {
137
+ warnings.push(sourceToolWarning("Reddit", toolText(redditResult.content)));
138
+ }
139
+ } catch (err) {
140
+ warnings.push(`Reddit: ${err instanceof Error ? err.message : String(err)}`);
141
+ }
189
142
 
190
- lines.push("");
191
- lines.push(
192
- "Source-coverage risk: sentiment can be noisy and missing sources can skew the signal; treat this as supporting evidence, not a standalone buy/sell input.",
193
- );
143
+ const [webResult, finnhubResult] = await Promise.allSettled([
144
+ searchWeb(args.query, { freshness: "day", limit: 10, category: "news" }),
145
+ // Finnhub only when includeFinnhub; otherwise resolves to []
146
+ fetchFinnhub(),
147
+ ]);
194
148
 
195
- // Divergence
196
- if (result.divergence && result.divergence.detected) {
197
- lines.push("");
198
- lines.push(result.divergence.message);
199
- } else if (result.divergence && !result.divergence.detected) {
149
+ // Process Web
150
+ if (webResult.status === "fulfilled" && webResult.value.status === "ok") {
151
+ const records = webAdapter.mapToRecords(webResult.value.data, args.query);
152
+ allRecords.push(...records);
153
+ } else {
154
+ const reason =
155
+ webResult.status === "rejected"
156
+ ? (webResult.reason?.message ?? "unknown error")
157
+ : ((webResult.value as any).reason ?? "unavailable");
158
+ warnings.push(`Web: ${reason}`);
159
+ }
160
+
161
+ // Process Finnhub (only when included — otherwise resolves to empty array anyway)
162
+ if (includeFinnhub) {
163
+ if (finnhubResult.status === "fulfilled") {
164
+ const articles = finnhubResult.value;
165
+ if (articles.length > 0) {
166
+ const records = finnhubAdapter.mapToRecords(articles, args.query);
167
+ allRecords.push(...records);
168
+ }
169
+ } else {
170
+ warnings.push(`Finnhub: ${finnhubResult.reason?.message ?? "unknown error"}`);
171
+ }
172
+ }
173
+
174
+ const softDegradedPrefix = finnhubSoftDegraded
175
+ ? `${buildSoftDegradedTag({
176
+ provider: "finnhub",
177
+ fallback: "other-sentiment-sources",
178
+ remediation: "run /connect news to enable Finnhub company news",
179
+ })}\n\n`
180
+ : "";
181
+
182
+ if (allRecords.length === 0) {
183
+ return {
184
+ content: [
185
+ {
186
+ type: "text",
187
+ text: `${softDegradedPrefix}⚠ Sentiment summary unavailable for "${args.query}" — no sources returned data.\n${warnings.join("\n")}`,
188
+ },
189
+ ],
190
+ details: null as any,
191
+ };
192
+ }
193
+
194
+ const summaryWarnings = warnings.map(stripOpenCandleControlLines);
195
+
196
+ // Score and index through pipeline
197
+ const pipeline = getSentimentPipeline();
198
+ const result = await pipeline.processRecords(allRecords, args.query);
199
+ const insight =
200
+ result.insight && summaryWarnings.length > 0
201
+ ? {
202
+ ...result.insight,
203
+ caveats: [
204
+ ...result.insight.caveats,
205
+ ...summaryWarnings.map((warning) => `Source warning: ${warning}`),
206
+ ],
207
+ }
208
+ : result.insight;
209
+
210
+ // Group by source (exclude comments from per-source averages)
211
+ const bySource: Record<string, { total: number; count: number }> = {};
212
+ for (const rec of result.fresh) {
213
+ if (rec.metadata.isComment) continue;
214
+ if (!bySource[rec.source]) bySource[rec.source] = { total: 0, count: 0 };
215
+ bySource[rec.source].total += rec.sentiment.score;
216
+ bySource[rec.source].count++;
217
+ }
218
+
219
+ const lines: string[] = [];
220
+ lines.push(`**Sentiment summary for "${args.query}"** (last ${hours}h):`);
200
221
  lines.push("");
201
- lines.push(result.divergence.message);
202
- }
222
+ lines.push("| Source | Score | Count | Signal |");
223
+ lines.push("|--------|-------|-------|--------|");
224
+
225
+ let totalScore = 0;
226
+ let totalCount = 0;
227
+ for (const [source, stats] of Object.entries(bySource)) {
228
+ const avg = stats.count > 0 ? stats.total / stats.count : 0;
229
+ const label = sentimentLabel(avg);
230
+ const sourceName =
231
+ source === "web" ? "Web/News" : source.charAt(0).toUpperCase() + source.slice(1);
232
+ lines.push(
233
+ `| ${sourceName} | ${avg >= 0 ? "+" : ""}${avg.toFixed(2)} | ${stats.count} | ${label} |`,
234
+ );
235
+ totalScore += stats.total;
236
+ totalCount += stats.count;
237
+ }
203
238
 
204
- // Trend
205
- if (result.trend && result.trend.length > 0) {
206
- const t = result.trend[0];
239
+ const aggregate = totalCount > 0 ? totalScore / totalCount : 0;
207
240
  lines.push("");
208
- lines.push(`Trend: ${t.sparkline} ${t.direction} (${t.count} records)`);
209
- }
241
+ lines.push(
242
+ `**Aggregate:** ${aggregate >= 0 ? "+" : ""}${aggregate.toFixed(2)} (${sentimentLabel(aggregate)})`,
243
+ );
244
+
245
+ if (insight) {
246
+ lines.push(...formatInsightSection(insight));
247
+ }
248
+
249
+ const priceContext = await buildPriceContext(candidateTickers[0], aggregate);
250
+ if (priceContext) {
251
+ lines.push("");
252
+ lines.push(priceContext);
253
+ } else if (candidateTickers[0]) {
254
+ lines.push("");
255
+ lines.push(
256
+ `Price context: unavailable for ${candidateTickers[0]}; sentiment/price divergence could not be evaluated.`,
257
+ );
258
+ }
210
259
 
211
- if (warnings.length > 0) {
212
260
  lines.push("");
213
- lines.push(warnings.map((w) => `⚠ ${w}`).join("\n"));
214
- }
261
+ lines.push(
262
+ "Source-coverage risk: sentiment can be noisy and missing sources can skew the signal; treat this as supporting evidence, not a standalone buy/sell input.",
263
+ );
264
+
265
+ // Divergence
266
+ if (result.divergence && result.divergence.detected) {
267
+ lines.push("");
268
+ lines.push(result.divergence.message);
269
+ } else if (result.divergence && !result.divergence.detected) {
270
+ lines.push("");
271
+ lines.push(result.divergence.message);
272
+ }
273
+
274
+ // Trend
275
+ if (result.trend && result.trend.length > 0) {
276
+ const t = result.trend[0];
277
+ lines.push("");
278
+ lines.push(`Trend: ${t.sparkline} ${t.direction} (${t.count} records)`);
279
+ }
280
+
281
+ if (summaryWarnings.length > 0) {
282
+ lines.push("");
283
+ lines.push(summaryWarnings.map((w) => `⚠ ${w}`).join("\n"));
284
+ }
285
+
286
+ const output = softDegradedPrefix + lines.join("\n");
287
+ return { content: [{ type: "text", text: output }], details: { ...result, insight } };
288
+ },
289
+ };
290
+ }
215
291
 
216
- const output = softDegradedPrefix + lines.join("\n");
217
- return { content: [{ type: "text", text: output }], details: result };
218
- },
219
- };
292
+ export const sentimentSummaryTool = createSentimentSummaryTool();
220
293
 
221
294
  async function buildPriceContext(
222
295
  symbol: string | undefined,
@@ -275,59 +348,6 @@ function formatQuoteFreshnessNote(timestamp: number | undefined): string {
275
348
  return ` Last available quote timestamp: ${quoteStamp} ET.${marketClosedNote}`;
276
349
  }
277
350
 
278
- async function fetchRedditCrossSubreddit(
279
- query: string,
280
- subreddits: string[],
281
- ): Promise<{ records: SentinelRecord[]; warnings: string[] }> {
282
- const adapter = new RedditAdapter();
283
- const records: SentinelRecord[] = [];
284
- const warnings: string[] = [];
285
- const config = getConfig();
286
- const commentsPerPost = config.sentiment?.commentsPerPost ?? 5;
287
-
288
- for (const sub of subreddits) {
289
- const result = await wrapProvider("reddit", () => getSubredditPosts(sub, 25));
290
- if (result.status === "unavailable") {
291
- warnings.push(`Reddit r/${sub}: ${result.reason}`);
292
- continue;
293
- }
294
- const postRecords = adapter.mapPostsToRecords(result.data, query);
295
-
296
- // Topic filter
297
- const queryLower = query.toLowerCase();
298
- const filtered = postRecords.filter(
299
- (r) =>
300
- r.text.toLowerCase().includes(queryLower) ||
301
- (r.title?.toLowerCase().includes(queryLower) ?? false),
302
- );
303
- records.push(...filtered);
304
-
305
- // Fetch comments for top posts
306
- const topPosts = [...filtered]
307
- .sort((a, b) => b.engagement.score - a.engagement.score)
308
- .slice(0, 3); // fewer per sub since we're searching multiple
309
- for (const post of topPosts) {
310
- if ((post.engagement.replies ?? 0) === 0) continue;
311
- try {
312
- const comments = await getPostComments(sub, post.sourceId, commentsPerPost);
313
- records.push(...adapter.mapCommentsToRecords(comments, post.sourceId, sub, query));
314
- } catch {
315
- /* non-fatal */
316
- }
317
- }
318
- }
319
-
320
- // Deduplicate
321
- const seen = new Set<string>();
322
- const deduped = records.filter((r) => {
323
- if (seen.has(r.sourceId)) return false;
324
- seen.add(r.sourceId);
325
- return true;
326
- });
327
-
328
- return { records: deduped, warnings };
329
- }
330
-
331
351
  function sentimentLabel(score: number): string {
332
352
  if (score > 0.3) return "Bullish";
333
353
  if (score < -0.3) return "Bearish";
@@ -335,3 +355,29 @@ function sentimentLabel(score: number): string {
335
355
  if (score < 0) return "Leaning Bearish";
336
356
  return "Neutral";
337
357
  }
358
+
359
+ function toolText(content: ToolTextContent[]): string {
360
+ return content
361
+ .map((part) => (part.type === "text" ? (part.text ?? "") : ""))
362
+ .filter(Boolean)
363
+ .join("\n");
364
+ }
365
+
366
+ function sourceToolWarning(source: string, text: string): string {
367
+ const lines = text
368
+ .split("\n")
369
+ .map((line) => line.trim())
370
+ .filter(Boolean);
371
+ const tag = lines.find((line) => line.includes("[OPENCANDLE_"));
372
+ const summary = lines.find((line) => !line.includes("[OPENCANDLE_")) ?? "unavailable";
373
+ return tag ? `${source}: ${summary}\n${tag}` : `${source}: ${summary}`;
374
+ }
375
+
376
+ function stripOpenCandleControlLines(text: string): string {
377
+ const stripped = text
378
+ .split("\n")
379
+ .map((line) => line.trim())
380
+ .filter((line) => line.length > 0 && !line.startsWith("[OPENCANDLE_"))
381
+ .join("\n");
382
+ return stripped || "unavailable";
383
+ }