simmer-reactor 0.1.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/dist/dispatcher.d.ts +24 -0
- package/dist/dispatcher.js +121 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +232 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.js +9 -0
- package/dist/websocket.d.ts +23 -0
- package/dist/websocket.js +117 -0
- package/openclaw.plugin.json +42 -0
- package/package.json +28 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PluginConfig, PluginLogger, PluginRuntime, WhaleEvent } from "./types.js";
|
|
2
|
+
export declare class Dispatcher {
|
|
3
|
+
private config;
|
|
4
|
+
private runtime;
|
|
5
|
+
private logger;
|
|
6
|
+
private buffer;
|
|
7
|
+
private syncQueue;
|
|
8
|
+
private consecutiveFailures;
|
|
9
|
+
private autoReactPaused;
|
|
10
|
+
private executing;
|
|
11
|
+
constructor(config: PluginConfig, runtime: PluginRuntime, logger: PluginLogger);
|
|
12
|
+
handleEvent(event: WhaleEvent): Promise<void>;
|
|
13
|
+
private addToBuffer;
|
|
14
|
+
private executeAutoReact;
|
|
15
|
+
private onFailure;
|
|
16
|
+
getPromptInjection(): string | null;
|
|
17
|
+
/** Drain the sync queue (for API POST). Returns events and clears the queue. */
|
|
18
|
+
drainSyncQueue(): WhaleEvent[];
|
|
19
|
+
getRecentEvents(n: number): WhaleEvent[];
|
|
20
|
+
get isAutoReactPaused(): boolean;
|
|
21
|
+
get failureCount(): number;
|
|
22
|
+
resumeAutoReact(): void;
|
|
23
|
+
updateConfig(config: PluginConfig): void;
|
|
24
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const SKILL_TIMEOUT_MS = 60_000;
|
|
2
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
3
|
+
const SYNC_QUEUE_CAP = 500;
|
|
4
|
+
export class Dispatcher {
|
|
5
|
+
config;
|
|
6
|
+
runtime;
|
|
7
|
+
logger;
|
|
8
|
+
buffer = [];
|
|
9
|
+
syncQueue = []; // separate queue for API sync (not cleared by prompt injection)
|
|
10
|
+
consecutiveFailures = 0;
|
|
11
|
+
autoReactPaused = false;
|
|
12
|
+
executing = false;
|
|
13
|
+
constructor(config, runtime, logger) {
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.runtime = runtime;
|
|
16
|
+
this.logger = logger;
|
|
17
|
+
}
|
|
18
|
+
async handleEvent(event) {
|
|
19
|
+
this.addToBuffer(event);
|
|
20
|
+
this.syncQueue.push(event);
|
|
21
|
+
while (this.syncQueue.length > SYNC_QUEUE_CAP) {
|
|
22
|
+
this.syncQueue.shift();
|
|
23
|
+
}
|
|
24
|
+
if (this.config.dispatch_mode === "autoReact" && !this.autoReactPaused) {
|
|
25
|
+
await this.executeAutoReact(event);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
addToBuffer(event) {
|
|
29
|
+
this.buffer.push(event);
|
|
30
|
+
while (this.buffer.length > this.config.buffer_max) {
|
|
31
|
+
this.buffer.shift();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async executeAutoReact(event) {
|
|
35
|
+
if (this.executing) {
|
|
36
|
+
this.logger.info("[reactor] autoReact already executing, skipping");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.executing = true;
|
|
40
|
+
try {
|
|
41
|
+
const argv = [
|
|
42
|
+
"npx", "clawhub", "run", this.config.autoReact_skill,
|
|
43
|
+
"--wallets", event.whale_wallet,
|
|
44
|
+
"--live",
|
|
45
|
+
];
|
|
46
|
+
this.logger.info(`[reactor] autoReact: running ${this.config.autoReact_skill} for ${event.whale_label || event.whale_wallet} ${event.side} $${event.size}`);
|
|
47
|
+
const result = await this.runtime.system.runCommandWithTimeout(argv, {
|
|
48
|
+
timeoutMs: SKILL_TIMEOUT_MS,
|
|
49
|
+
env: {
|
|
50
|
+
...process.env,
|
|
51
|
+
REACTOR_EVENT_WALLET: event.whale_wallet,
|
|
52
|
+
REACTOR_EVENT_MARKET_SLUG: event.market_slug || "",
|
|
53
|
+
REACTOR_EVENT_SIDE: event.side,
|
|
54
|
+
REACTOR_EVENT_SIZE: String(event.size),
|
|
55
|
+
REACTOR_EVENT_PRICE: String(event.price),
|
|
56
|
+
REACTOR_MAX_USD: String(this.config.autoReact_max_usd),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
if (result.code === 0) {
|
|
60
|
+
this.logger.info(`[reactor] autoReact: skill completed successfully`);
|
|
61
|
+
this.consecutiveFailures = 0;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
this.logger.warn(`[reactor] autoReact: skill failed (code=${result.code}, termination=${result.termination})`);
|
|
65
|
+
this.onFailure();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
this.logger.error(`[reactor] autoReact: execution error: ${e}`);
|
|
70
|
+
this.onFailure();
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
this.executing = false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
onFailure() {
|
|
77
|
+
this.consecutiveFailures++;
|
|
78
|
+
if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
79
|
+
this.logger.warn(`[reactor] autoReact paused after ${this.consecutiveFailures} consecutive failures — falling back to passive`);
|
|
80
|
+
this.autoReactPaused = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
getPromptInjection() {
|
|
84
|
+
if (this.buffer.length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
const lines = this.buffer.map((e) => {
|
|
87
|
+
const label = e.whale_label ? ` (${e.whale_label})` : "";
|
|
88
|
+
const market = e.market_title || e.market_slug || "unknown market";
|
|
89
|
+
return `- ${e.whale_wallet.slice(0, 8)}...${label} ${e.side} $${e.size.toLocaleString()} on "${market}" @ ${e.price}`;
|
|
90
|
+
});
|
|
91
|
+
const header = `🐋 Reactor: ${this.buffer.length} whale event${this.buffer.length === 1 ? "" : "s"} since last cycle`;
|
|
92
|
+
const body = lines.join("\n");
|
|
93
|
+
const skills = `\nYour installed skills can act on these events (e.g., polymarket-copytrading).`;
|
|
94
|
+
const footer = `\nReact or ignore?`;
|
|
95
|
+
this.buffer = [];
|
|
96
|
+
return `${header}\n${body}${skills}${footer}`;
|
|
97
|
+
}
|
|
98
|
+
/** Drain the sync queue (for API POST). Returns events and clears the queue. */
|
|
99
|
+
drainSyncQueue() {
|
|
100
|
+
const events = [...this.syncQueue];
|
|
101
|
+
this.syncQueue = [];
|
|
102
|
+
return events;
|
|
103
|
+
}
|
|
104
|
+
getRecentEvents(n) {
|
|
105
|
+
return this.buffer.slice(-n);
|
|
106
|
+
}
|
|
107
|
+
get isAutoReactPaused() {
|
|
108
|
+
return this.autoReactPaused;
|
|
109
|
+
}
|
|
110
|
+
get failureCount() {
|
|
111
|
+
return this.consecutiveFailures;
|
|
112
|
+
}
|
|
113
|
+
resumeAutoReact() {
|
|
114
|
+
this.autoReactPaused = false;
|
|
115
|
+
this.consecutiveFailures = 0;
|
|
116
|
+
this.logger.info("[reactor] autoReact resumed");
|
|
117
|
+
}
|
|
118
|
+
updateConfig(config) {
|
|
119
|
+
this.config = config;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { DEFAULT_CONFIG } from "./types.js";
|
|
2
|
+
const EVENT_SYNC_INTERVAL_MS = 30_000;
|
|
3
|
+
import { PolyNodeWsClient } from "./websocket.js";
|
|
4
|
+
import { Dispatcher } from "./dispatcher.js";
|
|
5
|
+
// ---- State ----
|
|
6
|
+
let pluginConfig = { ...DEFAULT_CONFIG };
|
|
7
|
+
let reactorConfig = { polynode_api_key: null, watchlist: [] };
|
|
8
|
+
let status = "unconfigured";
|
|
9
|
+
let wsClient = null;
|
|
10
|
+
let dispatcher = null;
|
|
11
|
+
let configRefreshTimer = null;
|
|
12
|
+
let eventSyncTimer = null;
|
|
13
|
+
let totalEventsReceived = 0;
|
|
14
|
+
let lastEventTime = null;
|
|
15
|
+
// ---- Config ----
|
|
16
|
+
function loadPluginConfig(raw) {
|
|
17
|
+
if (!raw)
|
|
18
|
+
return;
|
|
19
|
+
if (raw.dispatch_mode === "autoReact" || raw.dispatch_mode === "passive") {
|
|
20
|
+
pluginConfig.dispatch_mode = raw.dispatch_mode;
|
|
21
|
+
}
|
|
22
|
+
if (typeof raw.autoReact_skill === "string")
|
|
23
|
+
pluginConfig.autoReact_skill = raw.autoReact_skill;
|
|
24
|
+
if (typeof raw.autoReact_max_usd === "number")
|
|
25
|
+
pluginConfig.autoReact_max_usd = raw.autoReact_max_usd;
|
|
26
|
+
if (typeof raw.buffer_max === "number")
|
|
27
|
+
pluginConfig.buffer_max = raw.buffer_max;
|
|
28
|
+
if (typeof raw.reconnect_interval_ms === "number")
|
|
29
|
+
pluginConfig.reconnect_interval_ms = raw.reconnect_interval_ms;
|
|
30
|
+
if (typeof raw.config_refresh_interval_ms === "number")
|
|
31
|
+
pluginConfig.config_refresh_interval_ms = raw.config_refresh_interval_ms;
|
|
32
|
+
}
|
|
33
|
+
async function fetchReactorConfig(logger) {
|
|
34
|
+
const apiKey = process.env.SIMMER_API_KEY;
|
|
35
|
+
const apiUrl = process.env.SIMMER_API_URL || "https://api.simmer.markets";
|
|
36
|
+
if (!apiKey) {
|
|
37
|
+
logger.warn("[reactor] SIMMER_API_KEY not set — cannot fetch config");
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`${apiUrl}/api/sdk/reactor/config`, {
|
|
42
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
logger.warn(`[reactor] Config fetch failed: ${res.status}`);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const data = (await res.json());
|
|
49
|
+
reactorConfig = data;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
logger.error(`[reactor] Config fetch error: ${e}`);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function syncEventsToApi(events, logger) {
|
|
58
|
+
const apiKey = process.env.SIMMER_API_KEY;
|
|
59
|
+
const apiUrl = process.env.SIMMER_API_URL || "https://api.simmer.markets";
|
|
60
|
+
if (!apiKey || events.length === 0)
|
|
61
|
+
return;
|
|
62
|
+
try {
|
|
63
|
+
await fetch(`${apiUrl}/api/sdk/reactor/events`, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: {
|
|
66
|
+
Authorization: `Bearer ${apiKey}`,
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify({ events }),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
logger.warn(`[reactor] Event sync failed: ${e}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// ---- WS Lifecycle ----
|
|
77
|
+
function startWs(runtime, logger) {
|
|
78
|
+
if (!reactorConfig.polynode_api_key || reactorConfig.watchlist.length === 0) {
|
|
79
|
+
status = "unconfigured";
|
|
80
|
+
logger.info("[reactor] No API key or empty watchlist — status: unconfigured");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Stop existing WS if any (prevents leaking connections on re-init)
|
|
84
|
+
if (wsClient) {
|
|
85
|
+
wsClient.stop();
|
|
86
|
+
wsClient = null;
|
|
87
|
+
}
|
|
88
|
+
status = "connecting";
|
|
89
|
+
if (!dispatcher) {
|
|
90
|
+
dispatcher = new Dispatcher(pluginConfig, runtime, logger);
|
|
91
|
+
}
|
|
92
|
+
wsClient = new PolyNodeWsClient({
|
|
93
|
+
apiKey: reactorConfig.polynode_api_key,
|
|
94
|
+
watchlist: reactorConfig.watchlist,
|
|
95
|
+
reconnectStartMs: pluginConfig.reconnect_interval_ms,
|
|
96
|
+
onEvent: (event) => {
|
|
97
|
+
if (status === "connecting")
|
|
98
|
+
status = "connected";
|
|
99
|
+
if (status === "paused")
|
|
100
|
+
return;
|
|
101
|
+
totalEventsReceived++;
|
|
102
|
+
lastEventTime = event.timestamp;
|
|
103
|
+
dispatcher?.handleEvent(event);
|
|
104
|
+
},
|
|
105
|
+
logger,
|
|
106
|
+
});
|
|
107
|
+
wsClient.connect();
|
|
108
|
+
}
|
|
109
|
+
function stopWs(logger) {
|
|
110
|
+
if (wsClient) {
|
|
111
|
+
wsClient.stop();
|
|
112
|
+
wsClient = null;
|
|
113
|
+
}
|
|
114
|
+
status = "unconfigured";
|
|
115
|
+
logger.info("[reactor] WS stopped");
|
|
116
|
+
}
|
|
117
|
+
// ---- Plugin Registration ----
|
|
118
|
+
export default function register(pluginApi) {
|
|
119
|
+
loadPluginConfig(pluginApi.pluginConfig);
|
|
120
|
+
const logger = pluginApi.logger;
|
|
121
|
+
const runtime = pluginApi.runtime;
|
|
122
|
+
// ---- Service ----
|
|
123
|
+
pluginApi.registerService({
|
|
124
|
+
id: "reactor-ws",
|
|
125
|
+
start: async (ctx) => {
|
|
126
|
+
logger.info("[reactor] Service starting");
|
|
127
|
+
const ok = await fetchReactorConfig(logger);
|
|
128
|
+
if (ok) {
|
|
129
|
+
startWs(runtime, logger);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
status = "unconfigured";
|
|
133
|
+
logger.info("[reactor] No config — waiting for user to configure in dashboard");
|
|
134
|
+
}
|
|
135
|
+
configRefreshTimer = setInterval(async () => {
|
|
136
|
+
const refreshed = await fetchReactorConfig(logger);
|
|
137
|
+
if (refreshed && status === "unconfigured") {
|
|
138
|
+
startWs(runtime, logger);
|
|
139
|
+
}
|
|
140
|
+
else if (refreshed && wsClient) {
|
|
141
|
+
wsClient.updateWatchlist(reactorConfig.watchlist);
|
|
142
|
+
dispatcher?.updateConfig(pluginConfig);
|
|
143
|
+
}
|
|
144
|
+
}, pluginConfig.config_refresh_interval_ms);
|
|
145
|
+
eventSyncTimer = setInterval(() => {
|
|
146
|
+
if (dispatcher) {
|
|
147
|
+
const events = dispatcher.drainSyncQueue();
|
|
148
|
+
if (events.length > 0) {
|
|
149
|
+
syncEventsToApi(events, logger);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}, EVENT_SYNC_INTERVAL_MS);
|
|
153
|
+
},
|
|
154
|
+
stop: async (ctx) => {
|
|
155
|
+
logger.info("[reactor] Service stopping");
|
|
156
|
+
if (configRefreshTimer) {
|
|
157
|
+
clearInterval(configRefreshTimer);
|
|
158
|
+
configRefreshTimer = null;
|
|
159
|
+
}
|
|
160
|
+
if (eventSyncTimer) {
|
|
161
|
+
clearInterval(eventSyncTimer);
|
|
162
|
+
eventSyncTimer = null;
|
|
163
|
+
}
|
|
164
|
+
if (dispatcher) {
|
|
165
|
+
const events = dispatcher.drainSyncQueue();
|
|
166
|
+
if (events.length > 0) {
|
|
167
|
+
await syncEventsToApi(events, logger);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
stopWs(logger);
|
|
171
|
+
dispatcher = null;
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
// ---- Prompt Hook (passive mode) ----
|
|
175
|
+
pluginApi.on("before_prompt_build", async () => {
|
|
176
|
+
if (!dispatcher)
|
|
177
|
+
return {};
|
|
178
|
+
const injection = dispatcher.getPromptInjection();
|
|
179
|
+
if (!injection)
|
|
180
|
+
return {};
|
|
181
|
+
return { prependContext: injection };
|
|
182
|
+
});
|
|
183
|
+
// ---- Commands ----
|
|
184
|
+
pluginApi.registerCommand({
|
|
185
|
+
name: "reactor",
|
|
186
|
+
description: "Whale activity reactor — status, pause, resume, events",
|
|
187
|
+
acceptsArgs: true,
|
|
188
|
+
handler: async (ctx) => {
|
|
189
|
+
const parts = (ctx.args || "").trim().split(/\s+/);
|
|
190
|
+
const sub = parts[0] || "status";
|
|
191
|
+
if (sub === "status") {
|
|
192
|
+
const lines = [
|
|
193
|
+
`**Reactor Status:** ${status}`,
|
|
194
|
+
`**Mode:** ${pluginConfig.dispatch_mode}`,
|
|
195
|
+
`**Watchlist:** ${reactorConfig.watchlist.length} wallets`,
|
|
196
|
+
`**Events received:** ${totalEventsReceived}`,
|
|
197
|
+
`**Last event:** ${lastEventTime || "none"}`,
|
|
198
|
+
];
|
|
199
|
+
if (dispatcher?.isAutoReactPaused) {
|
|
200
|
+
lines.push(`⚠️ **autoReact paused** (${dispatcher.failureCount} consecutive failures)`);
|
|
201
|
+
}
|
|
202
|
+
return { text: lines.join("\n") };
|
|
203
|
+
}
|
|
204
|
+
if (sub === "pause") {
|
|
205
|
+
status = "paused";
|
|
206
|
+
return { text: "Reactor paused. WS stays connected but events are suppressed. Use `/reactor resume` to resume." };
|
|
207
|
+
}
|
|
208
|
+
if (sub === "resume") {
|
|
209
|
+
if (status === "paused") {
|
|
210
|
+
status = "connected";
|
|
211
|
+
}
|
|
212
|
+
if (dispatcher?.isAutoReactPaused) {
|
|
213
|
+
dispatcher.resumeAutoReact();
|
|
214
|
+
}
|
|
215
|
+
return { text: "Reactor resumed." };
|
|
216
|
+
}
|
|
217
|
+
if (sub === "events") {
|
|
218
|
+
const n = parseInt(parts[1] || "10", 10);
|
|
219
|
+
const events = dispatcher?.getRecentEvents(n) || [];
|
|
220
|
+
if (events.length === 0)
|
|
221
|
+
return { text: "No recent events." };
|
|
222
|
+
const lines = events.map((e) => {
|
|
223
|
+
const label = e.whale_label ? ` (${e.whale_label})` : "";
|
|
224
|
+
return `${e.timestamp} | ${e.whale_wallet.slice(0, 8)}...${label} ${e.side} $${e.size} on "${e.market_title || e.market_slug || "?"}"`;
|
|
225
|
+
});
|
|
226
|
+
return { text: lines.join("\n") };
|
|
227
|
+
}
|
|
228
|
+
return { text: "Usage: `/reactor status|pause|resume|events [n]`" };
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
logger.info("[reactor] Plugin registered");
|
|
232
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export interface PluginLogger {
|
|
2
|
+
info: (message: string) => void;
|
|
3
|
+
warn: (message: string) => void;
|
|
4
|
+
error: (message: string) => void;
|
|
5
|
+
}
|
|
6
|
+
export interface SpawnResult {
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
code: number | null;
|
|
10
|
+
signal: NodeJS.Signals | null;
|
|
11
|
+
killed: boolean;
|
|
12
|
+
termination: "exit" | "timeout" | "no-output-timeout" | "signal";
|
|
13
|
+
}
|
|
14
|
+
export interface PluginRuntime {
|
|
15
|
+
system: {
|
|
16
|
+
runCommandWithTimeout: (argv: string[], opts: {
|
|
17
|
+
timeoutMs: number;
|
|
18
|
+
cwd?: string;
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
}) => Promise<SpawnResult>;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export interface ServiceCtx {
|
|
24
|
+
config: Record<string, unknown>;
|
|
25
|
+
workspaceDir?: string;
|
|
26
|
+
stateDir: string;
|
|
27
|
+
logger: PluginLogger;
|
|
28
|
+
}
|
|
29
|
+
export interface CommandCtx {
|
|
30
|
+
args?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface PluginApi {
|
|
33
|
+
pluginConfig?: Record<string, unknown>;
|
|
34
|
+
logger: PluginLogger;
|
|
35
|
+
runtime: PluginRuntime;
|
|
36
|
+
on: (hookName: string, handler: (...args: unknown[]) => unknown, opts?: {
|
|
37
|
+
priority?: number;
|
|
38
|
+
}) => void;
|
|
39
|
+
registerService: (service: {
|
|
40
|
+
id: string;
|
|
41
|
+
start: (ctx: ServiceCtx) => void | Promise<void>;
|
|
42
|
+
stop?: (ctx: ServiceCtx) => void | Promise<void>;
|
|
43
|
+
}) => void;
|
|
44
|
+
registerCommand: (cmd: {
|
|
45
|
+
name: string;
|
|
46
|
+
description: string;
|
|
47
|
+
acceptsArgs?: boolean;
|
|
48
|
+
handler: (ctx: CommandCtx) => Promise<{
|
|
49
|
+
text: string;
|
|
50
|
+
}>;
|
|
51
|
+
}) => void;
|
|
52
|
+
}
|
|
53
|
+
export interface WatchlistEntry {
|
|
54
|
+
wallet: string;
|
|
55
|
+
label: string | null;
|
|
56
|
+
min_size: number;
|
|
57
|
+
}
|
|
58
|
+
export interface ReactorConfig {
|
|
59
|
+
polynode_api_key: string | null;
|
|
60
|
+
watchlist: WatchlistEntry[];
|
|
61
|
+
}
|
|
62
|
+
export interface WhaleEvent {
|
|
63
|
+
id: string;
|
|
64
|
+
timestamp: string;
|
|
65
|
+
market_title: string | null;
|
|
66
|
+
market_slug: string | null;
|
|
67
|
+
market_id: string | null;
|
|
68
|
+
whale_wallet: string;
|
|
69
|
+
whale_label: string | null;
|
|
70
|
+
side: string;
|
|
71
|
+
size: number;
|
|
72
|
+
price: number;
|
|
73
|
+
}
|
|
74
|
+
export interface PluginConfig {
|
|
75
|
+
dispatch_mode: "passive" | "autoReact";
|
|
76
|
+
autoReact_skill: string;
|
|
77
|
+
autoReact_max_usd: number;
|
|
78
|
+
buffer_max: number;
|
|
79
|
+
reconnect_interval_ms: number;
|
|
80
|
+
config_refresh_interval_ms: number;
|
|
81
|
+
}
|
|
82
|
+
export declare const DEFAULT_CONFIG: PluginConfig;
|
|
83
|
+
export type PluginStatus = "unconfigured" | "connecting" | "connected" | "paused" | "error";
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// ---- OpenClaw Plugin API types (subset we use) ----
|
|
2
|
+
export const DEFAULT_CONFIG = {
|
|
3
|
+
dispatch_mode: "passive",
|
|
4
|
+
autoReact_skill: "polymarket-copytrading",
|
|
5
|
+
autoReact_max_usd: 50,
|
|
6
|
+
buffer_max: 100,
|
|
7
|
+
reconnect_interval_ms: 5000,
|
|
8
|
+
config_refresh_interval_ms: 300_000,
|
|
9
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PluginLogger, WatchlistEntry, WhaleEvent } from "./types.js";
|
|
2
|
+
export interface WsClientOptions {
|
|
3
|
+
apiKey: string;
|
|
4
|
+
watchlist: WatchlistEntry[];
|
|
5
|
+
reconnectStartMs: number;
|
|
6
|
+
onEvent: (event: WhaleEvent) => void;
|
|
7
|
+
logger: PluginLogger;
|
|
8
|
+
}
|
|
9
|
+
export declare class PolyNodeWsClient {
|
|
10
|
+
private ws;
|
|
11
|
+
private options;
|
|
12
|
+
private reconnectMs;
|
|
13
|
+
private reconnectTimer;
|
|
14
|
+
private seenIds;
|
|
15
|
+
private stopped;
|
|
16
|
+
constructor(options: WsClientOptions);
|
|
17
|
+
connect(): void;
|
|
18
|
+
private subscribe;
|
|
19
|
+
private handleMessage;
|
|
20
|
+
private scheduleReconnect;
|
|
21
|
+
updateWatchlist(watchlist: WatchlistEntry[]): void;
|
|
22
|
+
stop(): void;
|
|
23
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
const MAX_RECONNECT_MS = 60_000;
|
|
3
|
+
const SEEN_IDS_CAP = 10_000;
|
|
4
|
+
export class PolyNodeWsClient {
|
|
5
|
+
ws = null;
|
|
6
|
+
options;
|
|
7
|
+
reconnectMs;
|
|
8
|
+
reconnectTimer = null;
|
|
9
|
+
seenIds = new Set();
|
|
10
|
+
stopped = false;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
this.reconnectMs = options.reconnectStartMs;
|
|
14
|
+
}
|
|
15
|
+
connect() {
|
|
16
|
+
this.stopped = false;
|
|
17
|
+
const url = `wss://ws.polynode.dev/ws?key=${this.options.apiKey}`;
|
|
18
|
+
this.options.logger.info(`[reactor] Connecting to PolyNode WS`);
|
|
19
|
+
this.ws = new WebSocket(url);
|
|
20
|
+
this.ws.on("open", () => {
|
|
21
|
+
this.options.logger.info("[reactor] WS connected");
|
|
22
|
+
this.reconnectMs = this.options.reconnectStartMs; // reset backoff
|
|
23
|
+
this.subscribe();
|
|
24
|
+
});
|
|
25
|
+
this.ws.on("message", (data) => {
|
|
26
|
+
try {
|
|
27
|
+
const msg = JSON.parse(data.toString());
|
|
28
|
+
this.handleMessage(msg);
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
this.options.logger.warn(`[reactor] Failed to parse WS message: ${e}`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
this.ws.on("close", (code, reason) => {
|
|
35
|
+
this.options.logger.warn(`[reactor] WS closed: ${code} ${reason.toString()}`);
|
|
36
|
+
this.scheduleReconnect();
|
|
37
|
+
});
|
|
38
|
+
this.ws.on("error", (err) => {
|
|
39
|
+
this.options.logger.error(`[reactor] WS error: ${err.message}`);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
subscribe() {
|
|
43
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
44
|
+
return;
|
|
45
|
+
const wallets = this.options.watchlist.map((w) => w.wallet);
|
|
46
|
+
const minSize = Math.min(...this.options.watchlist.map((w) => w.min_size));
|
|
47
|
+
this.ws.send(JSON.stringify({
|
|
48
|
+
action: "subscribe",
|
|
49
|
+
params: {
|
|
50
|
+
channel: "fills",
|
|
51
|
+
filters: { wallets, min_size: minSize },
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
54
|
+
this.options.logger.info(`[reactor] Subscribed to ${wallets.length} wallets`);
|
|
55
|
+
}
|
|
56
|
+
handleMessage(msg) {
|
|
57
|
+
if (msg.type !== "fill" && msg.channel !== "fills")
|
|
58
|
+
return;
|
|
59
|
+
const data = msg.data;
|
|
60
|
+
if (!data)
|
|
61
|
+
return;
|
|
62
|
+
const eventId = String(data.id || `${data.timestamp}-${data.taker_wallet}-${data.size}`);
|
|
63
|
+
if (this.seenIds.has(eventId))
|
|
64
|
+
return;
|
|
65
|
+
this.seenIds.add(eventId);
|
|
66
|
+
if (this.seenIds.size > SEEN_IDS_CAP) {
|
|
67
|
+
const first = this.seenIds.values().next().value;
|
|
68
|
+
if (first !== undefined)
|
|
69
|
+
this.seenIds.delete(first);
|
|
70
|
+
}
|
|
71
|
+
const takerWallet = String(data.taker_wallet || "");
|
|
72
|
+
const entry = this.options.watchlist.find((w) => w.wallet.toLowerCase() === takerWallet.toLowerCase());
|
|
73
|
+
if (!entry)
|
|
74
|
+
return;
|
|
75
|
+
const size = Number(data.size || 0);
|
|
76
|
+
if (size < entry.min_size)
|
|
77
|
+
return;
|
|
78
|
+
const event = {
|
|
79
|
+
id: eventId,
|
|
80
|
+
timestamp: String(data.timestamp || new Date().toISOString()),
|
|
81
|
+
market_title: data.market_title || null,
|
|
82
|
+
market_slug: data.market_slug || null,
|
|
83
|
+
market_id: data.market_id || null,
|
|
84
|
+
whale_wallet: takerWallet,
|
|
85
|
+
whale_label: entry.label,
|
|
86
|
+
side: String(data.side || "unknown"),
|
|
87
|
+
size,
|
|
88
|
+
price: Number(data.price || 0),
|
|
89
|
+
};
|
|
90
|
+
this.options.onEvent(event);
|
|
91
|
+
}
|
|
92
|
+
scheduleReconnect() {
|
|
93
|
+
if (this.stopped)
|
|
94
|
+
return;
|
|
95
|
+
this.options.logger.info(`[reactor] Reconnecting in ${this.reconnectMs}ms`);
|
|
96
|
+
this.reconnectTimer = setTimeout(() => {
|
|
97
|
+
this.connect();
|
|
98
|
+
}, this.reconnectMs);
|
|
99
|
+
this.reconnectMs = Math.min(this.reconnectMs * 2, MAX_RECONNECT_MS);
|
|
100
|
+
}
|
|
101
|
+
updateWatchlist(watchlist) {
|
|
102
|
+
this.options.watchlist = watchlist;
|
|
103
|
+
this.subscribe();
|
|
104
|
+
}
|
|
105
|
+
stop() {
|
|
106
|
+
this.stopped = true;
|
|
107
|
+
if (this.reconnectTimer) {
|
|
108
|
+
clearTimeout(this.reconnectTimer);
|
|
109
|
+
this.reconnectTimer = null;
|
|
110
|
+
}
|
|
111
|
+
if (this.ws) {
|
|
112
|
+
this.ws.close();
|
|
113
|
+
this.ws = null;
|
|
114
|
+
}
|
|
115
|
+
this.options.logger.info("[reactor] WS client stopped");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "simmer-reactor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Real-time whale activity monitor and trade reactor",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"configSchema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"dispatch_mode": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"enum": ["passive", "autoReact"],
|
|
12
|
+
"default": "passive",
|
|
13
|
+
"description": "passive = buffer events for LLM; autoReact = execute skill directly"
|
|
14
|
+
},
|
|
15
|
+
"autoReact_skill": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"default": "polymarket-copytrading",
|
|
18
|
+
"description": "Skill slug to invoke in autoReact mode"
|
|
19
|
+
},
|
|
20
|
+
"autoReact_max_usd": {
|
|
21
|
+
"type": "number",
|
|
22
|
+
"default": 50,
|
|
23
|
+
"description": "Max trade size per autoReact execution"
|
|
24
|
+
},
|
|
25
|
+
"buffer_max": {
|
|
26
|
+
"type": "number",
|
|
27
|
+
"default": 100,
|
|
28
|
+
"description": "Max events buffered for passive injection"
|
|
29
|
+
},
|
|
30
|
+
"reconnect_interval_ms": {
|
|
31
|
+
"type": "number",
|
|
32
|
+
"default": 5000,
|
|
33
|
+
"description": "WebSocket reconnect backoff start (ms)"
|
|
34
|
+
},
|
|
35
|
+
"config_refresh_interval_ms": {
|
|
36
|
+
"type": "number",
|
|
37
|
+
"default": 300000,
|
|
38
|
+
"description": "How often to re-fetch config from dashboard API (ms)"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "simmer-reactor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Real-time whale activity monitor and trade reactor for OpenClaw",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch"
|
|
10
|
+
},
|
|
11
|
+
"files": ["dist/", "openclaw.plugin.json"],
|
|
12
|
+
"keywords": ["openclaw", "simmer", "polymarket", "whale", "reactor"],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/SpartanLabsXyz/simmer"
|
|
17
|
+
},
|
|
18
|
+
"openclaw": {
|
|
19
|
+
"extensions": ["./dist/index.js"]
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"ws": "^8.16.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/ws": "^8.5.10",
|
|
26
|
+
"typescript": "^5.4.0"
|
|
27
|
+
}
|
|
28
|
+
}
|