opencandle 0.6.0 → 0.8.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 +43 -23
- package/dist/cli-main.d.ts +1 -0
- package/dist/cli-main.js +200 -0
- package/dist/cli-main.js.map +1 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +14 -194
- 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/doctor/cli-command.d.ts +1 -0
- package/dist/doctor/cli-command.js +69 -0
- package/dist/doctor/cli-command.js.map +1 -0
- package/dist/doctor/render.d.ts +2 -0
- package/dist/doctor/render.js +32 -0
- package/dist/doctor/render.js.map +1 -0
- package/dist/doctor/report.d.ts +63 -0
- package/dist/doctor/report.js +500 -0
- package/dist/doctor/report.js.map +1 -0
- 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/infra/native-dependencies.d.ts +8 -0
- package/dist/infra/native-dependencies.js +49 -0
- package/dist/infra/native-dependencies.js.map +1 -1
- package/dist/infra/node-version.js +2 -5
- package/dist/infra/node-version.js.map +1 -1
- package/dist/monitor.d.ts +1 -1
- package/dist/monitor.js +2 -1
- package/dist/monitor.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 +46 -0
- package/dist/onboarding/provider-status.js +256 -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/session.d.ts +0 -1
- package/dist/pi/session.js +2 -1
- package/dist/pi/session.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 +9 -5
- package/dist/prompts/policy-cards.js.map +1 -1
- package/dist/prompts/workflow-prompts.js +10 -6
- package/dist/prompts/workflow-prompts.js.map +1 -1
- package/dist/providers/external-tool-command.d.ts +17 -0
- package/dist/providers/external-tool-command.js +114 -0
- package/dist/providers/external-tool-command.js.map +1 -0
- 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 +32 -0
- package/dist/providers/reddit-cli.js +172 -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 +36 -0
- package/dist/providers/twitter-cli.js +124 -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/classify-intent.js +7 -3
- package/dist/routing/classify-intent.js.map +1 -1
- package/dist/routing/planning.d.ts +1 -1
- package/dist/routing/planning.js +9 -6
- package/dist/routing/planning.js.map +1 -1
- package/dist/routing/router.js +7 -3
- package/dist/routing/router.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/http-routes.ts +419 -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 +30 -275
- package/gui/server/tool-metadata.ts +80 -16
- package/gui/server/ws-hub.ts +19 -0
- package/gui/web/dist/assets/CatalogOverlay-CCVKwBUB.js +1 -0
- package/gui/web/dist/assets/index-Dm4Aom2_.js +69 -0
- package/gui/web/dist/assets/{index-2KZtKBmu.css → index-UzZUg3dx.css} +1 -1
- package/gui/web/dist/index.html +2 -2
- package/package.json +17 -14
- package/src/cli-main.ts +233 -0
- package/src/cli.ts +14 -226
- package/src/config.ts +27 -0
- package/src/doctor/cli-command.ts +83 -0
- package/src/doctor/render.ts +37 -0
- package/src/doctor/report.ts +638 -0
- package/src/infra/index.ts +0 -1
- package/src/infra/native-dependencies.ts +67 -0
- package/src/infra/node-version.ts +2 -5
- package/src/monitor.ts +3 -1
- package/src/onboarding/connect.ts +20 -4
- package/src/onboarding/provider-status.ts +382 -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/session.ts +2 -1
- package/src/pi/tool-adapter.ts +14 -4
- package/src/prompts/context-builder.ts +1 -1
- package/src/prompts/policy-cards.ts +13 -5
- package/src/prompts/workflow-prompts.ts +13 -6
- package/src/providers/external-tool-command.ts +164 -0
- package/src/providers/external-tool-error.ts +20 -0
- package/src/providers/reddit-cli.ts +286 -0
- package/src/providers/reddit.ts +7 -63
- package/src/providers/twitter-cli.ts +202 -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/classify-intent.ts +11 -6
- package/src/routing/planning.ts +16 -10
- package/src/routing/router.ts +11 -6
- 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
package/src/providers/reddit.ts
CHANGED
|
@@ -1,53 +1,23 @@
|
|
|
1
1
|
import { cache, STALE_LIMIT, TTL } from "../infra/cache.js";
|
|
2
|
-
import { httpGet } from "../infra/http-client.js";
|
|
3
2
|
import { rateLimiter } from "../infra/rate-limiter.js";
|
|
4
3
|
import { BEARISH_TERMS, BULLISH_TERMS } from "../sentiment/keywords.js";
|
|
5
4
|
import type { RedditSentimentResult } from "../types/sentiment.js";
|
|
6
|
-
|
|
7
|
-
interface RedditListingResponse {
|
|
8
|
-
data: {
|
|
9
|
-
children: Array<{
|
|
10
|
-
data: {
|
|
11
|
-
id: string;
|
|
12
|
-
title: string;
|
|
13
|
-
selftext: string;
|
|
14
|
-
author: string;
|
|
15
|
-
score: number;
|
|
16
|
-
num_comments: number;
|
|
17
|
-
permalink: string;
|
|
18
|
-
created_utc: number;
|
|
19
|
-
};
|
|
20
|
-
}>;
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const REDDIT_HEADERS = { "User-Agent": "OpenCandle/1.0 (financial analysis agent)" };
|
|
5
|
+
import { listSubredditPosts, readRedditPost, searchRedditPosts } from "./reddit-cli.js";
|
|
25
6
|
|
|
26
7
|
export async function getSubredditPosts(
|
|
27
8
|
subreddit: string,
|
|
28
9
|
limit: number = 25,
|
|
10
|
+
query?: string,
|
|
29
11
|
): Promise<RedditSentimentResult> {
|
|
30
|
-
const cacheKey = `reddit:${subreddit}:${limit}`;
|
|
12
|
+
const cacheKey = `reddit:${subreddit}:${query ?? "hot"}:${limit}`;
|
|
31
13
|
const cached = cache.get<RedditSentimentResult>(cacheKey);
|
|
32
14
|
if (cached) return cached;
|
|
33
15
|
|
|
34
16
|
try {
|
|
35
17
|
await rateLimiter.acquire("reddit");
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
const posts = data.data.children.map((child) => ({
|
|
42
|
-
id: child.data.id,
|
|
43
|
-
title: child.data.title,
|
|
44
|
-
selftext: child.data.selftext ?? "",
|
|
45
|
-
author: child.data.author ?? "unknown",
|
|
46
|
-
score: child.data.score,
|
|
47
|
-
comments: child.data.num_comments,
|
|
48
|
-
url: `https://reddit.com${child.data.permalink}`,
|
|
49
|
-
created: new Date(child.data.created_utc * 1000).toISOString(),
|
|
50
|
-
}));
|
|
18
|
+
const posts = query
|
|
19
|
+
? await searchRedditPosts(query, { subreddit, limit })
|
|
20
|
+
: await listSubredditPosts(subreddit, { limit });
|
|
51
21
|
|
|
52
22
|
// Extract ticker-like mentions ($AAPL, $TSLA, etc.)
|
|
53
23
|
const tickerRegex = /\$([A-Z]{1,5})\b/g;
|
|
@@ -107,33 +77,7 @@ export async function getPostComments(
|
|
|
107
77
|
if (cached) return cached;
|
|
108
78
|
|
|
109
79
|
await rateLimiter.acquire("reddit_comments");
|
|
110
|
-
const
|
|
111
|
-
const data = await httpGet<
|
|
112
|
-
Array<{
|
|
113
|
-
data: {
|
|
114
|
-
children: Array<{
|
|
115
|
-
kind: string;
|
|
116
|
-
data: { id: string; body?: string; author?: string; score?: number; permalink?: string };
|
|
117
|
-
}>;
|
|
118
|
-
};
|
|
119
|
-
}>
|
|
120
|
-
>(url, {
|
|
121
|
-
headers: REDDIT_HEADERS,
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// Comments are in the second listing element
|
|
125
|
-
const commentListing = data[1]?.data?.children ?? [];
|
|
126
|
-
const comments: RedditComment[] = commentListing
|
|
127
|
-
.filter((c) => c.kind === "t1" && c.data.body)
|
|
128
|
-
.sort((a, b) => (b.data.score ?? 0) - (a.data.score ?? 0))
|
|
129
|
-
.slice(0, limit)
|
|
130
|
-
.map((c) => ({
|
|
131
|
-
id: c.data.id,
|
|
132
|
-
body: c.data.body!,
|
|
133
|
-
author: c.data.author ?? "unknown",
|
|
134
|
-
score: c.data.score ?? 0,
|
|
135
|
-
permalink: `https://reddit.com${c.data.permalink ?? ""}`,
|
|
136
|
-
}));
|
|
80
|
+
const { comments } = await readRedditPost(postId, { limit });
|
|
137
81
|
|
|
138
82
|
cache.set(cacheKey, comments, COMMENT_TTL);
|
|
139
83
|
return comments;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { TwitterTweet } from "../types/sentiment.js";
|
|
2
|
+
import { type ExternalToolCommandResult, runExternalToolCommand } from "./external-tool-command.js";
|
|
3
|
+
import { ExternalToolError, ExternalToolNotInstalled } from "./external-tool-error.js";
|
|
4
|
+
|
|
5
|
+
const TWITTER_CLI_BINARY = "twitter";
|
|
6
|
+
const TWITTER_CLI_TOOL_NAME = "twitter-cli";
|
|
7
|
+
const TWITTER_CLI_INSTALL_CMD = "uv tool install twitter-cli";
|
|
8
|
+
const COMMAND_TIMEOUT_MS = 20_000;
|
|
9
|
+
const MAX_OUTPUT_CHARS = 2_000_000;
|
|
10
|
+
|
|
11
|
+
export interface RawTweet {
|
|
12
|
+
readonly id?: string;
|
|
13
|
+
readonly text?: string;
|
|
14
|
+
readonly author?: {
|
|
15
|
+
readonly username?: string;
|
|
16
|
+
readonly screenName?: string;
|
|
17
|
+
readonly name?: string;
|
|
18
|
+
};
|
|
19
|
+
readonly username?: string;
|
|
20
|
+
readonly url?: string;
|
|
21
|
+
readonly permanentUrl?: string;
|
|
22
|
+
readonly createdAt?: string | number;
|
|
23
|
+
readonly created_at?: string | number;
|
|
24
|
+
readonly likeCount?: number;
|
|
25
|
+
readonly likes?: number;
|
|
26
|
+
readonly retweetCount?: number;
|
|
27
|
+
readonly retweets?: number;
|
|
28
|
+
readonly replyCount?: number;
|
|
29
|
+
readonly replies?: number;
|
|
30
|
+
readonly viewCount?: number | null;
|
|
31
|
+
readonly views?: number | null;
|
|
32
|
+
readonly metrics?: {
|
|
33
|
+
readonly likes?: number;
|
|
34
|
+
readonly retweets?: number;
|
|
35
|
+
readonly replies?: number;
|
|
36
|
+
readonly views?: number | null;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface TwitterCliEnvelope<T> {
|
|
41
|
+
readonly ok: boolean;
|
|
42
|
+
readonly schema_version: string;
|
|
43
|
+
readonly data: T;
|
|
44
|
+
readonly error?: {
|
|
45
|
+
readonly code?: string;
|
|
46
|
+
readonly message?: string;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type TwitterCliCommandRunner = (
|
|
51
|
+
command: string,
|
|
52
|
+
args: readonly string[],
|
|
53
|
+
) => Promise<ExternalToolCommandResult>;
|
|
54
|
+
|
|
55
|
+
let commandRunner: TwitterCliCommandRunner = runCommand;
|
|
56
|
+
|
|
57
|
+
export function setTwitterCliCommandRunnerForTests(runner: TwitterCliCommandRunner): void {
|
|
58
|
+
commandRunner = runner;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resetTwitterCliCommandRunnerForTests(): void {
|
|
62
|
+
commandRunner = runCommand;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function searchTweets(query: string, max = 20): Promise<TwitterTweet[]> {
|
|
66
|
+
const envelope = await runTwitterCli<TwitterCliEnvelope<RawTweet[]>>([
|
|
67
|
+
"search",
|
|
68
|
+
query,
|
|
69
|
+
"--max",
|
|
70
|
+
String(max),
|
|
71
|
+
"-t",
|
|
72
|
+
"Latest",
|
|
73
|
+
"--json",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
if (!envelope.ok) {
|
|
77
|
+
throw new ExternalToolError(
|
|
78
|
+
TWITTER_CLI_TOOL_NAME,
|
|
79
|
+
redactSensitiveOutput(envelope.error?.message ?? "twitter-cli returned an error"),
|
|
80
|
+
envelope.error?.code,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(envelope.data)) {
|
|
84
|
+
throw new ExternalToolError(TWITTER_CLI_TOOL_NAME, "twitter-cli returned invalid tweet data");
|
|
85
|
+
}
|
|
86
|
+
return envelope.data.map(adaptRawTweet);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runTwitterCli<T>(args: readonly string[]): Promise<T> {
|
|
90
|
+
let result: ExternalToolCommandResult;
|
|
91
|
+
try {
|
|
92
|
+
result = await commandRunner(TWITTER_CLI_BINARY, args);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
const nodeError = err as NodeJS.ErrnoException;
|
|
95
|
+
if (nodeError.code === "ENOENT") {
|
|
96
|
+
throw new ExternalToolNotInstalled(TWITTER_CLI_TOOL_NAME, TWITTER_CLI_INSTALL_CMD);
|
|
97
|
+
}
|
|
98
|
+
throw new ExternalToolError(
|
|
99
|
+
TWITTER_CLI_TOOL_NAME,
|
|
100
|
+
redactSensitiveOutput(err instanceof Error ? err.message : String(err)),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (result.code !== 0) {
|
|
105
|
+
const envelopeError = parseCliErrorEnvelope(result.stdout);
|
|
106
|
+
if (envelopeError) {
|
|
107
|
+
throw new ExternalToolError(
|
|
108
|
+
TWITTER_CLI_TOOL_NAME,
|
|
109
|
+
redactSensitiveOutput(envelopeError.message),
|
|
110
|
+
envelopeError.code,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
throw new ExternalToolError(
|
|
114
|
+
TWITTER_CLI_TOOL_NAME,
|
|
115
|
+
redactSensitiveOutput(result.stderr.trim() || `twitter-cli exited with code ${result.code}`),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(result.stdout) as T;
|
|
121
|
+
} catch {
|
|
122
|
+
throw new ExternalToolError(
|
|
123
|
+
TWITTER_CLI_TOOL_NAME,
|
|
124
|
+
`twitter-cli returned non-JSON output: ${redactSensitiveOutput(result.stdout.slice(0, 200))}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseCliErrorEnvelope(stdout: string): { code?: string; message: string } | null {
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(stdout) as {
|
|
132
|
+
ok?: unknown;
|
|
133
|
+
error?: { code?: unknown; message?: unknown };
|
|
134
|
+
};
|
|
135
|
+
if (parsed.ok !== false || typeof parsed.error?.message !== "string") return null;
|
|
136
|
+
return {
|
|
137
|
+
code: typeof parsed.error.code === "string" ? parsed.error.code : undefined,
|
|
138
|
+
message: parsed.error.message,
|
|
139
|
+
};
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function adaptRawTweet(raw: RawTweet): TwitterTweet {
|
|
146
|
+
return {
|
|
147
|
+
id: stringValue(raw.id),
|
|
148
|
+
text: stringValue(raw.text).slice(0, 280),
|
|
149
|
+
author:
|
|
150
|
+
stringValue(raw.author?.username) ||
|
|
151
|
+
stringValue(raw.author?.screenName) ||
|
|
152
|
+
stringValue(raw.username) ||
|
|
153
|
+
"unknown",
|
|
154
|
+
likes: numberValue(raw.metrics?.likes ?? raw.likeCount ?? raw.likes),
|
|
155
|
+
retweets: numberValue(raw.metrics?.retweets ?? raw.retweetCount ?? raw.retweets),
|
|
156
|
+
replies: numberValue(raw.metrics?.replies ?? raw.replyCount ?? raw.replies),
|
|
157
|
+
views: nullableNumberValue(raw.metrics?.views ?? raw.viewCount ?? raw.views),
|
|
158
|
+
url: stringValue(raw.url ?? raw.permanentUrl),
|
|
159
|
+
created: normalizeCreatedAt(raw.createdAt ?? raw.created_at),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizeCreatedAt(value: string | number | undefined): string {
|
|
164
|
+
if (typeof value === "number") {
|
|
165
|
+
const millis = value > 1_000_000_000_000 ? value : value * 1000;
|
|
166
|
+
return new Date(millis).toISOString();
|
|
167
|
+
}
|
|
168
|
+
if (typeof value === "string" && value.length > 0) {
|
|
169
|
+
const millis = Date.parse(value);
|
|
170
|
+
if (!Number.isNaN(millis)) return new Date(millis).toISOString();
|
|
171
|
+
}
|
|
172
|
+
return new Date(0).toISOString();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function stringValue(value: unknown): string {
|
|
176
|
+
return typeof value === "string" ? value : "";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function numberValue(value: unknown): number {
|
|
180
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function nullableNumberValue(value: unknown): number | null {
|
|
184
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function redactSensitiveOutput(input: string): string {
|
|
188
|
+
const xCookieNames =
|
|
189
|
+
"auth_token|ct0|twid|kdt|guest_id|guest_id_ads|guest_id_marketing|personalization_id";
|
|
190
|
+
return input
|
|
191
|
+
.slice(0, MAX_OUTPUT_CHARS)
|
|
192
|
+
.replace(/\b(cookie|set-cookie)\s*:\s*[^\r\n]+/gi, "$1: [redacted]")
|
|
193
|
+
.replace(new RegExp(`\\b(${xCookieNames})\\b\\s*[:=]\\s*[^;\\s,)]+`, "gi"), "$1=[redacted]")
|
|
194
|
+
.replace(new RegExp(`\\b(${xCookieNames})=([^;\\s,)]+)`, "gi"), "$1=[redacted]");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function runCommand(command: string, args: readonly string[]): Promise<ExternalToolCommandResult> {
|
|
198
|
+
return runExternalToolCommand(command, args, {
|
|
199
|
+
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
200
|
+
maxOutputChars: MAX_OUTPUT_CHARS,
|
|
201
|
+
});
|
|
202
|
+
}
|
package/src/providers/twitter.ts
CHANGED
|
@@ -1,37 +1,7 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { Scraper, SearchMode } from "@the-convocation/twitter-scraper";
|
|
4
|
-
import Database from "better-sqlite3";
|
|
5
1
|
import { cache, STALE_LIMIT, TTL } from "../infra/cache.js";
|
|
6
|
-
import { getBrowserProfileDir } from "../infra/opencandle-paths.js";
|
|
7
2
|
import { rateLimiter } from "../infra/rate-limiter.js";
|
|
8
3
|
import type { TwitterSentimentResult, TwitterTweet } from "../types/sentiment.js";
|
|
9
|
-
|
|
10
|
-
// ── Cookie extraction ────────────────────────────────────
|
|
11
|
-
|
|
12
|
-
interface FirefoxCookie {
|
|
13
|
-
name: string;
|
|
14
|
-
value: string;
|
|
15
|
-
domain: string;
|
|
16
|
-
path: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function readTwitterCookies(profileDir: string): FirefoxCookie[] {
|
|
20
|
-
const dbPath = join(profileDir, "cookies.sqlite");
|
|
21
|
-
if (!existsSync(dbPath)) return [];
|
|
22
|
-
|
|
23
|
-
const db = new Database(dbPath, { readonly: true });
|
|
24
|
-
try {
|
|
25
|
-
const rows = db
|
|
26
|
-
.prepare(
|
|
27
|
-
`SELECT name, value, host AS domain, path FROM moz_cookies WHERE host LIKE ? OR host LIKE ?`,
|
|
28
|
-
)
|
|
29
|
-
.all("%x.com%", "%twitter.com%") as FirefoxCookie[];
|
|
30
|
-
return rows;
|
|
31
|
-
} finally {
|
|
32
|
-
db.close();
|
|
33
|
-
}
|
|
34
|
-
}
|
|
4
|
+
import { searchTweets } from "./twitter-cli.js";
|
|
35
5
|
|
|
36
6
|
// ── Sentiment scoring ────────────────────────────────────
|
|
37
7
|
|
|
@@ -87,49 +57,10 @@ export async function getTwitterSentiment(
|
|
|
87
57
|
await rateLimiter.acquire("twitter");
|
|
88
58
|
|
|
89
59
|
try {
|
|
90
|
-
const profileDir = getBrowserProfileDir();
|
|
91
|
-
const cookies = readTwitterCookies(profileDir);
|
|
92
|
-
|
|
93
|
-
const authToken = cookies.find((c) => c.name === "auth_token");
|
|
94
|
-
const ct0 = cookies.find((c) => c.name === "ct0");
|
|
95
|
-
|
|
96
|
-
if (!authToken || !ct0) {
|
|
97
|
-
throw new Error("No Twitter session found.");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const scraper = new Scraper();
|
|
101
|
-
const cookieStrings = cookies.map(
|
|
102
|
-
(c) => `${c.name}=${c.value}; Domain=${c.domain}; Path=${c.path}`,
|
|
103
|
-
);
|
|
104
|
-
await scraper.setCookies(cookieStrings);
|
|
105
|
-
|
|
106
|
-
const loggedIn = await scraper.isLoggedIn();
|
|
107
|
-
if (!loggedIn) {
|
|
108
|
-
throw new Error("Twitter session expired.");
|
|
109
|
-
}
|
|
110
|
-
|
|
111
60
|
const cutoff = new Date(Date.now() - hours * 3_600_000);
|
|
112
|
-
const tweets: TwitterTweet[] =
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
for await (const tweet of results) {
|
|
116
|
-
const created = tweet.timeParsed ?? new Date(0);
|
|
117
|
-
if (created < cutoff) continue;
|
|
118
|
-
|
|
119
|
-
tweets.push({
|
|
120
|
-
id: tweet.id ?? "",
|
|
121
|
-
text: tweet.text?.slice(0, 280) ?? "",
|
|
122
|
-
author: tweet.username ?? "unknown",
|
|
123
|
-
likes: tweet.likes ?? 0,
|
|
124
|
-
retweets: tweet.retweets ?? 0,
|
|
125
|
-
replies: tweet.replies ?? 0,
|
|
126
|
-
views: tweet.views ?? null,
|
|
127
|
-
url: tweet.permanentUrl ?? "",
|
|
128
|
-
created: created.toISOString(),
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
if (tweets.length >= limit) break;
|
|
132
|
-
}
|
|
61
|
+
const tweets: TwitterTweet[] = (await searchTweets(normalizedQuery, limit))
|
|
62
|
+
.filter((tweet) => new Date(tweet.created) >= cutoff)
|
|
63
|
+
.slice(0, limit);
|
|
133
64
|
|
|
134
65
|
// Extract co-mentioned cashtags
|
|
135
66
|
const tickerRegex = /\$([A-Z]{1,5})\b/g;
|
|
@@ -2,6 +2,7 @@ import { runWithStaleMetadata } from "../infra/cache.js";
|
|
|
2
2
|
import type { ProviderResult } from "../runtime/evidence.js";
|
|
3
3
|
import { getProviderTracker } from "../runtime/run-context.js";
|
|
4
4
|
import { InvalidSymbolError } from "./errors.js";
|
|
5
|
+
import { ExternalToolError, ExternalToolNotInstalled } from "./external-tool-error.js";
|
|
5
6
|
import { ProviderCredentialError } from "./provider-credential-error.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -51,6 +52,13 @@ export async function wrapProvider<T>(
|
|
|
51
52
|
if (error instanceof ProviderCredentialError) {
|
|
52
53
|
throw error;
|
|
53
54
|
}
|
|
55
|
+
if (isExternalToolSetupError(error)) {
|
|
56
|
+
return {
|
|
57
|
+
status: "unavailable",
|
|
58
|
+
reason: error.message,
|
|
59
|
+
provider: providerId,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
54
62
|
if (error instanceof InvalidSymbolError) {
|
|
55
63
|
return {
|
|
56
64
|
status: "unavailable",
|
|
@@ -67,3 +75,29 @@ export async function wrapProvider<T>(
|
|
|
67
75
|
};
|
|
68
76
|
}
|
|
69
77
|
}
|
|
78
|
+
|
|
79
|
+
function isExternalToolSetupError(
|
|
80
|
+
error: unknown,
|
|
81
|
+
): error is ExternalToolError | ExternalToolNotInstalled {
|
|
82
|
+
if (error instanceof ExternalToolNotInstalled) return true;
|
|
83
|
+
if (!(error instanceof ExternalToolError)) return false;
|
|
84
|
+
|
|
85
|
+
const code = error.code?.toLowerCase() ?? "";
|
|
86
|
+
const message = error.message.toLowerCase();
|
|
87
|
+
return (
|
|
88
|
+
code.includes("auth") ||
|
|
89
|
+
code.includes("session") ||
|
|
90
|
+
code.includes("unauthorized") ||
|
|
91
|
+
code.includes("expired") ||
|
|
92
|
+
message.includes("no twitter cookies") ||
|
|
93
|
+
message.includes("no twitter session") ||
|
|
94
|
+
message.includes("no reddit cookies") ||
|
|
95
|
+
message.includes("session missing") ||
|
|
96
|
+
message.includes("not authenticated") ||
|
|
97
|
+
message.includes("no cookies") ||
|
|
98
|
+
message.includes("rdt login") ||
|
|
99
|
+
message.includes("401") ||
|
|
100
|
+
message.includes("unauthorized") ||
|
|
101
|
+
message.includes("expired")
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import YahooFinance from "yahoo-finance2";
|
|
2
|
+
import type { OptionsResult as YahooFinance2OptionsResult } from "yahoo-finance2/modules/options";
|
|
2
3
|
import { cache, STALE_LIMIT, TTL } from "../infra/cache.js";
|
|
3
4
|
import { HttpError, httpGet } from "../infra/http-client.js";
|
|
4
5
|
import { rateLimiter } from "../infra/rate-limiter.js";
|
|
@@ -17,6 +18,15 @@ const BASE_URL = "https://query1.finance.yahoo.com/v8/finance/chart";
|
|
|
17
18
|
const QUOTE_SUMMARY_URL = "https://query1.finance.yahoo.com/v10/finance/quoteSummary";
|
|
18
19
|
const STALE_QUOTE_MAX_RETRY_AFTER_MS = 1_000;
|
|
19
20
|
|
|
21
|
+
let yahooFinance2Client: InstanceType<typeof YahooFinance> | undefined;
|
|
22
|
+
|
|
23
|
+
function getYahooFinance2Client(): InstanceType<typeof YahooFinance> {
|
|
24
|
+
yahooFinance2Client ??= new YahooFinance({
|
|
25
|
+
suppressNotices: ["yahooSurvey", "ripHistorical"],
|
|
26
|
+
});
|
|
27
|
+
return yahooFinance2Client;
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
type YahooNumber = number | { raw?: number; fmt?: string };
|
|
21
31
|
|
|
22
32
|
interface YahooChartResponse {
|
|
@@ -410,9 +420,9 @@ export async function getOptionsChain(symbol: string, expiration?: number): Prom
|
|
|
410
420
|
if (!res?.ok) {
|
|
411
421
|
let browserError: unknown;
|
|
412
422
|
try {
|
|
413
|
-
const
|
|
414
|
-
if (
|
|
415
|
-
const chain = parseOptionsResponse(
|
|
423
|
+
const fallbackData = await fetchOptionsViaYahooFinance2(symbol, expiration);
|
|
424
|
+
if (fallbackData) {
|
|
425
|
+
const chain = parseOptionsResponse(fallbackData);
|
|
416
426
|
cache.set(cacheKey, chain, TTL.OPTIONS_CHAIN);
|
|
417
427
|
return chain;
|
|
418
428
|
}
|
|
@@ -425,14 +435,14 @@ export async function getOptionsChain(symbol: string, expiration?: number): Prom
|
|
|
425
435
|
if (res) {
|
|
426
436
|
const message = `Yahoo Finance options: HTTP ${res.status}`;
|
|
427
437
|
if (browserError instanceof Error) {
|
|
428
|
-
throw new Error(`${message};
|
|
438
|
+
throw new Error(`${message}; yahoo-finance2 fallback failed: ${browserError.message}`);
|
|
429
439
|
}
|
|
430
440
|
throw new Error(message);
|
|
431
441
|
}
|
|
432
442
|
if (browserError instanceof Error) {
|
|
433
443
|
const message =
|
|
434
444
|
fetchError instanceof Error ? fetchError.message : "Yahoo Finance options: fetch failed";
|
|
435
|
-
throw new Error(`${message};
|
|
445
|
+
throw new Error(`${message}; yahoo-finance2 fallback failed: ${browserError.message}`);
|
|
436
446
|
}
|
|
437
447
|
throw fetchError instanceof Error
|
|
438
448
|
? fetchError
|
|
@@ -602,38 +612,61 @@ function parseOptionsResponse(data: YahooOptionsResponse): OptionsChain {
|
|
|
602
612
|
}
|
|
603
613
|
|
|
604
614
|
/**
|
|
605
|
-
* Fallback: fetch options data via
|
|
606
|
-
* Bypasses Yahoo's TLS fingerprinting and rate limiting.
|
|
615
|
+
* Fallback: fetch options data via yahoo-finance2 when the raw Yahoo path is blocked.
|
|
607
616
|
*/
|
|
608
|
-
async function
|
|
617
|
+
async function fetchOptionsViaYahooFinance2(
|
|
609
618
|
symbol: string,
|
|
610
619
|
expiration?: number,
|
|
611
620
|
): Promise<YahooOptionsResponse | null> {
|
|
612
621
|
try {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
return await StealthBrowser.run(async (page) => {
|
|
619
|
-
await page.goto("https://query2.finance.yahoo.com/v1/test/getcrumb", {
|
|
620
|
-
waitUntil: "domcontentloaded",
|
|
621
|
-
timeout: 15000,
|
|
622
|
-
});
|
|
623
|
-
const crumb = (await page.locator("body").innerText()).trim();
|
|
624
|
-
if (!crumb) return null;
|
|
625
|
-
|
|
626
|
-
const url = `https://query1.finance.yahoo.com/v7/finance/options/${encodeURIComponent(symbol)}?crumb=${encodeURIComponent(crumb)}${dateParam}`;
|
|
627
|
-
const response = await page.goto(url, {
|
|
628
|
-
waitUntil: "domcontentloaded",
|
|
629
|
-
timeout: 15000,
|
|
630
|
-
});
|
|
631
|
-
if (!response?.ok()) return null;
|
|
632
|
-
|
|
633
|
-
const text = (await page.locator("body").innerText()).trim();
|
|
634
|
-
return JSON.parse(text) as YahooOptionsResponse;
|
|
635
|
-
});
|
|
622
|
+
const result = await getYahooFinance2Client().options(
|
|
623
|
+
symbol,
|
|
624
|
+
expiration ? { date: new Date(expiration * 1000) } : undefined,
|
|
625
|
+
);
|
|
626
|
+
return normalizeYahooFinance2OptionsResponse(result);
|
|
636
627
|
} catch (error) {
|
|
637
628
|
throw error instanceof Error ? error : new Error(String(error));
|
|
638
629
|
}
|
|
639
630
|
}
|
|
631
|
+
|
|
632
|
+
function normalizeYahooFinance2OptionsResponse(
|
|
633
|
+
data: YahooFinance2OptionsResult | YahooOptionsResponse,
|
|
634
|
+
): YahooOptionsResponse {
|
|
635
|
+
if ("optionChain" in data) return data as YahooOptionsResponse;
|
|
636
|
+
|
|
637
|
+
const options = data.options.map((option) => ({
|
|
638
|
+
expirationDate: toYahooUnixSeconds(option.expirationDate),
|
|
639
|
+
calls: option.calls,
|
|
640
|
+
puts: option.puts,
|
|
641
|
+
}));
|
|
642
|
+
const strikes = [
|
|
643
|
+
...new Set(
|
|
644
|
+
options
|
|
645
|
+
.flatMap((option) => [...option.calls, ...option.puts])
|
|
646
|
+
.map((contract) => Number((contract as { strike?: unknown }).strike))
|
|
647
|
+
.filter((strike) => Number.isFinite(strike)),
|
|
648
|
+
),
|
|
649
|
+
].sort((a, b) => a - b);
|
|
650
|
+
|
|
651
|
+
return {
|
|
652
|
+
optionChain: {
|
|
653
|
+
result: [
|
|
654
|
+
{
|
|
655
|
+
underlyingSymbol: data.underlyingSymbol,
|
|
656
|
+
expirationDates: data.expirationDates.map(toYahooUnixSeconds),
|
|
657
|
+
strikes,
|
|
658
|
+
quote: data.quote as Record<string, any>,
|
|
659
|
+
options,
|
|
660
|
+
},
|
|
661
|
+
],
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function toYahooUnixSeconds(value: Date | number | string): number {
|
|
667
|
+
if (value instanceof Date) return Math.floor(value.getTime() / 1000);
|
|
668
|
+
if (typeof value === "number") {
|
|
669
|
+
return value > 1_000_000_000_000 ? Math.floor(value / 1000) : value;
|
|
670
|
+
}
|
|
671
|
+
return Math.floor(new Date(value).getTime() / 1000);
|
|
672
|
+
}
|
|
@@ -285,17 +285,22 @@ export function classifyIntent(input: string): ClassificationResult {
|
|
|
285
285
|
function isPortfolioEvaluationRequest(input: string): boolean {
|
|
286
286
|
const lower = input.toLowerCase();
|
|
287
287
|
const hasEvaluationIntent =
|
|
288
|
-
/\b(?:evaluat(?:e|ion)|review|assess|analy[sz]e|prospects?|risks?|opportunities?|mitigat(?:e|ion)|
|
|
289
|
-
lower,
|
|
290
|
-
);
|
|
291
|
-
const hasPortfolioObject =
|
|
292
|
-
/\b(?:portfolio|allocation|asset\s+allocation|60\/40|equity|fixed\s+income|bonds?)\b/.test(
|
|
288
|
+
/\b(?:evaluat(?:e|ion)|review|assess|analy[sz]e|prospects?|risks?|risky|opportunities?|mitigat(?:e|ion)|adjust(?:ment)?|change|rebalance|diversify|concentration|overweight|underweight|expos(?:ed|ure)|target\s+bands?|drift|worried|crash|protect|protection|missing\s+out\s+on\s+growth)\b/.test(
|
|
293
289
|
lower,
|
|
294
290
|
);
|
|
291
|
+
const hasExplicitPortfolioSubject =
|
|
292
|
+
/\b(?:portfolio|allocation|asset\s+allocation|60\/40|holdings?|positions?)\b/.test(lower);
|
|
293
|
+
const hasOwnedAssetMix =
|
|
294
|
+
/\b(?:my|current|have|holding|hold|own|invested|positions?|\d+(?:\.\d+)?%)\b/.test(lower) &&
|
|
295
|
+
/\b(?:equity|fixed\s+income|bonds?|cash|stocks?|etfs?|funds?)\b/.test(lower);
|
|
295
296
|
const hasConstructionIntent =
|
|
296
297
|
/\b(?:build|create|construct|put\s+together|invest|allocate)\b/.test(lower) &&
|
|
297
298
|
/\$\s*\d|\b\d+(?:\.\d+)?\s*k\b|\bbudget\b|\bcapital\b/.test(lower);
|
|
298
|
-
return
|
|
299
|
+
return (
|
|
300
|
+
hasEvaluationIntent &&
|
|
301
|
+
(hasExplicitPortfolioSubject || hasOwnedAssetMix) &&
|
|
302
|
+
!hasConstructionIntent
|
|
303
|
+
);
|
|
299
304
|
}
|
|
300
305
|
|
|
301
306
|
function isStatefulTrackingRequest(input: string): boolean {
|
package/src/routing/planning.ts
CHANGED
|
@@ -98,6 +98,7 @@ export type StructuredCheckId =
|
|
|
98
98
|
| "freshness_disclosed"
|
|
99
99
|
| "data_gap_disclosed"
|
|
100
100
|
| "commitment_mode_respected"
|
|
101
|
+
| "required_final_fields_present"
|
|
101
102
|
| "source_coverage_disclosed"
|
|
102
103
|
| "capability_gap_disclosure"
|
|
103
104
|
| "assumption_disclosed"
|
|
@@ -555,18 +556,18 @@ function defaultTaskFamilyForOutput(output: RouterOutput, text: string): TaskFam
|
|
|
555
556
|
if (/\bbacktest(?:ing|ed)?\b/.test(lower)) {
|
|
556
557
|
return "backtest_review";
|
|
557
558
|
}
|
|
558
|
-
if (/\b(?:macro|inflation|fed|rates?|duration|recession)\b/.test(lower)) {
|
|
559
|
-
return "macro_allocation_review";
|
|
560
|
-
}
|
|
561
|
-
if (/\b(?:sentiment|mood|reddit|twitter|x\/twitter)\b/.test(lower)) {
|
|
562
|
-
return "sentiment_snapshot";
|
|
563
|
-
}
|
|
564
559
|
if (
|
|
565
560
|
isPortfolioRebalancePrompt(lower) ||
|
|
566
561
|
isAddToExistingHoldingsPrompt(lower, output.entities.symbols.length)
|
|
567
562
|
) {
|
|
568
563
|
return "portfolio_review";
|
|
569
564
|
}
|
|
565
|
+
if (/\b(?:macro|inflation|fed|rates?|duration|recession)\b/.test(lower)) {
|
|
566
|
+
return "macro_allocation_review";
|
|
567
|
+
}
|
|
568
|
+
if (/\b(?:sentiment|mood|reddit|twitter|x\/twitter)\b/.test(lower)) {
|
|
569
|
+
return "sentiment_snapshot";
|
|
570
|
+
}
|
|
570
571
|
if (/\b(?:today|right now|this morning|after close|moved|catalyst)\b/.test(lower)) {
|
|
571
572
|
return "current_event_explanation";
|
|
572
573
|
}
|
|
@@ -765,11 +766,16 @@ function isValuationMetricEducationPrompt(lower: string): boolean {
|
|
|
765
766
|
}
|
|
766
767
|
|
|
767
768
|
function isPortfolioRebalancePrompt(lower: string): boolean {
|
|
768
|
-
|
|
769
|
-
/\b(?:portfolio|allocation|holdings?|sleeves?|ira|etfs?|funds?|s&p\s*500|index
|
|
769
|
+
const hasExplicitPortfolioSubject =
|
|
770
|
+
/\b(?:portfolio|allocation|holdings?|sleeves?|ira|401k|401\(k\)|etfs?|funds?|s&p\s*500|index)\b/.test(
|
|
770
771
|
lower,
|
|
771
|
-
)
|
|
772
|
-
|
|
772
|
+
);
|
|
773
|
+
const hasOwnedAssetMix =
|
|
774
|
+
/\b(?:my|current|have|holding|hold|own|invested|positions?|\d+(?:\.\d+)?%)\b/.test(lower) &&
|
|
775
|
+
/\b(?:stocks?|equity|fixed\s+income|bonds?|cash|etfs?|funds?)\b/.test(lower);
|
|
776
|
+
return (
|
|
777
|
+
(hasExplicitPortfolioSubject || hasOwnedAssetMix) &&
|
|
778
|
+
/\b(?:rebalance|diversify|diversifying|concentration|overweight|underweight|expos(?:ed|ure)|target\s+bands?|drift|reduce\s+concentration|adjust(?:ment)?|change|more\s+aggressive|higher\s+growth|too\s+risky|riskier|worried|crash|hedge|protect|protection|missing\s+out\s+on\s+growth)\b/.test(
|
|
773
779
|
lower,
|
|
774
780
|
)
|
|
775
781
|
);
|