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