opencandle 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +10 -3
  2. package/dist/cli.js +36 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.js +13 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/infra/index.d.ts +0 -1
  8. package/dist/infra/index.js +0 -1
  9. package/dist/infra/index.js.map +1 -1
  10. package/dist/onboarding/connect.d.ts +2 -2
  11. package/dist/onboarding/connect.js +10 -3
  12. package/dist/onboarding/connect.js.map +1 -1
  13. package/dist/onboarding/provider-status.d.ts +48 -0
  14. package/dist/onboarding/provider-status.js +285 -0
  15. package/dist/onboarding/provider-status.js.map +1 -0
  16. package/dist/onboarding/providers.d.ts +85 -8
  17. package/dist/onboarding/providers.js +87 -9
  18. package/dist/onboarding/providers.js.map +1 -1
  19. package/dist/onboarding/state.d.ts +1 -0
  20. package/dist/onboarding/state.js +5 -0
  21. package/dist/onboarding/state.js.map +1 -1
  22. package/dist/onboarding/tool-tags.d.ts +12 -1
  23. package/dist/onboarding/tool-tags.js +31 -1
  24. package/dist/onboarding/tool-tags.js.map +1 -1
  25. package/dist/onboarding/validation.d.ts +2 -2
  26. package/dist/onboarding/validation.js.map +1 -1
  27. package/dist/pi/opencandle-extension.js +91 -15
  28. package/dist/pi/opencandle-extension.js.map +1 -1
  29. package/dist/pi/tool-adapter.d.ts +4 -1
  30. package/dist/pi/tool-adapter.js +5 -4
  31. package/dist/pi/tool-adapter.js.map +1 -1
  32. package/dist/prompts/context-builder.js +1 -1
  33. package/dist/prompts/policy-cards.js +1 -1
  34. package/dist/prompts/policy-cards.js.map +1 -1
  35. package/dist/providers/external-tool-error.d.ts +10 -0
  36. package/dist/providers/external-tool-error.js +21 -0
  37. package/dist/providers/external-tool-error.js.map +1 -0
  38. package/dist/providers/reddit-cli.d.ts +36 -0
  39. package/dist/providers/reddit-cli.js +201 -0
  40. package/dist/providers/reddit-cli.js.map +1 -0
  41. package/dist/providers/reddit.d.ts +1 -1
  42. package/dist/providers/reddit.js +7 -35
  43. package/dist/providers/reddit.js.map +1 -1
  44. package/dist/providers/twitter-cli.d.ts +40 -0
  45. package/dist/providers/twitter-cli.js +153 -0
  46. package/dist/providers/twitter-cli.js.map +1 -0
  47. package/dist/providers/twitter.d.ts +0 -8
  48. package/dist/providers/twitter.js +4 -54
  49. package/dist/providers/twitter.js.map +1 -1
  50. package/dist/providers/wrap-provider.js +30 -0
  51. package/dist/providers/wrap-provider.js.map +1 -1
  52. package/dist/providers/yahoo-finance.js +53 -32
  53. package/dist/providers/yahoo-finance.js.map +1 -1
  54. package/dist/routing/planning.d.ts +1 -1
  55. package/dist/routing/planning.js.map +1 -1
  56. package/dist/runtime/answer-contracts.d.ts +1 -1
  57. package/dist/runtime/answer-contracts.js +12 -1
  58. package/dist/runtime/answer-contracts.js.map +1 -1
  59. package/dist/runtime/tool-defaults-wrapper.js +6 -2
  60. package/dist/runtime/tool-defaults-wrapper.js.map +1 -1
  61. package/dist/sentiment/index.d.ts +1 -0
  62. package/dist/sentiment/index.js +1 -0
  63. package/dist/sentiment/index.js.map +1 -1
  64. package/dist/sentiment/insights.d.ts +17 -0
  65. package/dist/sentiment/insights.js +206 -0
  66. package/dist/sentiment/insights.js.map +1 -0
  67. package/dist/sentiment/pipeline.js +13 -1
  68. package/dist/sentiment/pipeline.js.map +1 -1
  69. package/dist/sentiment/scorer.d.ts +2 -0
  70. package/dist/sentiment/scorer.js +10 -1
  71. package/dist/sentiment/scorer.js.map +1 -1
  72. package/dist/sentiment/types.d.ts +2 -0
  73. package/dist/sentiment/types.js.map +1 -1
  74. package/dist/system-prompt.js +3 -7
  75. package/dist/system-prompt.js.map +1 -1
  76. package/dist/tools/index.d.ts +5 -2
  77. package/dist/tools/index.js +8 -8
  78. package/dist/tools/index.js.map +1 -1
  79. package/dist/tools/sentiment/insight-format.d.ts +2 -0
  80. package/dist/tools/sentiment/insight-format.js +36 -0
  81. package/dist/tools/sentiment/insight-format.js.map +1 -0
  82. package/dist/tools/sentiment/query-match.d.ts +3 -0
  83. package/dist/tools/sentiment/query-match.js +113 -0
  84. package/dist/tools/sentiment/query-match.js.map +1 -0
  85. package/dist/tools/sentiment/reddit-sentiment.d.ts +12 -1
  86. package/dist/tools/sentiment/reddit-sentiment.js +263 -117
  87. package/dist/tools/sentiment/reddit-sentiment.js.map +1 -1
  88. package/dist/tools/sentiment/sentiment-summary.d.ts +9 -1
  89. package/dist/tools/sentiment/sentiment-summary.js +217 -201
  90. package/dist/tools/sentiment/sentiment-summary.js.map +1 -1
  91. package/dist/tools/sentiment/twitter-sentiment.d.ts +11 -1
  92. package/dist/tools/sentiment/twitter-sentiment.js +187 -64
  93. package/dist/tools/sentiment/twitter-sentiment.js.map +1 -1
  94. package/dist/tools/sentiment/web-sentiment.js +4 -0
  95. package/dist/tools/sentiment/web-sentiment.js.map +1 -1
  96. package/dist/types/sentiment.d.ts +52 -0
  97. package/gui/server/invoke-tool.ts +17 -3
  98. package/gui/server/model-setup.ts +10 -3
  99. package/gui/server/projector.ts +6 -2
  100. package/gui/server/server.ts +18 -0
  101. package/gui/server/tool-metadata.ts +80 -16
  102. package/gui/server/ws-hub.ts +19 -0
  103. package/gui/web/dist/assets/CatalogOverlay-CgeY5Pkp.js +1 -0
  104. package/gui/web/dist/assets/index-C6W_2eAn.js +69 -0
  105. package/gui/web/dist/assets/{index-2KZtKBmu.css → index-hwbx24a5.css} +1 -1
  106. package/gui/web/dist/index.html +2 -2
  107. package/package.json +5 -6
  108. package/src/cli.ts +41 -0
  109. package/src/config.ts +27 -0
  110. package/src/infra/index.ts +0 -1
  111. package/src/onboarding/connect.ts +20 -4
  112. package/src/onboarding/provider-status.ts +410 -0
  113. package/src/onboarding/providers.ts +148 -18
  114. package/src/onboarding/state.ts +9 -0
  115. package/src/onboarding/tool-tags.ts +45 -2
  116. package/src/onboarding/validation.ts +2 -2
  117. package/src/pi/opencandle-extension.ts +115 -17
  118. package/src/pi/tool-adapter.ts +14 -4
  119. package/src/prompts/context-builder.ts +1 -1
  120. package/src/prompts/policy-cards.ts +1 -1
  121. package/src/providers/external-tool-error.ts +20 -0
  122. package/src/providers/reddit-cli.ts +317 -0
  123. package/src/providers/reddit.ts +7 -63
  124. package/src/providers/twitter-cli.ts +233 -0
  125. package/src/providers/twitter.ts +4 -73
  126. package/src/providers/wrap-provider.ts +34 -0
  127. package/src/providers/yahoo-finance.ts +65 -32
  128. package/src/routing/planning.ts +1 -0
  129. package/src/runtime/answer-contracts.ts +23 -2
  130. package/src/runtime/tool-defaults-wrapper.ts +12 -2
  131. package/src/sentiment/index.ts +1 -0
  132. package/src/sentiment/insights.ts +269 -0
  133. package/src/sentiment/pipeline.ts +13 -1
  134. package/src/sentiment/scorer.ts +12 -1
  135. package/src/sentiment/types.ts +3 -0
  136. package/src/system-prompt.ts +3 -7
  137. package/src/tools/index.ts +9 -8
  138. package/src/tools/sentiment/insight-format.ts +50 -0
  139. package/src/tools/sentiment/query-match.ts +117 -0
  140. package/src/tools/sentiment/reddit-sentiment.ts +354 -141
  141. package/src/tools/sentiment/sentiment-summary.ts +283 -237
  142. package/src/tools/sentiment/twitter-sentiment.ts +262 -78
  143. package/src/tools/sentiment/web-sentiment.ts +4 -0
  144. package/src/types/sentiment.ts +59 -0
  145. package/dist/infra/browser.d.ts +0 -35
  146. package/dist/infra/browser.js +0 -105
  147. package/dist/infra/browser.js.map +0 -1
  148. package/dist/tools/interaction/twitter-login.d.ts +0 -8
  149. package/dist/tools/interaction/twitter-login.js +0 -87
  150. package/dist/tools/interaction/twitter-login.js.map +0 -1
  151. package/gui/web/dist/assets/CatalogOverlay-eJ2cBk33.js +0 -1
  152. package/gui/web/dist/assets/index-CveNgtDg.js +0 -69
  153. package/src/infra/browser.ts +0 -113
  154. package/src/tools/interaction/twitter-login.ts +0 -105
@@ -7,9 +7,19 @@ export function wrapWithDefaults<TParams extends TSchema, TDetails>(
7
7
  ): AgentTool<TParams, TDetails> {
8
8
  return {
9
9
  ...tool,
10
- execute: async (toolCallId, params, signal, onUpdate) => {
10
+ execute: async (toolCallId, params, signal, onUpdate, ctx?: unknown) => {
11
11
  const merged = mergeDefaults(defaults, params as Record<string, unknown>);
12
- return tool.execute(toolCallId, merged as typeof params, signal, onUpdate);
12
+ const executeWithContext = tool.execute as unknown as (
13
+ id: string,
14
+ params: unknown,
15
+ signal?: AbortSignal,
16
+ onUpdate?: unknown,
17
+ ctx?: unknown,
18
+ ) => ReturnType<typeof tool.execute>;
19
+ if (ctx === undefined) {
20
+ return executeWithContext(toolCallId, merged, signal, onUpdate);
21
+ }
22
+ return executeWithContext(toolCallId, merged, signal, onUpdate, ctx);
13
23
  },
14
24
  };
15
25
  }
@@ -1,6 +1,7 @@
1
1
  export { RedditAdapter } from "./adapters/reddit.js";
2
2
  export { TwitterAdapter } from "./adapters/twitter.js";
3
3
  export { WebAdapter } from "./adapters/web.js";
4
+ export { buildSentimentInsight, labelForScore } from "./insights.js";
4
5
  export { BEARISH_TERMS, BULLISH_TERMS } from "./keywords.js";
5
6
  export { SentimentPipeline } from "./pipeline.js";
6
7
  export { keywordScore, scoreRecords } from "./scorer.js";
@@ -0,0 +1,269 @@
1
+ import type { SentimentConfig } from "../config.js";
2
+ import type {
3
+ SentimentInsight,
4
+ SentimentInsightConfidence,
5
+ SentimentInsightDriver,
6
+ SentimentRepresentativeItem,
7
+ } from "../types/sentiment.js";
8
+ import type { SentimentSource, SentinelRecord } from "./types.js";
9
+
10
+ const DEFAULT_MIN_SAMPLE = 10;
11
+ const DEFAULT_DRIVER_CAP = 3;
12
+ const DEFAULT_SOURCE_ITEM_CAP = 5;
13
+ const DEFAULT_AGGREGATE_ITEM_CAP = 8;
14
+ const DEFAULT_CLAIM_CAP = 5;
15
+
16
+ export interface BuildSentimentInsightOptions {
17
+ query: string;
18
+ records: SentinelRecord[];
19
+ score?: number;
20
+ label?: string;
21
+ source?: SentimentSource | "aggregate";
22
+ missingSources?: string[];
23
+ sourceNotes?: string[];
24
+ config?: Partial<SentimentConfig>;
25
+ aggregate?: boolean;
26
+ extraCaveats?: string[];
27
+ }
28
+
29
+ interface TermBucket {
30
+ term: string;
31
+ count: number;
32
+ sourceIds: Set<string>;
33
+ }
34
+
35
+ export function buildSentimentInsight(options: BuildSentimentInsightOptions): SentimentInsight {
36
+ const records = options.records;
37
+ const scored = records.filter((record) => Math.abs(record.sentiment.score) > 0);
38
+ const score =
39
+ typeof options.score === "number"
40
+ ? options.score
41
+ : records.length > 0
42
+ ? records.reduce((sum, record) => sum + record.sentiment.score, 0) / records.length
43
+ : 0;
44
+ const sourceCounts = countBySource(records);
45
+ const positiveDrivers = driversFromTerms(records, "positive", options.config);
46
+ const negativeDrivers = driversFromTerms(records, "negative", options.config);
47
+ const caveats = buildCaveats(records, scored, sourceCounts, options);
48
+ const confidence = buildConfidence(records, scored, sourceCounts, caveats, options.config);
49
+ const representativeItems = representativeItemsFor(records, options);
50
+
51
+ return {
52
+ label: options.label ?? labelForScore(score),
53
+ score,
54
+ sampleSize: records.length,
55
+ scoredSampleSize: scored.length,
56
+ confidence,
57
+ positiveDrivers,
58
+ negativeDrivers,
59
+ mixedDrivers: buildMixedDrivers(positiveDrivers, negativeDrivers, score),
60
+ notableClaims: notableClaimsFor(records, options),
61
+ representativeItems,
62
+ sourceCoverage: {
63
+ sources: Object.keys(sourceCounts),
64
+ counts: sourceCounts,
65
+ missingSources: options.missingSources?.filter(Boolean),
66
+ notes: options.sourceNotes?.filter(Boolean),
67
+ },
68
+ caveats,
69
+ method: "deterministic-keyword-v1",
70
+ };
71
+ }
72
+
73
+ export function labelForScore(score: number): string {
74
+ if (score > 0.3) return "Bullish";
75
+ if (score < -0.3) return "Bearish";
76
+ if (score > 0) return "Leaning Bullish";
77
+ if (score < 0) return "Leaning Bearish";
78
+ return "Neutral";
79
+ }
80
+
81
+ function driversFromTerms(
82
+ records: SentinelRecord[],
83
+ polarity: "positive" | "negative",
84
+ config: Partial<SentimentConfig> | undefined,
85
+ ): SentimentInsightDriver[] {
86
+ const buckets = new Map<string, TermBucket>();
87
+ const metadataKey = polarity === "positive" ? "matchedBullishTerms" : "matchedBearishTerms";
88
+
89
+ for (const record of records) {
90
+ const terms = stringArray(record.metadata[metadataKey]);
91
+ for (const term of terms) {
92
+ const existing = buckets.get(term) ?? { term, count: 0, sourceIds: new Set<string>() };
93
+ existing.count += 1;
94
+ existing.sourceIds.add(record.sourceId);
95
+ buckets.set(term, existing);
96
+ }
97
+ }
98
+
99
+ return [...buckets.values()]
100
+ .sort((a, b) => b.count - a.count || a.term.localeCompare(b.term))
101
+ .slice(0, config?.maxInsightDriversPerPolarity ?? DEFAULT_DRIVER_CAP)
102
+ .map((bucket) => ({
103
+ label: bucket.term,
104
+ count: bucket.count,
105
+ polarity,
106
+ terms: [bucket.term],
107
+ sourceIds: [...bucket.sourceIds],
108
+ }));
109
+ }
110
+
111
+ function buildMixedDrivers(
112
+ positiveDrivers: SentimentInsightDriver[],
113
+ negativeDrivers: SentimentInsightDriver[],
114
+ score: number,
115
+ ): SentimentInsightDriver[] {
116
+ if (positiveDrivers.length === 0 || negativeDrivers.length === 0) return [];
117
+ if (Math.abs(score) > 0.3) return [];
118
+ return [
119
+ {
120
+ label: "offsetting bullish and bearish evidence",
121
+ count: Math.min(sumDriverCounts(positiveDrivers), sumDriverCounts(negativeDrivers)),
122
+ polarity: "mixed",
123
+ terms: [
124
+ ...positiveDrivers.flatMap((d) => d.terms),
125
+ ...negativeDrivers.flatMap((d) => d.terms),
126
+ ],
127
+ sourceIds: [
128
+ ...new Set([
129
+ ...positiveDrivers.flatMap((d) => d.sourceIds),
130
+ ...negativeDrivers.flatMap((d) => d.sourceIds),
131
+ ]),
132
+ ],
133
+ },
134
+ ];
135
+ }
136
+
137
+ function representativeItemsFor(
138
+ records: SentinelRecord[],
139
+ options: BuildSentimentInsightOptions,
140
+ ): SentimentRepresentativeItem[] {
141
+ const cap = options.aggregate
142
+ ? (options.config?.maxAggregateRepresentativeItems ?? DEFAULT_AGGREGATE_ITEM_CAP)
143
+ : (options.config?.maxRepresentativeItemsPerSource ?? DEFAULT_SOURCE_ITEM_CAP);
144
+
145
+ return [...records]
146
+ .sort((a, b) => itemRank(b) - itemRank(a))
147
+ .slice(0, cap)
148
+ .map((record) => ({
149
+ source: record.source,
150
+ sourceId: record.sourceId,
151
+ title: record.title,
152
+ excerpt: excerptFor(record),
153
+ url: record.url || null,
154
+ author: record.author,
155
+ publishedAt: record.publishedAt,
156
+ engagement: Number.isFinite(record.engagement.score) ? record.engagement.score : null,
157
+ score: record.sentiment.score,
158
+ matchedTerms: [
159
+ ...stringArray(record.metadata.matchedBullishTerms),
160
+ ...stringArray(record.metadata.matchedBearishTerms),
161
+ ],
162
+ metadata: record.metadata,
163
+ }));
164
+ }
165
+
166
+ function notableClaimsFor(
167
+ records: SentinelRecord[],
168
+ options: BuildSentimentInsightOptions,
169
+ ): string[] {
170
+ const cap = options.config?.maxNotableClaims ?? DEFAULT_CLAIM_CAP;
171
+ const claims = new Set<string>();
172
+ for (const record of representativeItemsFor(records, { ...options, aggregate: false })) {
173
+ const claim = record.title ?? record.excerpt;
174
+ if (claim) claims.add(claim);
175
+ if (claims.size >= cap) break;
176
+ }
177
+ return [...claims];
178
+ }
179
+
180
+ function buildConfidence(
181
+ records: SentinelRecord[],
182
+ scored: SentinelRecord[],
183
+ sourceCounts: Record<string, number>,
184
+ caveats: string[],
185
+ config: Partial<SentimentConfig> | undefined,
186
+ ): SentimentInsightConfidence {
187
+ const minSample = config?.minUsefulSampleSize ?? DEFAULT_MIN_SAMPLE;
188
+ const sampleRatio = Math.min(records.length / minSample, 1);
189
+ const scoredRatio = records.length === 0 ? 0 : scored.length / records.length;
190
+ const coverageRatio = Math.min(Object.keys(sourceCounts).length / 3, 1);
191
+ let score = sampleRatio * 0.4 + scoredRatio * 0.4 + coverageRatio * 0.2;
192
+ const reasons: string[] = [];
193
+
194
+ if (records.length < minSample) {
195
+ reasons.push(`low sample size (${records.length}/${minSample})`);
196
+ }
197
+ if (scoredRatio < 0.5) {
198
+ reasons.push("most records were neutral or had no keyword sentiment match");
199
+ }
200
+ if (Object.keys(sourceCounts).length <= 1) {
201
+ reasons.push("single-source sentiment coverage");
202
+ }
203
+ if (caveats.length > 0) {
204
+ score -= Math.min(0.2, caveats.length * 0.05);
205
+ }
206
+
207
+ score = clamp01(score);
208
+ return {
209
+ level: score >= 0.7 ? "high" : score >= 0.4 ? "medium" : "low",
210
+ score,
211
+ reasons,
212
+ };
213
+ }
214
+
215
+ function buildCaveats(
216
+ records: SentinelRecord[],
217
+ scored: SentinelRecord[],
218
+ sourceCounts: Record<string, number>,
219
+ options: BuildSentimentInsightOptions,
220
+ ): string[] {
221
+ const minSample = options.config?.minUsefulSampleSize ?? DEFAULT_MIN_SAMPLE;
222
+ const caveats = [...(options.extraCaveats ?? [])];
223
+ if (records.length < minSample) caveats.push(`Low sample size: ${records.length} records.`);
224
+ if (records.length > 0 && scored.length === 0)
225
+ caveats.push("No records contained keyword sentiment matches.");
226
+ if (records.length > 0 && scored.length / records.length < 0.5) {
227
+ caveats.push("Most records were neutral or unscored by keyword matching.");
228
+ }
229
+ if (Object.keys(sourceCounts).length <= 1) caveats.push("Single-source sentiment coverage.");
230
+ if (options.missingSources && options.missingSources.length > 0) {
231
+ caveats.push(`Missing sources: ${options.missingSources.join(", ")}.`);
232
+ }
233
+ return [...new Set(caveats)];
234
+ }
235
+
236
+ function countBySource(records: SentinelRecord[]): Record<string, number> {
237
+ const counts: Record<string, number> = {};
238
+ for (const record of records) {
239
+ counts[record.source] = (counts[record.source] ?? 0) + 1;
240
+ }
241
+ return counts;
242
+ }
243
+
244
+ function stringArray(value: unknown): string[] {
245
+ return Array.isArray(value)
246
+ ? value.filter((item): item is string => typeof item === "string")
247
+ : [];
248
+ }
249
+
250
+ function sumDriverCounts(drivers: SentimentInsightDriver[]): number {
251
+ return drivers.reduce((sum, driver) => sum + driver.count, 0);
252
+ }
253
+
254
+ function itemRank(record: SentinelRecord): number {
255
+ const sentimentWeight = Math.abs(record.sentiment.score) * 100;
256
+ const confidenceWeight = record.sentiment.confidence * 20;
257
+ const engagementWeight = Math.log10(Math.max(0, record.engagement.score) + 1);
258
+ return sentimentWeight + confidenceWeight + engagementWeight;
259
+ }
260
+
261
+ function excerptFor(record: SentinelRecord): string {
262
+ const raw = record.title ? `${record.title} ${record.text}` : record.text;
263
+ const normalized = raw.replace(/\s+/g, " ").trim();
264
+ return normalized.length > 220 ? `${normalized.slice(0, 219)}…` : normalized;
265
+ }
266
+
267
+ function clamp01(value: number): number {
268
+ return Math.max(0, Math.min(1, value));
269
+ }
@@ -1,4 +1,5 @@
1
1
  import type { SentimentConfig } from "../config.js";
2
+ import { buildSentimentInsight } from "./insights.js";
2
3
  import { scoreRecords } from "./scorer.js";
3
4
  import type { SentimentStore } from "./store.js";
4
5
  import { computeDivergence, computeTrend, type SourceStats } from "./trends.js";
@@ -65,7 +66,18 @@ export class SentimentPipeline {
65
66
  divergence = computeDivergence(sourceStats, this.config.divergenceThreshold);
66
67
  }
67
68
 
68
- return { fresh: scored, trend, divergence, warnings };
69
+ return {
70
+ fresh: scored,
71
+ trend,
72
+ divergence,
73
+ warnings,
74
+ insight: buildSentimentInsight({
75
+ query,
76
+ records: scored,
77
+ config: this.config,
78
+ aggregate: true,
79
+ }),
80
+ };
69
81
  }
70
82
  }
71
83
 
@@ -7,6 +7,8 @@ interface ScoreResult {
7
7
  score: number;
8
8
  confidence: number;
9
9
  tickers: string[];
10
+ bullishTerms: string[];
11
+ bearishTerms: string[];
10
12
  }
11
13
 
12
14
  export function keywordScore(record: SentinelRecord): ScoreResult {
@@ -17,11 +19,14 @@ export function keywordScore(record: SentinelRecord): ScoreResult {
17
19
  let bearishWeight = 0;
18
20
  let bullishCount = 0;
19
21
  let bearishCount = 0;
22
+ const bullishTerms: string[] = [];
23
+ const bearishTerms: string[] = [];
20
24
 
21
25
  for (const term of BULLISH_TERMS) {
22
26
  if (lower.includes(term)) {
23
27
  bullishCount++;
24
28
  bullishWeight += engagement;
29
+ bullishTerms.push(term);
25
30
  }
26
31
  }
27
32
 
@@ -29,6 +34,7 @@ export function keywordScore(record: SentinelRecord): ScoreResult {
29
34
  if (lower.includes(term)) {
30
35
  bearishCount++;
31
36
  bearishWeight += engagement;
37
+ bearishTerms.push(term);
32
38
  }
33
39
  }
34
40
 
@@ -59,7 +65,7 @@ export function keywordScore(record: SentinelRecord): ScoreResult {
59
65
  }
60
66
  }
61
67
 
62
- return { score, confidence, tickers };
68
+ return { score, confidence, tickers, bullishTerms, bearishTerms };
63
69
  }
64
70
 
65
71
  export function scoreRecords(records: SentinelRecord[]): SentinelRecord[] {
@@ -73,6 +79,11 @@ export function scoreRecords(records: SentinelRecord[]): SentinelRecord[] {
73
79
  method: "keyword" as const,
74
80
  tickers: result.tickers,
75
81
  },
82
+ metadata: {
83
+ ...record.metadata,
84
+ matchedBullishTerms: result.bullishTerms,
85
+ matchedBearishTerms: result.bearishTerms,
86
+ },
76
87
  };
77
88
  });
78
89
  }
@@ -1,3 +1,5 @@
1
+ import type { SentimentInsight } from "../types/sentiment.js";
2
+
1
3
  export const SENTIMENT_SOURCES = ["twitter", "reddit", "web", "finnhub"] as const;
2
4
  export type SentimentSource = (typeof SENTIMENT_SOURCES)[number];
3
5
 
@@ -106,4 +108,5 @@ export interface SentimentSummary {
106
108
  trend: TrendResult[] | null;
107
109
  divergence: DivergenceResult | null;
108
110
  warnings: string[];
111
+ insight?: SentimentInsight;
109
112
  }
@@ -20,7 +20,7 @@ You are an analyst, not a fiduciary advisor. When asked for entry levels, price
20
20
  - **Sentiment**: get_reddit_sentiment, get_twitter_sentiment, get_web_sentiment, get_sentiment_trend, get_sentiment_summary — retail and news sentiment from Reddit, Twitter/X, and web sources with historical trends
21
21
  - **Options**: get_option_chain — full options chain with strikes, bids/asks, volume, OI, IV, and computed Greeks (delta, gamma, theta, vega, rho)
22
22
  - **Portfolio**: track_portfolio, analyze_risk, manage_watchlist, analyze_correlation, track_prediction, manage_alerts, daily_watchlist_report, manage_notifications — position tracking, P&L, Sharpe ratio, VaR, watchlist tracking, durable local alerts, daily watchlist reports, notification history, correlation matrix, and prediction tracking with accuracy scoring
23
- - **User Interaction**: ask_user — ask clarification questions; trigger_twitter_login open a browser for Twitter/X login
23
+ - **User Interaction**: ask_user — ask clarification questions and setup confirmations
24
24
 
25
25
  ## Analytical Framework
26
26
  When analyzing a stock, follow these steps in order:
@@ -80,12 +80,8 @@ Do NOT ask clarifying questions when:
80
80
 
81
81
  Keep questions concise and offer specific options when possible. Prefer select-type questions over open-ended text input to minimize user effort.
82
82
 
83
- ## Twitter Authentication
84
- get_twitter_sentiment requires a one-time Twitter/X login. When the tool returns [LOGIN_NEEDED]:
85
- 1. Use ask_user (confirm) to ask: "Twitter sentiment requires a one-time login. A browser will open — want to proceed?"
86
- 2. If confirmed, call trigger_twitter_login. It opens a browser, waits for the user to log in, and returns success/failure.
87
- 3. On success, retry get_twitter_sentiment with the original query.
88
- If the user declines, skip Twitter sentiment and continue with other available data sources.
83
+ ## Twitter/X External Tool Setup
84
+ get_twitter_sentiment uses the external twitter-cli command and the user's normal browser session. If the tool says twitter-cli is missing, ask the user whether they want to install it with \`uv tool install twitter-cli\`, skip X for this query, or always skip X. If the tool says browser cookies or the X session are missing or stale, ask the user to log into or refresh x.com in a supported browser, then retry only after they confirm. Do not use the retired browser-login tool.
89
85
 
90
86
  ## After Clarification: Fetch Data Immediately
91
87
  CRITICAL: After ask_user answers come back, your NEXT action MUST be tool calls — not a text response. You are a data agent, not a chatbot. Never respond with generic investment categories or tell the user to come back with tickers. YOU pick the relevant assets and indicators based on what you learned, then fetch the data.
@@ -1,4 +1,5 @@
1
1
  import type { AgentTool } from "@earendil-works/pi-agent-core";
2
+ import type { AskUserHandler } from "../types/index.js";
2
3
  import { companyOverviewTool } from "./fundamentals/company-overview.js";
3
4
  import { compsTool } from "./fundamentals/comps.js";
4
5
  import { dcfTool } from "./fundamentals/dcf.js";
@@ -23,10 +24,10 @@ import { predictionsTool } from "./portfolio/predictions.js";
23
24
  import { riskAnalysisTool } from "./portfolio/risk-analysis.js";
24
25
  import { portfolioTrackerTool } from "./portfolio/tracker.js";
25
26
  import { watchlistTool } from "./portfolio/watchlist.js";
26
- import { redditSentimentTool } from "./sentiment/reddit-sentiment.js";
27
- import { sentimentSummaryTool } from "./sentiment/sentiment-summary.js";
27
+ import { createRedditSentimentTool } from "./sentiment/reddit-sentiment.js";
28
+ import { createSentimentSummaryTool } from "./sentiment/sentiment-summary.js";
28
29
  import { sentimentTrendTool } from "./sentiment/sentiment-trend.js";
29
- import { twitterSentimentTool } from "./sentiment/twitter-sentiment.js";
30
+ import { createTwitterSentimentTool } from "./sentiment/twitter-sentiment.js";
30
31
  import { webSearchTool } from "./sentiment/web-search.js";
31
32
  import { webSentimentTool } from "./sentiment/web-sentiment.js";
32
33
  import { backtestTool } from "./technical/backtest.js";
@@ -57,7 +58,7 @@ export { riskAnalysisTool } from "./portfolio/risk-analysis.js";
57
58
  export { portfolioTrackerTool } from "./portfolio/tracker.js";
58
59
  export { watchlistTool } from "./portfolio/watchlist.js";
59
60
  export { redditSentimentTool } from "./sentiment/reddit-sentiment.js";
60
- export { sentimentSummaryTool } from "./sentiment/sentiment-summary.js";
61
+ export { createSentimentSummaryTool, sentimentSummaryTool } from "./sentiment/sentiment-summary.js";
61
62
  export { sentimentTrendTool } from "./sentiment/sentiment-trend.js";
62
63
  export { twitterSentimentTool } from "./sentiment/twitter-sentiment.js";
63
64
  export { webSearchTool } from "./sentiment/web-search.js";
@@ -65,7 +66,7 @@ export { webSentimentTool } from "./sentiment/web-sentiment.js";
65
66
  export { backtestTool } from "./technical/backtest.js";
66
67
  export { technicalIndicatorsTool } from "./technical/indicators.js";
67
68
 
68
- export function getAllTools(): AgentTool<any>[] {
69
+ export function getAllTools(options: { askUserHandler?: AskUserHandler } = {}): AgentTool<any>[] {
69
70
  return [
70
71
  searchTickerTool,
71
72
  stockQuoteTool,
@@ -81,8 +82,8 @@ export function getAllTools(): AgentTool<any>[] {
81
82
  secFilingsTool,
82
83
  fredDataTool,
83
84
  fearGreedTool,
84
- redditSentimentTool,
85
- twitterSentimentTool,
85
+ createRedditSentimentTool({ askUserHandler: options.askUserHandler }),
86
+ createTwitterSentimentTool({ askUserHandler: options.askUserHandler }),
86
87
  technicalIndicatorsTool,
87
88
  backtestTool,
88
89
  portfolioTrackerTool,
@@ -98,6 +99,6 @@ export function getAllTools(): AgentTool<any>[] {
98
99
  webSearchTool,
99
100
  webSentimentTool,
100
101
  sentimentTrendTool,
101
- sentimentSummaryTool,
102
+ createSentimentSummaryTool({ askUserHandler: options.askUserHandler }),
102
103
  ];
103
104
  }
@@ -0,0 +1,50 @@
1
+ import type { SentimentInsight } from "../../types/sentiment.js";
2
+ import { renderUntrustedText } from "./untrusted-text.js";
3
+
4
+ export function formatInsightSection(insight: SentimentInsight): string[] {
5
+ const lines: string[] = [];
6
+ lines.push("");
7
+ lines.push("Findings:");
8
+ lines.push(
9
+ `- Overall: ${insight.label} (${formatSigned(insight.score)}) from ${insight.sampleSize} records; ${insight.scoredSampleSize} had keyword sentiment evidence.`,
10
+ );
11
+ lines.push(
12
+ `- Confidence: ${insight.confidence.level} (${insight.confidence.score.toFixed(2)})${
13
+ insight.confidence.reasons.length > 0 ? ` — ${insight.confidence.reasons.join("; ")}` : ""
14
+ }`,
15
+ );
16
+ appendDrivers(lines, "Positive drivers", insight.positiveDrivers);
17
+ appendDrivers(lines, "Negative drivers", insight.negativeDrivers);
18
+ appendDrivers(lines, "Mixed drivers", insight.mixedDrivers);
19
+ if (insight.caveats.length > 0) {
20
+ lines.push(`- Caveats: ${insight.caveats.join("; ")}`);
21
+ }
22
+ if (insight.notableClaims.length > 0) {
23
+ lines.push("- Notable source claims:");
24
+ for (const claim of insight.notableClaims.slice(0, 3)) {
25
+ lines.push(` - ${renderUntrustedText(claim, 140)}`);
26
+ }
27
+ }
28
+ if (insight.representativeItems.length > 0) {
29
+ lines.push(
30
+ `- Representative evidence preview: ${insight.representativeItems.length} shown from ${insight.scoredSampleSize} scored records (${insight.sampleSize} total records).`,
31
+ );
32
+ }
33
+ return lines;
34
+ }
35
+
36
+ function appendDrivers(
37
+ lines: string[],
38
+ label: string,
39
+ drivers: SentimentInsight["positiveDrivers"],
40
+ ): void {
41
+ if (drivers.length === 0) return;
42
+ const rendered = drivers
43
+ .map((driver) => `${renderUntrustedText(driver.label, 60)} (${driver.count})`)
44
+ .join(", ");
45
+ lines.push(`- ${label}: ${rendered}`);
46
+ }
47
+
48
+ function formatSigned(value: number): string {
49
+ return `${value >= 0 ? "+" : ""}${value.toFixed(2)}`;
50
+ }
@@ -0,0 +1,117 @@
1
+ import { extractEntities } from "../../routing/entity-extractor.js";
2
+ import type { SentinelRecord } from "../../sentiment/types.js";
3
+
4
+ const STOP_TERMS = new Set([
5
+ "about",
6
+ "around",
7
+ "are",
8
+ "from",
9
+ "how",
10
+ "is",
11
+ "latest",
12
+ "mood",
13
+ "now",
14
+ "on",
15
+ "or",
16
+ "people",
17
+ "reddit",
18
+ "retail",
19
+ "right",
20
+ "say",
21
+ "saying",
22
+ "says",
23
+ "sentiment",
24
+ "social",
25
+ "stock",
26
+ "stocks",
27
+ "talking",
28
+ "the",
29
+ "think",
30
+ "thinking",
31
+ "to",
32
+ "tweet",
33
+ "tweets",
34
+ "twitter",
35
+ "what",
36
+ "with",
37
+ ]);
38
+
39
+ export function sentimentQueryTerms(query: string): string[] {
40
+ const hasSp500 = /\bs\s*&\s*p\s*500\b/i.test(query) || /\bspx\b/i.test(query);
41
+
42
+ const symbols = hasSp500
43
+ ? ["sp500"]
44
+ : extractEntities(query).symbols.map((symbol) => symbol.toLowerCase());
45
+ const punctuationTerms = [...query.toLowerCase().matchAll(/\b([a-z])\s*[&/]\s*([a-z])\b/g)].map(
46
+ (match) => `${match[1]}${match[2]}`,
47
+ );
48
+ const terms = query.toLowerCase().match(/[a-z0-9]{2,}/g);
49
+ if (!terms) {
50
+ if (punctuationTerms.length > 0) return [...new Set(punctuationTerms)];
51
+ const compact = query.toLowerCase().replace(/[^a-z0-9]/g, "");
52
+ if (compact.length >= 2) return [compact];
53
+ return query.trim().length > 0 ? [query.trim().toLowerCase()] : [];
54
+ }
55
+
56
+ const stopTerms = hasSp500 ? new Set([...STOP_TERMS, "sp", "spx", "500"]) : STOP_TERMS;
57
+ const filtered = [
58
+ ...new Set([...punctuationTerms, ...terms.filter((term) => !stopTerms.has(term))]),
59
+ ].slice(0, 6);
60
+ if (symbols.length > 0) {
61
+ return [
62
+ ...new Set([
63
+ ...symbols,
64
+ ...filtered.filter((term) => !symbols.some((symbol) => symbol.startsWith(term))),
65
+ ]),
66
+ ].slice(0, 6);
67
+ }
68
+ if (filtered.length > 0) return filtered;
69
+ return query.trim().length > 0 ? [query.trim().toLowerCase()] : [];
70
+ }
71
+
72
+ export function recordMatchesSentimentQuery(
73
+ record: SentinelRecord,
74
+ terms: readonly string[],
75
+ ): boolean {
76
+ if (terms.length === 0) return true;
77
+ const text = `${record.title ?? ""}\n${record.text}`.toLowerCase();
78
+ const textTokens = new Set(text.match(/[a-z0-9]+/g) ?? []);
79
+ const metadataTokens = new Set<string>();
80
+ const subreddit = record.metadata.subreddit;
81
+ if (typeof subreddit === "string") {
82
+ metadataTokens.add(subreddit.toLowerCase());
83
+ metadataTokens.add(subreddit.toLowerCase().replace(/^r\//, ""));
84
+ }
85
+ if (/\bs\s*&\s*p\s*500\b/i.test(text) || /\bspx\b/i.test(text)) textTokens.add("sp500");
86
+ for (const match of text.matchAll(/\b([a-z])\s*[&/]\s*([a-z])\b/g)) {
87
+ textTokens.add(`${match[1]}${match[2]}`);
88
+ }
89
+ for (const match of text.matchAll(/\b([a-z]{1,5})[.-]([a-z])\b/g)) {
90
+ textTokens.add(`${match[1]}${match[2]}`);
91
+ }
92
+ const tickerTokens = new Set<string>();
93
+ for (const ticker of record.sentiment.tickers) {
94
+ const normalized = ticker.toLowerCase();
95
+ tickerTokens.add(normalized);
96
+ tickerTokens.add(normalized.replace(/[^a-z0-9]/g, ""));
97
+ }
98
+ const matchedTerms = terms.filter(
99
+ (term) => tickerTokens.has(term) || textTokens.has(term) || metadataTokens.has(term),
100
+ );
101
+ for (const term of terms) {
102
+ if (matchedTerms.includes(term) || !term.includes(" ")) continue;
103
+ if (text.includes(term)) matchedTerms.push(term);
104
+ }
105
+ for (const term of terms) {
106
+ if (matchedTerms.includes(term) || term.length < 4) continue;
107
+ if (textTokens.has(`${term}s`)) {
108
+ matchedTerms.push(term);
109
+ continue;
110
+ }
111
+ if (term.endsWith("s") && textTokens.has(term.slice(0, -1))) {
112
+ matchedTerms.push(term);
113
+ }
114
+ }
115
+ const requiredMatches = terms.length > 1 ? 2 : 1;
116
+ return matchedTerms.length >= requiredMatches;
117
+ }