@jellyos/agent 0.1.3 → 0.1.5
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 +9 -9
- package/README.npm.md +212 -0
- package/bin/jellyos-mcp +26 -0
- package/dist/api/ExtensionAPI.d.ts +6 -0
- package/dist/api/Registry.js +3 -1
- package/dist/cli.js +117 -42
- package/dist/index.d.ts +24 -1
- package/dist/index.js +19 -2
- package/dist/mcp/entry.d.ts +2 -0
- package/dist/mcp/entry.js +71 -0
- package/dist/mcp/server.d.ts +31 -0
- package/dist/mcp/server.js +128 -0
- package/dist/models/CostTracker.d.ts +66 -0
- package/dist/models/CostTracker.js +148 -0
- package/dist/models/ModelRegistry.d.ts +157 -0
- package/dist/models/ModelRegistry.js +496 -0
- package/dist/models/index.d.ts +5 -0
- package/dist/models/index.js +3 -0
- package/dist/runner/AgentRunner.d.ts +23 -2
- package/dist/runner/AgentRunner.js +264 -24
- package/dist/runner/ModelClient.d.ts +26 -6
- package/dist/runner/ModelClient.js +147 -28
- package/dist/runner/SwarmRouter.d.ts +10 -7
- package/dist/runner/SwarmRouter.js +85 -28
- package/dist/runner/ToolDispatcher.d.ts +10 -0
- package/dist/runner/ToolDispatcher.js +106 -2
- package/dist/scheduler/AgentScheduler.d.ts +118 -0
- package/dist/scheduler/AgentScheduler.js +253 -0
- package/dist/session/ContextStore.d.ts +96 -0
- package/dist/session/ContextStore.js +207 -0
- package/dist/session/GoalManager.d.ts +101 -0
- package/dist/session/GoalManager.js +167 -0
- package/dist/session/MemoryStore.d.ts +48 -0
- package/dist/session/MemoryStore.js +166 -0
- package/dist/session/SessionManager.d.ts +45 -4
- package/dist/session/SessionManager.js +151 -8
- package/dist/telemetry/Tracer.d.ts +48 -0
- package/dist/telemetry/Tracer.js +102 -0
- package/dist/tests/ContextStore.test.d.ts +2 -0
- package/dist/tests/ContextStore.test.js +74 -0
- package/dist/tests/ModelRegistry.test.d.ts +2 -0
- package/dist/tests/ModelRegistry.test.js +69 -0
- package/dist/tests/SessionManager.test.d.ts +2 -0
- package/dist/tests/SessionManager.test.js +108 -0
- package/dist/tests/TechnicalAnalysis.test.d.ts +2 -0
- package/dist/tests/TechnicalAnalysis.test.js +109 -0
- package/dist/tools/MarketSentiment.d.ts +166 -0
- package/dist/tools/MarketSentiment.js +209 -0
- package/dist/tools/NewsSentiment.d.ts +67 -0
- package/dist/tools/NewsSentiment.js +226 -0
- package/dist/tools/PriceFeed.d.ts +105 -0
- package/dist/tools/PriceFeed.js +282 -0
- package/dist/tools/TechnicalAnalysis.d.ts +110 -0
- package/dist/tools/TechnicalAnalysis.js +357 -0
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.js +4 -0
- package/dist/tui/App.d.ts +7 -5
- package/dist/tui/App.js +350 -65
- package/dist/tui/REPL.d.ts +2 -1
- package/dist/tui/REPL.js +11 -6
- package/dist/tui/StatusBar.js +1 -1
- package/package.json +9 -4
- package/dist/api/ExtensionAPI.d.ts.map +0 -1
- package/dist/api/ExtensionAPI.js.map +0 -1
- package/dist/api/Registry.d.ts.map +0 -1
- package/dist/api/Registry.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/loader.d.ts.map +0 -1
- package/dist/loader.js.map +0 -1
- package/dist/runner/AgentRunner.d.ts.map +0 -1
- package/dist/runner/AgentRunner.js.map +0 -1
- package/dist/runner/ModelClient.d.ts.map +0 -1
- package/dist/runner/ModelClient.js.map +0 -1
- package/dist/runner/SwarmRouter.d.ts.map +0 -1
- package/dist/runner/SwarmRouter.js.map +0 -1
- package/dist/runner/ToolDispatcher.d.ts.map +0 -1
- package/dist/runner/ToolDispatcher.js.map +0 -1
- package/dist/session/SessionManager.d.ts.map +0 -1
- package/dist/session/SessionManager.js.map +0 -1
- package/dist/tui/App.d.ts.map +0 -1
- package/dist/tui/App.js.map +0 -1
- package/dist/tui/REPL.d.ts.map +0 -1
- package/dist/tui/REPL.js.map +0 -1
- package/dist/tui/StatusBar.d.ts.map +0 -1
- package/dist/tui/StatusBar.js.map +0 -1
- package/dist/tui/theme.d.ts.map +0 -1
- package/dist/tui/theme.js.map +0 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NewsSentiment — fetches RSS feeds, runs headline sentiment scoring
|
|
3
|
+
* with a cheap model, and surfaces trending narratives.
|
|
4
|
+
*/
|
|
5
|
+
import { Type } from "@sinclair/typebox";
|
|
6
|
+
// ── RSS sources ───────────────────────────────────────────────────────────────
|
|
7
|
+
const FEEDS = [
|
|
8
|
+
{ name: "CoinDesk", url: "https://www.coindesk.com/arc/outboundfeeds/rss/" },
|
|
9
|
+
{ name: "CoinTelegraph", url: "https://cointelegraph.com/rss" },
|
|
10
|
+
{ name: "The Block", url: "https://www.theblock.co/rss" },
|
|
11
|
+
];
|
|
12
|
+
// ── Simple keyword-based sentiment (no model required) ────────────────────────
|
|
13
|
+
const BULLISH_WORDS = [
|
|
14
|
+
"surge", "soar", "rally", "bull", "breakout", "moon", "pump", "record high",
|
|
15
|
+
"all-time high", "ath", "accumulate", "institutional", "adoption", "approval",
|
|
16
|
+
"etf approved", "launch", "partnership", "upgrade", "mainnet", "listing",
|
|
17
|
+
"airdrop", "halving", "buyback", "burn", "yield", "staking",
|
|
18
|
+
];
|
|
19
|
+
const BEARISH_WORDS = [
|
|
20
|
+
"crash", "plunge", "dump", "bear", "downturn", "correction", "liquidate",
|
|
21
|
+
"hack", "exploit", "rug", "scam", "sec", "lawsuit", "regulation", "ban",
|
|
22
|
+
"crackdown", "fine", "penalty", "delist", "suspend", "halt", "freeze",
|
|
23
|
+
"insolvent", "bankrupt", "depeg", "exploit",
|
|
24
|
+
];
|
|
25
|
+
export function scoreSentiment(text) {
|
|
26
|
+
const lower = text.toLowerCase();
|
|
27
|
+
let score = 0;
|
|
28
|
+
let hits = 0;
|
|
29
|
+
for (const w of BULLISH_WORDS) {
|
|
30
|
+
if (lower.includes(w)) {
|
|
31
|
+
score++;
|
|
32
|
+
hits++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const w of BEARISH_WORDS) {
|
|
36
|
+
if (lower.includes(w)) {
|
|
37
|
+
score--;
|
|
38
|
+
hits++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return hits === 0 ? 0 : Math.max(-1, Math.min(1, score / hits));
|
|
42
|
+
}
|
|
43
|
+
// ── Fetch & parse ────────────────────────────────────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* Proper RSS item extractor — handles CDATA, encoded entities, attributes.
|
|
46
|
+
* No regex on the whole document; scans block by block.
|
|
47
|
+
*/
|
|
48
|
+
function extractTag(block, tag) {
|
|
49
|
+
// Try CDATA form first: <tag><![CDATA[...]]></tag>
|
|
50
|
+
const cdataRe = new RegExp(`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]>`, "i");
|
|
51
|
+
const cdataM = block.match(cdataRe);
|
|
52
|
+
if (cdataM?.[1])
|
|
53
|
+
return cdataM[1].trim();
|
|
54
|
+
// Plain text form: <tag ...>content</tag>
|
|
55
|
+
const plainRe = new RegExp(`<${tag}[^>]*>([^<]*)`, "i");
|
|
56
|
+
const plainM = block.match(plainRe);
|
|
57
|
+
if (plainM?.[1]) {
|
|
58
|
+
return plainM[1]
|
|
59
|
+
.replace(/&/g, "&")
|
|
60
|
+
.replace(/</g, "<")
|
|
61
|
+
.replace(/>/g, ">")
|
|
62
|
+
.replace(/"/g, '"')
|
|
63
|
+
.replace(/'/g, "'")
|
|
64
|
+
.trim();
|
|
65
|
+
}
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
async function fetchRSS(url, source) {
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(url, {
|
|
71
|
+
signal: AbortSignal.timeout(10_000),
|
|
72
|
+
headers: { "User-Agent": "JellyOS/1.0", "Accept": "application/rss+xml, application/xml, text/xml, */*" },
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok)
|
|
75
|
+
return [];
|
|
76
|
+
const xml = await res.text();
|
|
77
|
+
// Find all <item>...</item> blocks (also handles <entry> for Atom feeds)
|
|
78
|
+
const TAG_RE = /<(?:item|entry)[^>]*>([\s\S]*?)<\/(?:item|entry)>/gi;
|
|
79
|
+
const items = [];
|
|
80
|
+
let match;
|
|
81
|
+
while ((match = TAG_RE.exec(xml)) !== null) {
|
|
82
|
+
const block = match[1] ?? "";
|
|
83
|
+
const title = extractTag(block, "title");
|
|
84
|
+
const link = extractTag(block, "link") || extractTag(block, "guid");
|
|
85
|
+
const pubDate = extractTag(block, "pubDate") || extractTag(block, "updated") || extractTag(block, "published");
|
|
86
|
+
if (!title)
|
|
87
|
+
continue;
|
|
88
|
+
items.push({
|
|
89
|
+
title,
|
|
90
|
+
source,
|
|
91
|
+
url: link,
|
|
92
|
+
published: pubDate ? (new Date(pubDate).getTime() || Date.now()) : Date.now(),
|
|
93
|
+
sentiment: scoreSentiment(title),
|
|
94
|
+
});
|
|
95
|
+
if (items.length >= 25)
|
|
96
|
+
break; // cap per feed
|
|
97
|
+
}
|
|
98
|
+
return items;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// ── Keyword extraction ────────────────────────────────────────────────────────
|
|
105
|
+
function extractKeywords(items, topN = 10) {
|
|
106
|
+
const wordFreq = new Map();
|
|
107
|
+
const stopWords = new Set(["the", "a", "an", "in", "on", "at", "to", "for", "of", "and", "is", "are", "was", "were", "will", "has", "have", "it", "its", "with", "from", "by", "as", "be", "that", "this", "but", "or", "not", "can", "may"]);
|
|
108
|
+
for (const item of items) {
|
|
109
|
+
const words = item.title.toLowerCase().replace(/[^a-z0-9 ]/g, "").split(/\s+/);
|
|
110
|
+
for (const w of words) {
|
|
111
|
+
if (w.length < 3 || stopWords.has(w))
|
|
112
|
+
continue;
|
|
113
|
+
wordFreq.set(w, (wordFreq.get(w) ?? 0) + 1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [...wordFreq.entries()]
|
|
117
|
+
.sort((a, b) => b[1] - a[1])
|
|
118
|
+
.slice(0, topN)
|
|
119
|
+
.map(([w]) => w);
|
|
120
|
+
}
|
|
121
|
+
// ── NewsFeed class ────────────────────────────────────────────────────────────
|
|
122
|
+
export class NewsFeed {
|
|
123
|
+
lastReport = null;
|
|
124
|
+
pollInterval;
|
|
125
|
+
timer;
|
|
126
|
+
constructor(pollIntervalMs = 600_000) {
|
|
127
|
+
this.pollInterval = pollIntervalMs;
|
|
128
|
+
}
|
|
129
|
+
start() {
|
|
130
|
+
this.fetch();
|
|
131
|
+
this.timer = setInterval(() => this.fetch(), this.pollInterval);
|
|
132
|
+
}
|
|
133
|
+
stop() {
|
|
134
|
+
if (this.timer) {
|
|
135
|
+
clearInterval(this.timer);
|
|
136
|
+
this.timer = undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async fetch() {
|
|
140
|
+
const allItems = [];
|
|
141
|
+
for (const feed of FEEDS) {
|
|
142
|
+
const items = await fetchRSS(feed.url, feed.name);
|
|
143
|
+
allItems.push(...items);
|
|
144
|
+
}
|
|
145
|
+
allItems.sort((a, b) => b.published - a.published);
|
|
146
|
+
const scored = allItems.filter(i => i.sentiment !== undefined && i.sentiment !== 0);
|
|
147
|
+
const avgSent = scored.length > 0 ? scored.reduce((s, i) => s + (i.sentiment ?? 0), 0) / scored.length : 0;
|
|
148
|
+
const positive = allItems.filter(i => (i.sentiment ?? 0) > 0.1).length;
|
|
149
|
+
const negative = allItems.filter(i => (i.sentiment ?? 0) < -0.1).length;
|
|
150
|
+
const neutral = allItems.length - positive - negative;
|
|
151
|
+
const topKeywords = extractKeywords(allItems);
|
|
152
|
+
this.lastReport = {
|
|
153
|
+
items: allItems,
|
|
154
|
+
avgSentiment: Math.round(avgSent * 1000) / 1000,
|
|
155
|
+
positive,
|
|
156
|
+
negative,
|
|
157
|
+
neutral,
|
|
158
|
+
updatedAt: Date.now(),
|
|
159
|
+
topKeywords,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
getLatest() {
|
|
163
|
+
return this.lastReport;
|
|
164
|
+
}
|
|
165
|
+
summary() {
|
|
166
|
+
if (!this.lastReport)
|
|
167
|
+
return "No news data available.";
|
|
168
|
+
const r = this.lastReport;
|
|
169
|
+
const score = r.avgSentiment;
|
|
170
|
+
const mood = score > 0.2 ? "🟢 Bullish" : score < -0.2 ? "🔴 Bearish" : "🟡 Neutral";
|
|
171
|
+
return [
|
|
172
|
+
`News Sentiment: ${mood} (score: ${score >= 0 ? "+" : ""}${score.toFixed(3)})`,
|
|
173
|
+
` ${r.positive} positive · ${r.negative} negative · ${r.neutral} neutral (${r.items.length} articles)`,
|
|
174
|
+
` Trending: ${r.topKeywords.slice(0, 8).join(", ")}`,
|
|
175
|
+
` Latest:`,
|
|
176
|
+
...r.items.slice(0, 5).map(i => {
|
|
177
|
+
const s = (i.sentiment ?? 0) >= 0.1 ? "🟢" : (i.sentiment ?? 0) <= -0.1 ? "🔴" : " ";
|
|
178
|
+
return ` ${s} [${i.source}] ${i.title.slice(0, 80)}`;
|
|
179
|
+
}),
|
|
180
|
+
].join("\n");
|
|
181
|
+
}
|
|
182
|
+
/** Compact sentiment badge for status bar. */
|
|
183
|
+
statusBadge() {
|
|
184
|
+
if (!this.lastReport)
|
|
185
|
+
return "📰 ?";
|
|
186
|
+
const s = this.lastReport.avgSentiment;
|
|
187
|
+
const emoji = s > 0.3 ? "🟢" : s > 0 ? "🟡" : "🔴";
|
|
188
|
+
return `📰 ${emoji} ${(s * 100).toFixed(0)}%`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// ── Singleton ─────────────────────────────────────────────────────────────────
|
|
192
|
+
export const newsFeed = new NewsFeed();
|
|
193
|
+
// ── Tool: get_news ────────────────────────────────────────────────────────────
|
|
194
|
+
export const getNewsParams = Type.Object({
|
|
195
|
+
limit: Type.Optional(Type.Number({ default: 10, description: "Number of articles to show" })),
|
|
196
|
+
});
|
|
197
|
+
export async function getNewsTool(_id, params) {
|
|
198
|
+
const report = newsFeed.getLatest();
|
|
199
|
+
if (!report) {
|
|
200
|
+
return {
|
|
201
|
+
content: [{ type: "text", text: "News data not yet available. Please wait for the first fetch." }],
|
|
202
|
+
details: {},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const items = report.items.slice(0, params.limit ?? 10)
|
|
206
|
+
.map(i => {
|
|
207
|
+
const s = (i.sentiment ?? 0) >= 0.1 ? "+" : (i.sentiment ?? 0) <= -0.1 ? "-" : " ";
|
|
208
|
+
return `${s} [${i.source}] ${i.title}`;
|
|
209
|
+
})
|
|
210
|
+
.join("\n");
|
|
211
|
+
return {
|
|
212
|
+
content: [{
|
|
213
|
+
type: "text",
|
|
214
|
+
text: `News Sentiment: ${report.avgSentiment >= 0 ? "+" : ""}${(report.avgSentiment * 100).toFixed(0)}% sentiment · ${report.positive}p/${report.negative}n/${report.neutral}·\nTrending: ${report.topKeywords.join(", ")}\n\n${items}`,
|
|
215
|
+
}],
|
|
216
|
+
details: {
|
|
217
|
+
avgSentiment: report.avgSentiment,
|
|
218
|
+
positive: report.positive,
|
|
219
|
+
negative: report.negative,
|
|
220
|
+
neutral: report.neutral,
|
|
221
|
+
articleCount: report.items.length,
|
|
222
|
+
topKeywords: report.topKeywords,
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=NewsSentiment.js.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PriceFeed — real-time price aggregation from CoinGecko and Binance.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Background polling with configurable intervals
|
|
6
|
+
* - Multi-source aggregation (prioritises Binance for precision, CG for breadth)
|
|
7
|
+
* - 24h change, volume, and market cap tracking
|
|
8
|
+
* - In-memory price cache for tool lookups
|
|
9
|
+
* - Exposed as tools for the AI to query
|
|
10
|
+
*/
|
|
11
|
+
import { type Static } from "@sinclair/typebox";
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
export interface PriceTick {
|
|
14
|
+
symbol: string;
|
|
15
|
+
price: number;
|
|
16
|
+
change24h: number;
|
|
17
|
+
volume24h: number;
|
|
18
|
+
marketCap: number;
|
|
19
|
+
high24h: number;
|
|
20
|
+
low24h: number;
|
|
21
|
+
source: "coingecko" | "binance";
|
|
22
|
+
timestamp: number;
|
|
23
|
+
}
|
|
24
|
+
export interface CoinGeckoCoin {
|
|
25
|
+
id: string;
|
|
26
|
+
symbol: string;
|
|
27
|
+
name: string;
|
|
28
|
+
current_price: number;
|
|
29
|
+
price_change_percentage_24h: number;
|
|
30
|
+
market_cap: number;
|
|
31
|
+
total_volume: number;
|
|
32
|
+
high_24h: number;
|
|
33
|
+
low_24h: number;
|
|
34
|
+
}
|
|
35
|
+
export declare class PriceFeed extends EventEmitter {
|
|
36
|
+
private prices;
|
|
37
|
+
private pollInterval;
|
|
38
|
+
private timer?;
|
|
39
|
+
private tracking;
|
|
40
|
+
constructor(pollIntervalMs?: number);
|
|
41
|
+
start(): void;
|
|
42
|
+
stop(): void;
|
|
43
|
+
/** Add symbols to track. IDs are auto-resolved from the symbol map. */
|
|
44
|
+
track(...symbols: string[]): void;
|
|
45
|
+
fetchAll(): Promise<void>;
|
|
46
|
+
private fetchBinance;
|
|
47
|
+
private fetchCoinGecko;
|
|
48
|
+
get(symbol: string): PriceTick | undefined;
|
|
49
|
+
getMultiple(symbols: string[]): PriceTick[];
|
|
50
|
+
getAll(): PriceTick[];
|
|
51
|
+
getTopMovers(limit?: number): PriceTick[];
|
|
52
|
+
formatPrice(tick: PriceTick): string;
|
|
53
|
+
/** Compact ticker line for the status bar. */
|
|
54
|
+
tickerLine(maxSymbols?: number): string;
|
|
55
|
+
}
|
|
56
|
+
export declare const priceFeed: PriceFeed;
|
|
57
|
+
export declare const getPricesParams: import("@sinclair/typebox").TObject<{
|
|
58
|
+
symbols: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
|
|
59
|
+
}>;
|
|
60
|
+
export declare function getPricesTool(_id: string, params: Static<typeof getPricesParams>): Promise<{
|
|
61
|
+
content: {
|
|
62
|
+
type: "text";
|
|
63
|
+
text: string;
|
|
64
|
+
}[];
|
|
65
|
+
details: Record<string, unknown>;
|
|
66
|
+
}>;
|
|
67
|
+
export declare const topMoversParams: import("@sinclair/typebox").TObject<{
|
|
68
|
+
limit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
|
|
69
|
+
}>;
|
|
70
|
+
export declare function topMoversTool(_id: string, params: Static<typeof topMoversParams>): Promise<{
|
|
71
|
+
content: {
|
|
72
|
+
type: "text";
|
|
73
|
+
text: string;
|
|
74
|
+
}[];
|
|
75
|
+
details: {
|
|
76
|
+
count: number;
|
|
77
|
+
};
|
|
78
|
+
}>;
|
|
79
|
+
export declare const marketOverviewParams: import("@sinclair/typebox").TObject<{}>;
|
|
80
|
+
export declare function marketOverviewTool(): Promise<{
|
|
81
|
+
content: {
|
|
82
|
+
type: "text";
|
|
83
|
+
text: string;
|
|
84
|
+
}[];
|
|
85
|
+
details: {
|
|
86
|
+
totalMarketCap?: undefined;
|
|
87
|
+
avgChange24h?: undefined;
|
|
88
|
+
gainers?: undefined;
|
|
89
|
+
losers?: undefined;
|
|
90
|
+
assets?: undefined;
|
|
91
|
+
};
|
|
92
|
+
} | {
|
|
93
|
+
content: {
|
|
94
|
+
type: "text";
|
|
95
|
+
text: string;
|
|
96
|
+
}[];
|
|
97
|
+
details: {
|
|
98
|
+
totalMarketCap: number;
|
|
99
|
+
avgChange24h: number;
|
|
100
|
+
gainers: number;
|
|
101
|
+
losers: number;
|
|
102
|
+
assets: number;
|
|
103
|
+
};
|
|
104
|
+
}>;
|
|
105
|
+
//# sourceMappingURL=PriceFeed.d.ts.map
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PriceFeed — real-time price aggregation from CoinGecko and Binance.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Background polling with configurable intervals
|
|
6
|
+
* - Multi-source aggregation (prioritises Binance for precision, CG for breadth)
|
|
7
|
+
* - 24h change, volume, and market cap tracking
|
|
8
|
+
* - In-memory price cache for tool lookups
|
|
9
|
+
* - Exposed as tools for the AI to query
|
|
10
|
+
*/
|
|
11
|
+
import { Type } from "@sinclair/typebox";
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
// ── CoinGecko ID map (common symbols) ────────────────────────────────────────
|
|
14
|
+
const SYMBOL_TO_ID = {
|
|
15
|
+
btc: "bitcoin",
|
|
16
|
+
eth: "ethereum",
|
|
17
|
+
sol: "solana",
|
|
18
|
+
bnb: "binancecoin",
|
|
19
|
+
xrp: "ripple",
|
|
20
|
+
ada: "cardano",
|
|
21
|
+
doge: "dogecoin",
|
|
22
|
+
dot: "polkadot",
|
|
23
|
+
matic: "matic-network",
|
|
24
|
+
avax: "avalanche-2",
|
|
25
|
+
link: "chainlink",
|
|
26
|
+
uni: "uniswap",
|
|
27
|
+
atom: "cosmos",
|
|
28
|
+
arb: "arbitrum",
|
|
29
|
+
op: "optimism",
|
|
30
|
+
near: "near",
|
|
31
|
+
apt: "aptos",
|
|
32
|
+
sui: "sui",
|
|
33
|
+
sei: "sei-network",
|
|
34
|
+
inj: "injective-protocol",
|
|
35
|
+
tia: "celestia",
|
|
36
|
+
wld: "worldcoin-wld",
|
|
37
|
+
pepe: "pepe",
|
|
38
|
+
wif: "dogwifcoin",
|
|
39
|
+
bonk: "bonk",
|
|
40
|
+
floki: "floki",
|
|
41
|
+
shib: "shiba-inu",
|
|
42
|
+
aave: "aave",
|
|
43
|
+
mkr: "maker",
|
|
44
|
+
ldo: "lido-dao",
|
|
45
|
+
ens: "ethereum-name-service",
|
|
46
|
+
pendle: "pendle",
|
|
47
|
+
ltc: "litecoin",
|
|
48
|
+
bch: "bitcoin-cash",
|
|
49
|
+
etc: "ethereum-classic",
|
|
50
|
+
fil: "filecoin",
|
|
51
|
+
trx: "tron",
|
|
52
|
+
usdc: "usd-coin",
|
|
53
|
+
usdt: "tether",
|
|
54
|
+
dai: "dai",
|
|
55
|
+
};
|
|
56
|
+
// ── PriceFeed class ──────────────────────────────────────────────────────────
|
|
57
|
+
export class PriceFeed extends EventEmitter {
|
|
58
|
+
prices = new Map();
|
|
59
|
+
pollInterval;
|
|
60
|
+
timer;
|
|
61
|
+
tracking = new Set(["btc", "eth", "sol", "bnb", "matic", "arb", "op", "avax"]);
|
|
62
|
+
constructor(pollIntervalMs = 60_000) {
|
|
63
|
+
super();
|
|
64
|
+
this.pollInterval = pollIntervalMs;
|
|
65
|
+
}
|
|
66
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
67
|
+
start() {
|
|
68
|
+
this.fetchAll();
|
|
69
|
+
this.timer = setInterval(() => this.fetchAll(), this.pollInterval);
|
|
70
|
+
}
|
|
71
|
+
stop() {
|
|
72
|
+
if (this.timer) {
|
|
73
|
+
clearInterval(this.timer);
|
|
74
|
+
this.timer = undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Add symbols to track. IDs are auto-resolved from the symbol map. */
|
|
78
|
+
track(...symbols) {
|
|
79
|
+
for (const s of symbols)
|
|
80
|
+
this.tracking.add(s.toLowerCase());
|
|
81
|
+
}
|
|
82
|
+
// ── Fetch ────────────────────────────────────────────────────────────────
|
|
83
|
+
async fetchAll() {
|
|
84
|
+
// #17: Fan-out to Binance (primary) + CoinGecko (secondary)
|
|
85
|
+
// Binance: no rate limits on public endpoints, real-time order book prices
|
|
86
|
+
// CoinGecko: covers long-tail tokens Binance doesn't list, includes market cap
|
|
87
|
+
const [binanceResult, cgResult] = await Promise.allSettled([
|
|
88
|
+
this.fetchBinance(),
|
|
89
|
+
this.fetchCoinGecko(),
|
|
90
|
+
]);
|
|
91
|
+
let updated = false;
|
|
92
|
+
// Binance wins for price precision on listed pairs
|
|
93
|
+
if (binanceResult.status === "fulfilled") {
|
|
94
|
+
for (const tick of binanceResult.value) {
|
|
95
|
+
this.prices.set(tick.symbol.toLowerCase(), tick);
|
|
96
|
+
updated = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// CoinGecko fills gaps (market cap data, unlisted tokens)
|
|
100
|
+
if (cgResult.status === "fulfilled") {
|
|
101
|
+
for (const tick of cgResult.value) {
|
|
102
|
+
const key = tick.symbol.toLowerCase();
|
|
103
|
+
const existing = this.prices.get(key);
|
|
104
|
+
if (!existing) {
|
|
105
|
+
// Token not on Binance — add from CoinGecko
|
|
106
|
+
this.prices.set(key, tick);
|
|
107
|
+
updated = true;
|
|
108
|
+
}
|
|
109
|
+
else if (tick.marketCap > 0) {
|
|
110
|
+
// Enrich Binance tick with market cap from CoinGecko
|
|
111
|
+
existing.marketCap = tick.marketCap;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (updated)
|
|
116
|
+
this.emit("prices", [...this.prices.values()]);
|
|
117
|
+
}
|
|
118
|
+
async fetchBinance() {
|
|
119
|
+
// Build list of USDT pairs for tracked symbols
|
|
120
|
+
const pairs = [...this.tracking]
|
|
121
|
+
.map(s => s.toUpperCase() + "USDT")
|
|
122
|
+
.filter(s => !s.startsWith("USDT") && !s.startsWith("DAI")); // skip stablecoins
|
|
123
|
+
if (!pairs.length)
|
|
124
|
+
return [];
|
|
125
|
+
const url = `https://api.binance.com/api/v3/ticker/24hr?symbols=${encodeURIComponent(JSON.stringify(pairs))}`;
|
|
126
|
+
const res = await fetch(url, {
|
|
127
|
+
signal: AbortSignal.timeout(8_000),
|
|
128
|
+
headers: { "User-Agent": "JellyOS/1.0" },
|
|
129
|
+
});
|
|
130
|
+
if (!res.ok)
|
|
131
|
+
throw new Error(`Binance ticker HTTP ${res.status}`);
|
|
132
|
+
const data = await res.json();
|
|
133
|
+
return data.map(d => ({
|
|
134
|
+
symbol: d.symbol.replace(/USDT$/, ""),
|
|
135
|
+
price: parseFloat(d.lastPrice),
|
|
136
|
+
change24h: parseFloat(d.priceChangePercent),
|
|
137
|
+
volume24h: parseFloat(d.quoteVolume),
|
|
138
|
+
marketCap: 0, // Binance doesn't provide market cap — CoinGecko fills this
|
|
139
|
+
high24h: parseFloat(d.highPrice),
|
|
140
|
+
low24h: parseFloat(d.lowPrice),
|
|
141
|
+
source: "binance",
|
|
142
|
+
timestamp: Date.now(),
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
async fetchCoinGecko() {
|
|
146
|
+
const ids = [...this.tracking]
|
|
147
|
+
.map(s => SYMBOL_TO_ID[s])
|
|
148
|
+
.filter((id) => Boolean(id))
|
|
149
|
+
.join(",");
|
|
150
|
+
if (!ids)
|
|
151
|
+
return [];
|
|
152
|
+
const res = await fetch(`https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=${ids}&order=market_cap_desc&sparkline=false`, { signal: AbortSignal.timeout(10_000), headers: { "User-Agent": "JellyOS/1.0" } });
|
|
153
|
+
if (!res.ok)
|
|
154
|
+
throw new Error(`CoinGecko HTTP ${res.status}`);
|
|
155
|
+
const data = await res.json();
|
|
156
|
+
return data.map(coin => ({
|
|
157
|
+
symbol: coin.symbol.toUpperCase(),
|
|
158
|
+
price: coin.current_price,
|
|
159
|
+
change24h: coin.price_change_percentage_24h ?? 0,
|
|
160
|
+
volume24h: coin.total_volume ?? 0,
|
|
161
|
+
marketCap: coin.market_cap ?? 0,
|
|
162
|
+
high24h: coin.high_24h ?? 0,
|
|
163
|
+
low24h: coin.low_24h ?? 0,
|
|
164
|
+
source: "coingecko",
|
|
165
|
+
timestamp: Date.now(),
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
// ── Query ─────────────────────────────────────────────────────────────────
|
|
169
|
+
get(symbol) {
|
|
170
|
+
return this.prices.get(symbol.toLowerCase());
|
|
171
|
+
}
|
|
172
|
+
getMultiple(symbols) {
|
|
173
|
+
return symbols.map(s => this.get(s)).filter(Boolean);
|
|
174
|
+
}
|
|
175
|
+
getAll() {
|
|
176
|
+
return [...this.prices.values()];
|
|
177
|
+
}
|
|
178
|
+
getTopMovers(limit = 5) {
|
|
179
|
+
return [...this.prices.values()]
|
|
180
|
+
.sort((a, b) => Math.abs(b.change24h) - Math.abs(a.change24h))
|
|
181
|
+
.slice(0, limit);
|
|
182
|
+
}
|
|
183
|
+
formatPrice(tick) {
|
|
184
|
+
const changeStr = tick.change24h >= 0
|
|
185
|
+
? `+${tick.change24h.toFixed(2)}%`
|
|
186
|
+
: `${tick.change24h.toFixed(2)}%`;
|
|
187
|
+
const priceStr = tick.price < 1
|
|
188
|
+
? `$${tick.price.toFixed(6)}`
|
|
189
|
+
: tick.price < 100
|
|
190
|
+
? `$${tick.price.toFixed(2)}`
|
|
191
|
+
: `$${tick.price.toLocaleString()}`;
|
|
192
|
+
return `${tick.symbol.padEnd(6)} ${priceStr} ${changeStr}`;
|
|
193
|
+
}
|
|
194
|
+
/** Compact ticker line for the status bar. */
|
|
195
|
+
tickerLine(maxSymbols = 5) {
|
|
196
|
+
return [...this.prices.values()]
|
|
197
|
+
.slice(0, maxSymbols)
|
|
198
|
+
.map(t => {
|
|
199
|
+
const sign = t.change24h >= 0 ? "▲" : "▼";
|
|
200
|
+
const p = t.price < 1 ? t.price.toFixed(4) : t.price.toFixed(0);
|
|
201
|
+
return `${t.symbol} $${p} ${sign}${Math.abs(t.change24h).toFixed(1)}%`;
|
|
202
|
+
})
|
|
203
|
+
.join(" · ");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// ── Singleton ─────────────────────────────────────────────────────────────────
|
|
207
|
+
export const priceFeed = new PriceFeed();
|
|
208
|
+
// ── Tool: get_prices ─────────────────────────────────────────────────────────
|
|
209
|
+
export const getPricesParams = Type.Object({
|
|
210
|
+
symbols: Type.Array(Type.String(), { description: "Symbols to fetch (e.g. btc, eth, sol)" }),
|
|
211
|
+
});
|
|
212
|
+
export async function getPricesTool(_id, params) {
|
|
213
|
+
const ticks = priceFeed.getAll();
|
|
214
|
+
if (ticks.length === 0) {
|
|
215
|
+
return {
|
|
216
|
+
content: [{ type: "text", text: "No price data available yet. Please wait for the first price update." }],
|
|
217
|
+
details: {},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const requested = params.symbols.map(s => s.toLowerCase());
|
|
221
|
+
const results = ticks.filter(t => requested.includes(t.symbol.toLowerCase()));
|
|
222
|
+
if (results.length === 0) {
|
|
223
|
+
return {
|
|
224
|
+
content: [{ type: "text", text: `No data for: ${params.symbols.join(", ")}. Available: ${ticks.map(t => t.symbol).join(", ")}` }],
|
|
225
|
+
details: {},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const text = results.map(t => priceFeed.formatPrice(t)).join("\n");
|
|
229
|
+
return {
|
|
230
|
+
content: [{ type: "text", text }],
|
|
231
|
+
details: results.reduce((acc, t) => {
|
|
232
|
+
acc[t.symbol] = { price: t.price, change24h: t.change24h, volume24h: t.volume24h, marketCap: t.marketCap, source: t.source };
|
|
233
|
+
return acc;
|
|
234
|
+
}, {}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
// ── Tool: get_top_movers ─────────────────────────────────────────────────────
|
|
238
|
+
export const topMoversParams = Type.Object({
|
|
239
|
+
limit: Type.Optional(Type.Number({ default: 5, description: "Number of top movers to show" })),
|
|
240
|
+
});
|
|
241
|
+
export async function topMoversTool(_id, params) {
|
|
242
|
+
const movers = priceFeed.getTopMovers(params.limit ?? 5);
|
|
243
|
+
const text = movers.map(t => priceFeed.formatPrice(t)).join("\n");
|
|
244
|
+
return {
|
|
245
|
+
content: [{ type: "text", text: text || "No data available" }],
|
|
246
|
+
details: { count: movers.length },
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
// ── Tool: get_market_overview ────────────────────────────────────────────────
|
|
250
|
+
export const marketOverviewParams = Type.Object({});
|
|
251
|
+
export async function marketOverviewTool() {
|
|
252
|
+
const ticks = priceFeed.getAll();
|
|
253
|
+
if (ticks.length === 0) {
|
|
254
|
+
return {
|
|
255
|
+
content: [{ type: "text", text: "No market data available yet." }],
|
|
256
|
+
details: {},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const totalCap = ticks.reduce((s, t) => s + t.marketCap, 0);
|
|
260
|
+
const avgChange = ticks.reduce((s, t) => s + t.change24h, 0) / ticks.length;
|
|
261
|
+
const gainers = ticks.filter(t => t.change24h > 0).length;
|
|
262
|
+
const losers = ticks.filter(t => t.change24h < 0).length;
|
|
263
|
+
const text = [
|
|
264
|
+
`Market Overview (${ticks.length} assets tracked)`,
|
|
265
|
+
`Total Market Cap: $${(totalCap / 1e9).toFixed(1)}B`,
|
|
266
|
+
`Avg 24h Change: ${avgChange >= 0 ? "+" : ""}${avgChange.toFixed(2)}%`,
|
|
267
|
+
`Gainers: ${gainers} | Losers: ${losers}`,
|
|
268
|
+
`---`,
|
|
269
|
+
...ticks.slice(0, 10).map(t => priceFeed.formatPrice(t)),
|
|
270
|
+
].join("\n");
|
|
271
|
+
return {
|
|
272
|
+
content: [{ type: "text", text }],
|
|
273
|
+
details: {
|
|
274
|
+
totalMarketCap: totalCap,
|
|
275
|
+
avgChange24h: avgChange,
|
|
276
|
+
gainers,
|
|
277
|
+
losers,
|
|
278
|
+
assets: ticks.length,
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
//# sourceMappingURL=PriceFeed.js.map
|