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.
- package/README.md +10 -3
- package/dist/cli.js +36 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/infra/index.d.ts +0 -1
- package/dist/infra/index.js +0 -1
- package/dist/infra/index.js.map +1 -1
- package/dist/onboarding/connect.d.ts +2 -2
- package/dist/onboarding/connect.js +10 -3
- package/dist/onboarding/connect.js.map +1 -1
- package/dist/onboarding/provider-status.d.ts +48 -0
- package/dist/onboarding/provider-status.js +285 -0
- package/dist/onboarding/provider-status.js.map +1 -0
- package/dist/onboarding/providers.d.ts +85 -8
- package/dist/onboarding/providers.js +87 -9
- package/dist/onboarding/providers.js.map +1 -1
- package/dist/onboarding/state.d.ts +1 -0
- package/dist/onboarding/state.js +5 -0
- package/dist/onboarding/state.js.map +1 -1
- package/dist/onboarding/tool-tags.d.ts +12 -1
- package/dist/onboarding/tool-tags.js +31 -1
- package/dist/onboarding/tool-tags.js.map +1 -1
- package/dist/onboarding/validation.d.ts +2 -2
- package/dist/onboarding/validation.js.map +1 -1
- package/dist/pi/opencandle-extension.js +91 -15
- package/dist/pi/opencandle-extension.js.map +1 -1
- package/dist/pi/tool-adapter.d.ts +4 -1
- package/dist/pi/tool-adapter.js +5 -4
- package/dist/pi/tool-adapter.js.map +1 -1
- package/dist/prompts/context-builder.js +1 -1
- package/dist/prompts/policy-cards.js +1 -1
- package/dist/prompts/policy-cards.js.map +1 -1
- package/dist/providers/external-tool-error.d.ts +10 -0
- package/dist/providers/external-tool-error.js +21 -0
- package/dist/providers/external-tool-error.js.map +1 -0
- package/dist/providers/reddit-cli.d.ts +36 -0
- package/dist/providers/reddit-cli.js +201 -0
- package/dist/providers/reddit-cli.js.map +1 -0
- package/dist/providers/reddit.d.ts +1 -1
- package/dist/providers/reddit.js +7 -35
- package/dist/providers/reddit.js.map +1 -1
- package/dist/providers/twitter-cli.d.ts +40 -0
- package/dist/providers/twitter-cli.js +153 -0
- package/dist/providers/twitter-cli.js.map +1 -0
- package/dist/providers/twitter.d.ts +0 -8
- package/dist/providers/twitter.js +4 -54
- package/dist/providers/twitter.js.map +1 -1
- package/dist/providers/wrap-provider.js +30 -0
- package/dist/providers/wrap-provider.js.map +1 -1
- package/dist/providers/yahoo-finance.js +53 -32
- package/dist/providers/yahoo-finance.js.map +1 -1
- package/dist/routing/planning.d.ts +1 -1
- package/dist/routing/planning.js.map +1 -1
- package/dist/runtime/answer-contracts.d.ts +1 -1
- package/dist/runtime/answer-contracts.js +12 -1
- package/dist/runtime/answer-contracts.js.map +1 -1
- package/dist/runtime/tool-defaults-wrapper.js +6 -2
- package/dist/runtime/tool-defaults-wrapper.js.map +1 -1
- package/dist/sentiment/index.d.ts +1 -0
- package/dist/sentiment/index.js +1 -0
- package/dist/sentiment/index.js.map +1 -1
- package/dist/sentiment/insights.d.ts +17 -0
- package/dist/sentiment/insights.js +206 -0
- package/dist/sentiment/insights.js.map +1 -0
- package/dist/sentiment/pipeline.js +13 -1
- package/dist/sentiment/pipeline.js.map +1 -1
- package/dist/sentiment/scorer.d.ts +2 -0
- package/dist/sentiment/scorer.js +10 -1
- package/dist/sentiment/scorer.js.map +1 -1
- package/dist/sentiment/types.d.ts +2 -0
- package/dist/sentiment/types.js.map +1 -1
- package/dist/system-prompt.js +3 -7
- package/dist/system-prompt.js.map +1 -1
- package/dist/tools/index.d.ts +5 -2
- package/dist/tools/index.js +8 -8
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/sentiment/insight-format.d.ts +2 -0
- package/dist/tools/sentiment/insight-format.js +36 -0
- package/dist/tools/sentiment/insight-format.js.map +1 -0
- package/dist/tools/sentiment/query-match.d.ts +3 -0
- package/dist/tools/sentiment/query-match.js +113 -0
- package/dist/tools/sentiment/query-match.js.map +1 -0
- package/dist/tools/sentiment/reddit-sentiment.d.ts +12 -1
- package/dist/tools/sentiment/reddit-sentiment.js +263 -117
- package/dist/tools/sentiment/reddit-sentiment.js.map +1 -1
- package/dist/tools/sentiment/sentiment-summary.d.ts +9 -1
- package/dist/tools/sentiment/sentiment-summary.js +217 -201
- package/dist/tools/sentiment/sentiment-summary.js.map +1 -1
- package/dist/tools/sentiment/twitter-sentiment.d.ts +11 -1
- package/dist/tools/sentiment/twitter-sentiment.js +187 -64
- package/dist/tools/sentiment/twitter-sentiment.js.map +1 -1
- package/dist/tools/sentiment/web-sentiment.js +4 -0
- package/dist/tools/sentiment/web-sentiment.js.map +1 -1
- package/dist/types/sentiment.d.ts +52 -0
- package/gui/server/invoke-tool.ts +17 -3
- package/gui/server/model-setup.ts +10 -3
- package/gui/server/projector.ts +6 -2
- package/gui/server/server.ts +18 -0
- package/gui/server/tool-metadata.ts +80 -16
- package/gui/server/ws-hub.ts +19 -0
- package/gui/web/dist/assets/CatalogOverlay-CgeY5Pkp.js +1 -0
- package/gui/web/dist/assets/index-C6W_2eAn.js +69 -0
- package/gui/web/dist/assets/{index-2KZtKBmu.css → index-hwbx24a5.css} +1 -1
- package/gui/web/dist/index.html +2 -2
- package/package.json +5 -6
- package/src/cli.ts +41 -0
- package/src/config.ts +27 -0
- package/src/infra/index.ts +0 -1
- package/src/onboarding/connect.ts +20 -4
- package/src/onboarding/provider-status.ts +410 -0
- package/src/onboarding/providers.ts +148 -18
- package/src/onboarding/state.ts +9 -0
- package/src/onboarding/tool-tags.ts +45 -2
- package/src/onboarding/validation.ts +2 -2
- package/src/pi/opencandle-extension.ts +115 -17
- package/src/pi/tool-adapter.ts +14 -4
- package/src/prompts/context-builder.ts +1 -1
- package/src/prompts/policy-cards.ts +1 -1
- package/src/providers/external-tool-error.ts +20 -0
- package/src/providers/reddit-cli.ts +317 -0
- package/src/providers/reddit.ts +7 -63
- package/src/providers/twitter-cli.ts +233 -0
- package/src/providers/twitter.ts +4 -73
- package/src/providers/wrap-provider.ts +34 -0
- package/src/providers/yahoo-finance.ts +65 -32
- package/src/routing/planning.ts +1 -0
- package/src/runtime/answer-contracts.ts +23 -2
- package/src/runtime/tool-defaults-wrapper.ts +12 -2
- package/src/sentiment/index.ts +1 -0
- package/src/sentiment/insights.ts +269 -0
- package/src/sentiment/pipeline.ts +13 -1
- package/src/sentiment/scorer.ts +12 -1
- package/src/sentiment/types.ts +3 -0
- package/src/system-prompt.ts +3 -7
- package/src/tools/index.ts +9 -8
- package/src/tools/sentiment/insight-format.ts +50 -0
- package/src/tools/sentiment/query-match.ts +117 -0
- package/src/tools/sentiment/reddit-sentiment.ts +354 -141
- package/src/tools/sentiment/sentiment-summary.ts +283 -237
- package/src/tools/sentiment/twitter-sentiment.ts +262 -78
- package/src/tools/sentiment/web-sentiment.ts +4 -0
- package/src/types/sentiment.ts +59 -0
- package/dist/infra/browser.d.ts +0 -35
- package/dist/infra/browser.js +0 -105
- package/dist/infra/browser.js.map +0 -1
- package/dist/tools/interaction/twitter-login.d.ts +0 -8
- package/dist/tools/interaction/twitter-login.js +0 -87
- package/dist/tools/interaction/twitter-login.js.map +0 -1
- package/gui/web/dist/assets/CatalogOverlay-eJ2cBk33.js +0 -1
- package/gui/web/dist/assets/index-CveNgtDg.js +0 -69
- package/src/infra/browser.ts +0 -113
- 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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
}
|
|
121
|
-
warnings.push(`
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
119
|
+
query: args.query,
|
|
120
|
+
subreddits: config.sentiment?.defaultSubreddits ?? [
|
|
121
|
+
"wallstreetbets",
|
|
122
|
+
"stocks",
|
|
123
|
+
"investing",
|
|
124
|
+
"options",
|
|
125
|
+
],
|
|
139
126
|
},
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|