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.
- package/assets/logo.svg +187 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +38 -2
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +9 -0
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/infra/browser.d.ts +10 -0
- package/dist/infra/browser.js +1 -0
- package/dist/infra/browser.js.map +1 -1
- package/dist/infra/native-dependencies.d.ts +1 -0
- package/dist/infra/native-dependencies.js +10 -0
- package/dist/infra/native-dependencies.js.map +1 -0
- package/dist/infra/node-version.d.ts +2 -0
- package/dist/infra/node-version.js +23 -0
- package/dist/infra/node-version.js.map +1 -0
- package/dist/memory/index.d.ts +2 -0
- package/dist/memory/index.js +1 -0
- package/dist/memory/index.js.map +1 -1
- package/dist/memory/sqlite.js +42 -4
- package/dist/memory/sqlite.js.map +1 -1
- package/dist/memory/storage.d.ts +6 -0
- package/dist/memory/storage.js +3 -3
- package/dist/memory/storage.js.map +1 -1
- package/dist/memory/tool-defaults.d.ts +8 -0
- package/dist/memory/tool-defaults.js +59 -0
- package/dist/memory/tool-defaults.js.map +1 -0
- package/dist/onboarding/connect.d.ts +13 -1
- package/dist/onboarding/connect.js +21 -10
- package/dist/onboarding/connect.js.map +1 -1
- package/dist/onboarding/prompt-user.d.ts +1 -1
- package/dist/onboarding/providers.d.ts +7 -0
- package/dist/onboarding/providers.js +6 -3
- package/dist/onboarding/providers.js.map +1 -1
- package/dist/onboarding/tool-helpers.d.ts +1 -1
- package/dist/pi/opencandle-extension.d.ts +7 -1
- package/dist/pi/opencandle-extension.js +186 -10
- package/dist/pi/opencandle-extension.js.map +1 -1
- package/dist/pi/session-storage.d.ts +2 -0
- package/dist/pi/session-storage.js +5 -0
- package/dist/pi/session-storage.js.map +1 -0
- package/dist/pi/session.d.ts +4 -1
- package/dist/pi/session.js +25 -3
- package/dist/pi/session.js.map +1 -1
- package/dist/pi/setup.d.ts +1 -1
- package/dist/pi/setup.js +1 -1
- package/dist/pi/setup.js.map +1 -1
- package/dist/pi/tool-adapter.d.ts +2 -2
- package/dist/pi/tool-adapter.js +14 -1
- package/dist/pi/tool-adapter.js.map +1 -1
- package/dist/prompts/context-builder.d.ts +22 -0
- package/dist/prompts/context-builder.js +45 -10
- package/dist/prompts/context-builder.js.map +1 -1
- package/dist/prompts/disclaimer.d.ts +6 -0
- package/dist/prompts/disclaimer.js +9 -0
- package/dist/prompts/disclaimer.js.map +1 -0
- package/dist/prompts/workflow-prompts.d.ts +8 -0
- package/dist/prompts/workflow-prompts.js +39 -5
- package/dist/prompts/workflow-prompts.js.map +1 -1
- package/dist/providers/yahoo-finance.js +70 -33
- package/dist/providers/yahoo-finance.js.map +1 -1
- package/dist/routing/defaults.js +1 -1
- package/dist/routing/defaults.js.map +1 -1
- package/dist/routing/index.d.ts +4 -0
- package/dist/routing/index.js +3 -0
- package/dist/routing/index.js.map +1 -1
- package/dist/routing/router-llm-client.d.ts +11 -0
- package/dist/routing/router-llm-client.js +42 -0
- package/dist/routing/router-llm-client.js.map +1 -0
- package/dist/routing/router-prompt.d.ts +2 -0
- package/dist/routing/router-prompt.js +138 -0
- package/dist/routing/router-prompt.js.map +1 -0
- package/dist/routing/router-types.d.ts +62 -0
- package/dist/routing/router-types.js +2 -0
- package/dist/routing/router-types.js.map +1 -0
- package/dist/routing/router.d.ts +10 -0
- package/dist/routing/router.js +194 -0
- package/dist/routing/router.js.map +1 -0
- package/dist/runtime/session-coordinator.d.ts +63 -3
- package/dist/runtime/session-coordinator.js +155 -4
- package/dist/runtime/session-coordinator.js.map +1 -1
- package/dist/runtime/tool-defaults-wrapper.d.ts +3 -0
- package/dist/runtime/tool-defaults-wrapper.js +25 -0
- package/dist/runtime/tool-defaults-wrapper.js.map +1 -0
- package/dist/sentiment/store.js +5 -0
- package/dist/sentiment/store.js.map +1 -1
- package/dist/system-prompt.js +20 -12
- package/dist/system-prompt.js.map +1 -1
- package/dist/tool-kit.d.ts +4 -4
- package/dist/tools/fundamentals/company-overview.d.ts +1 -1
- package/dist/tools/fundamentals/comps.d.ts +1 -1
- package/dist/tools/fundamentals/dcf.d.ts +1 -1
- package/dist/tools/fundamentals/earnings.d.ts +1 -1
- package/dist/tools/fundamentals/financials.d.ts +1 -1
- package/dist/tools/fundamentals/sec-filings.d.ts +1 -1
- package/dist/tools/index.d.ts +28 -1
- package/dist/tools/index.js +27 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/interaction/ask-user.d.ts +1 -1
- package/dist/tools/interaction/twitter-login.d.ts +1 -1
- package/dist/tools/macro/fear-greed.d.ts +1 -1
- package/dist/tools/macro/fred-data.d.ts +1 -1
- package/dist/tools/market/crypto-history.d.ts +1 -1
- package/dist/tools/market/crypto-price.d.ts +1 -1
- package/dist/tools/market/search-ticker.d.ts +1 -1
- package/dist/tools/market/stock-history.d.ts +1 -1
- package/dist/tools/market/stock-quote.d.ts +1 -1
- package/dist/tools/options/option-chain.d.ts +1 -1
- package/dist/tools/options/option-chain.js +4 -1
- package/dist/tools/options/option-chain.js.map +1 -1
- package/dist/tools/portfolio/correlation.d.ts +1 -1
- package/dist/tools/portfolio/predictions.d.ts +1 -1
- package/dist/tools/portfolio/risk-analysis.d.ts +1 -1
- package/dist/tools/portfolio/tracker.d.ts +1 -1
- package/dist/tools/portfolio/watchlist.d.ts +1 -1
- package/dist/tools/sentiment/reddit-sentiment.d.ts +1 -1
- package/dist/tools/sentiment/sentiment-summary.d.ts +1 -1
- package/dist/tools/sentiment/sentiment-trend.d.ts +1 -1
- package/dist/tools/sentiment/twitter-sentiment.d.ts +1 -1
- package/dist/tools/sentiment/web-search.d.ts +1 -1
- package/dist/tools/sentiment/web-sentiment.d.ts +1 -1
- package/dist/tools/technical/backtest.d.ts +1 -1
- package/dist/tools/technical/indicators.d.ts +1 -1
- package/dist/tools/technical/indicators.js +7 -1
- package/dist/tools/technical/indicators.js.map +1 -1
- package/dist/workflows/options-screener.js +7 -2
- package/dist/workflows/options-screener.js.map +1 -1
- package/dist/workflows/portfolio-builder.js +3 -3
- package/dist/workflows/portfolio-builder.js.map +1 -1
- package/gui/server/background-quotes.ts +31 -0
- package/gui/server/chat-event-adapter.ts +142 -0
- package/gui/server/invoke-tool.ts +89 -0
- package/gui/server/live-chat-event-adapter.ts +181 -0
- package/gui/server/model-setup.ts +100 -0
- package/gui/server/package.json +5 -0
- package/gui/server/projector.ts +212 -0
- package/gui/server/server.ts +592 -0
- package/gui/server/session-actions.ts +31 -0
- package/gui/server/tool-metadata.ts +88 -0
- package/gui/server/websocket.ts +128 -0
- package/gui/server/writer-lock.ts +118 -0
- package/gui/shared/chat-events.ts +118 -0
- package/gui/shared/event-reducer.ts +186 -0
- package/gui/web/dist/assets/CatalogOverlay-D1ImSJTe.js +1 -0
- package/gui/web/dist/assets/index-DBrWq43L.css +1 -0
- package/gui/web/dist/assets/index-RflHaj0y.js +67 -0
- package/gui/web/dist/assets/logo-CWpt6Y2a.svg +187 -0
- package/gui/web/dist/index.html +17 -0
- package/package.json +44 -18
- package/src/analysts/contracts.ts +189 -0
- package/src/analysts/orchestrator.ts +300 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +161 -0
- package/src/index.ts +5 -0
- package/src/infra/browser.ts +111 -0
- package/src/infra/cache.ts +103 -0
- package/src/infra/http-client.ts +68 -0
- package/src/infra/index.ts +18 -0
- package/src/infra/native-dependencies.ts +12 -0
- package/src/infra/node-version.ts +24 -0
- package/src/infra/open-url.ts +28 -0
- package/src/infra/opencandle-paths.ts +64 -0
- package/src/infra/rate-limiter.ts +64 -0
- package/src/memory/index.ts +10 -0
- package/src/memory/manager.ts +159 -0
- package/src/memory/preference-extractor.ts +106 -0
- package/src/memory/retrieval.ts +70 -0
- package/src/memory/sqlite.ts +172 -0
- package/src/memory/storage.ts +204 -0
- package/src/memory/tool-defaults.ts +87 -0
- package/src/memory/types.ts +67 -0
- package/src/onboarding/connect.ts +184 -0
- package/src/onboarding/credential-interceptor.ts +134 -0
- package/src/onboarding/degradation-accumulator.ts +79 -0
- package/src/onboarding/prompt-user.ts +85 -0
- package/src/onboarding/providers.ts +315 -0
- package/src/onboarding/state.ts +218 -0
- package/src/onboarding/tool-helpers.ts +111 -0
- package/src/onboarding/tool-tags.ts +201 -0
- package/src/onboarding/validation.ts +158 -0
- package/src/pi/opencandle-extension.ts +724 -0
- package/src/pi/session-storage.ts +5 -0
- package/src/pi/session.ts +81 -0
- package/src/pi/setup.ts +371 -0
- package/src/pi/tool-adapter.ts +36 -0
- package/src/prompts/context-builder.ts +204 -0
- package/src/prompts/disclaimer.ts +9 -0
- package/src/prompts/sections.ts +46 -0
- package/src/prompts/workflow-prompts.ts +279 -0
- package/src/providers/alpha-vantage.ts +292 -0
- package/src/providers/coingecko.ts +96 -0
- package/src/providers/exa-search.ts +373 -0
- package/src/providers/fear-greed.ts +45 -0
- package/src/providers/finnhub.ts +124 -0
- package/src/providers/fred.ts +83 -0
- package/src/providers/index.ts +9 -0
- package/src/providers/provider-credential-error.ts +23 -0
- package/src/providers/reddit.ts +151 -0
- package/src/providers/sec-edgar.ts +96 -0
- package/src/providers/twitter.ts +173 -0
- package/src/providers/web-search.ts +293 -0
- package/src/providers/with-fallback.ts +41 -0
- package/src/providers/wrap-provider.ts +64 -0
- package/src/providers/yahoo-finance.ts +367 -0
- package/src/routing/classify-intent.ts +194 -0
- package/src/routing/defaults.ts +29 -0
- package/src/routing/entity-extractor.ts +140 -0
- package/src/routing/index.ts +26 -0
- package/src/routing/router-llm-client.ts +51 -0
- package/src/routing/router-prompt.ts +159 -0
- package/src/routing/router-types.ts +66 -0
- package/src/routing/router.ts +213 -0
- package/src/routing/slot-resolver.ts +152 -0
- package/src/routing/types.ts +63 -0
- package/src/runtime/evidence.ts +77 -0
- package/src/runtime/index.ts +55 -0
- package/src/runtime/prompt-step.ts +75 -0
- package/src/runtime/provider-ids.ts +15 -0
- package/src/runtime/provider-tracker.ts +40 -0
- package/src/runtime/run-context.ts +22 -0
- package/src/runtime/session-coordinator.ts +406 -0
- package/src/runtime/tool-defaults-wrapper.ts +35 -0
- package/src/runtime/validation.ts +214 -0
- package/src/runtime/workflow-events.ts +75 -0
- package/src/runtime/workflow-runner.ts +188 -0
- package/src/runtime/workflow-types.ts +102 -0
- package/src/sentiment/adapters/finnhub.ts +44 -0
- package/src/sentiment/adapters/reddit.ts +65 -0
- package/src/sentiment/adapters/twitter.ts +36 -0
- package/src/sentiment/adapters/web.ts +44 -0
- package/src/sentiment/index.ts +58 -0
- package/src/sentiment/keywords.ts +9 -0
- package/src/sentiment/pipeline.ts +68 -0
- package/src/sentiment/scorer.ts +78 -0
- package/src/sentiment/store.ts +260 -0
- package/src/sentiment/trends.ts +90 -0
- package/src/sentiment/types.ts +108 -0
- package/src/system-prompt.ts +115 -0
- package/src/tool-kit.ts +68 -0
- package/src/tools/AGENTS.md +36 -0
- package/src/tools/fundamentals/company-overview.ts +54 -0
- package/src/tools/fundamentals/comps.ts +156 -0
- package/src/tools/fundamentals/dcf.ts +267 -0
- package/src/tools/fundamentals/earnings.ts +47 -0
- package/src/tools/fundamentals/financials.ts +54 -0
- package/src/tools/fundamentals/sec-filings.ts +61 -0
- package/src/tools/index.ts +88 -0
- package/src/tools/interaction/ask-user.ts +81 -0
- package/src/tools/interaction/twitter-login.ts +93 -0
- package/src/tools/macro/fear-greed.ts +41 -0
- package/src/tools/macro/fred-data.ts +54 -0
- package/src/tools/market/crypto-history.ts +51 -0
- package/src/tools/market/crypto-price.ts +53 -0
- package/src/tools/market/search-ticker.ts +53 -0
- package/src/tools/market/stock-history.ts +79 -0
- package/src/tools/market/stock-quote.ts +64 -0
- package/src/tools/options/greeks.ts +82 -0
- package/src/tools/options/option-chain.ts +91 -0
- package/src/tools/portfolio/correlation.ts +162 -0
- package/src/tools/portfolio/predictions.ts +253 -0
- package/src/tools/portfolio/risk-analysis.ts +134 -0
- package/src/tools/portfolio/tracker.ts +147 -0
- package/src/tools/portfolio/watchlist.ts +153 -0
- package/src/tools/sentiment/reddit-sentiment.ts +164 -0
- package/src/tools/sentiment/sentiment-summary.ts +256 -0
- package/src/tools/sentiment/sentiment-trend.ts +58 -0
- package/src/tools/sentiment/twitter-sentiment.ts +96 -0
- package/src/tools/sentiment/web-search.ts +150 -0
- package/src/tools/sentiment/web-sentiment.ts +76 -0
- package/src/tools/technical/backtest.ts +246 -0
- package/src/tools/technical/indicators.ts +258 -0
- package/src/types/fundamentals.ts +46 -0
- package/src/types/index.ts +20 -0
- package/src/types/macro.ts +27 -0
- package/src/types/market.ts +43 -0
- package/src/types/options.ts +35 -0
- package/src/types/portfolio.ts +41 -0
- package/src/types/sentiment.ts +70 -0
- package/src/workflows/compare-assets.ts +39 -0
- package/src/workflows/index.ts +4 -0
- package/src/workflows/options-screener.ts +49 -0
- package/src/workflows/portfolio-builder.ts +52 -0
- 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
|
+
};
|