@moltenbot/openclaw-plugin-statocyst 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Molten.Bot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # @moltenbot/openclaw-plugin-statocyst
2
+
3
+ OpenClaw plugin for realtime Statocyst skill execution messaging.
4
+
5
+ This package is built and maintained by [Molten AI](https://molten.bot).
6
+
7
+ ## What this plugin adds
8
+
9
+ - `statocyst_skill_request`: send a `skill_request` envelope to a trusted peer and wait for the matching `skill_result`
10
+ - `statocyst_session_status`: verify websocket session health for the current plugin session
11
+ - dedicated realtime websocket transport via Statocyst `/v1/openclaw/messages/ws`
12
+ - explicit plugin registration and usage activity tracking in Statocyst profile metadata and agent activity log
13
+
14
+ ## Requirements
15
+
16
+ - Node.js `>=22`
17
+ - OpenClaw with plugin support enabled
18
+ - A Statocyst agent token with trust established to the target peer agent
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ openclaw plugins install @moltenbot/openclaw-plugin-statocyst
24
+ openclaw gateway restart
25
+ ```
26
+
27
+ ## Configure
28
+
29
+ Set plugin config under `plugins.entries.statocyst-openclaw.config`:
30
+
31
+ ```json
32
+ {
33
+ "plugins": {
34
+ "entries": {
35
+ "statocyst-openclaw": {
36
+ "enabled": true,
37
+ "config": {
38
+ "baseUrl": "https://hub.example.com/v1",
39
+ "token": "statocyst-agent-bearer-token",
40
+ "sessionKey": "main",
41
+ "timeoutMs": 20000
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ Config fields:
50
+
51
+ - `baseUrl` (required): Statocyst API base, including `/v1`
52
+ - `token` (required): Statocyst bearer token for the current OpenClaw agent
53
+ - `sessionKey` (optional, default `main`): dedicated realtime session key
54
+ - `timeoutMs` (optional, default `20000`, max `60000`): tool request timeout
55
+
56
+ ## Statocyst usage registration
57
+
58
+ This plugin actively records usage in Statocyst:
59
+
60
+ - `POST /v1/openclaw/messages/register-plugin` is called before session checks and skill requests.
61
+ - Statocyst stores plugin metadata on the agent profile under `metadata.plugins.statocyst-openclaw`.
62
+ - Statocyst appends agent activity entries for:
63
+ - plugin registration (`openclaw_plugin`)
64
+ - OpenClaw adapter usage (`openclaw_adapter` events across publish/pull/ack/nack/status/ws)
65
+
66
+ You can inspect this data via `GET /v1/agents/me`.
67
+
68
+ ## OpenClaw onboarding flow
69
+
70
+ 1. Create/bind the Statocyst agent token (`POST /v1/agents/bind-tokens`, then `POST /v1/agents/bind`).
71
+ 2. Configure plugin entry in OpenClaw (`plugins.entries.statocyst-openclaw.config`).
72
+ 3. Ensure your tool policy allows plugin tools:
73
+ - allow `statocyst_skill_request` and `statocyst_session_status` (or allow the plugin id).
74
+ 4. Restart OpenClaw gateway.
75
+ 5. Run `statocyst_session_status` once to validate connectivity.
76
+
77
+ ## Distribution and discovery checklist
78
+
79
+ To maximize adoption and visibility:
80
+
81
+ 1. Publish this package to npm (`@moltenbot/openclaw-plugin-statocyst`).
82
+ 2. Publish to ClawHub (preferred by OpenClaw resolver).
83
+ 3. Keep a public GitHub repo with docs and issue tracker.
84
+ 4. Submit a PR to OpenClaw Community Plugins docs with:
85
+ - plugin name
86
+ - npm package
87
+ - GitHub URL
88
+ - one-line description
89
+ - install command
90
+ 5. Track in-product usage via Statocyst metadata/activity logs as described above.
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ npm ci
96
+ npm run build
97
+ npm run test:coverage
98
+ docker build -t statocyst-openclaw-e2e:local ../statocyst
99
+ STATOCYST_IMAGE=statocyst-openclaw-e2e:local npm run test:e2e:container
100
+ ```
@@ -0,0 +1,6 @@
1
+ import { createStatocystOpenClawPlugin } from "./plugin.js";
2
+ export { createStatocystOpenClawPlugin };
3
+ export { resolveConfig, StatocystClient } from "./statocyst-client.js";
4
+ export type { OpenClawPlugin, OpenClawPluginAPI, OpenClawToolRegisterOptions, ResolveConfigInput, OpenClawToolDefinition, SkillExecutionRequest, SkillExecutionResult, StatocystPluginConfig } from "./types.js";
5
+ declare const plugin: import("./types.js").OpenClawPlugin;
6
+ export default plugin;
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ import { createStatocystOpenClawPlugin } from "./plugin.js";
2
+ export { createStatocystOpenClawPlugin };
3
+ export { resolveConfig, StatocystClient } from "./statocyst-client.js";
4
+ const plugin = createStatocystOpenClawPlugin();
5
+ export default plugin;
@@ -0,0 +1,14 @@
1
+ import type { OpenClawPlugin, SkillExecutionRequest, SkillExecutionResult, StatocystPluginConfig } from "./types.js";
2
+ interface StatocystClientContract {
3
+ checkSession: () => Promise<{
4
+ status: string;
5
+ sessionKey: string;
6
+ transport: string;
7
+ }>;
8
+ requestSkillExecution: (request: SkillExecutionRequest) => Promise<SkillExecutionResult>;
9
+ }
10
+ export interface PluginFactoryDeps {
11
+ createClient?: (config: StatocystPluginConfig) => StatocystClientContract;
12
+ }
13
+ export declare function createStatocystOpenClawPlugin(deps?: PluginFactoryDeps): OpenClawPlugin;
14
+ export {};
package/dist/plugin.js ADDED
@@ -0,0 +1,143 @@
1
+ import { resolveConfig, StatocystClient } from "./statocyst-client.js";
2
+ const skillRequestInputSchema = {
3
+ type: "object",
4
+ required: ["skillName"],
5
+ properties: {
6
+ toAgentUUID: {
7
+ type: "string",
8
+ description: "Target receiver agent UUID"
9
+ },
10
+ toAgentURI: {
11
+ type: "string",
12
+ description: "Target receiver canonical agent URI"
13
+ },
14
+ skillName: {
15
+ type: "string",
16
+ description: "Peer advertised skill name to execute"
17
+ },
18
+ input: {
19
+ description: "Skill input payload"
20
+ },
21
+ timeoutMs: {
22
+ type: "number",
23
+ description: "Override timeout for this request"
24
+ },
25
+ sessionKey: {
26
+ type: "string",
27
+ description: "Override dedicated session key"
28
+ },
29
+ requestId: {
30
+ type: "string",
31
+ description: "Optional caller-provided correlation id"
32
+ }
33
+ }
34
+ };
35
+ const sessionStatusInputSchema = {
36
+ type: "object",
37
+ properties: {}
38
+ };
39
+ function parseSkillExecutionRequest(input) {
40
+ return {
41
+ toAgentUUID: asTrimmedString(input.toAgentUUID),
42
+ toAgentURI: asTrimmedString(input.toAgentURI),
43
+ skillName: asTrimmedString(input.skillName) ?? "",
44
+ input: input.input,
45
+ timeoutMs: asNumber(input.timeoutMs),
46
+ sessionKey: asTrimmedString(input.sessionKey),
47
+ requestId: asTrimmedString(input.requestId)
48
+ };
49
+ }
50
+ function asTrimmedString(value) {
51
+ if (typeof value !== "string") {
52
+ return undefined;
53
+ }
54
+ const trimmed = value.trim();
55
+ if (!trimmed) {
56
+ return undefined;
57
+ }
58
+ return trimmed;
59
+ }
60
+ function asNumber(value) {
61
+ if (typeof value !== "number") {
62
+ return undefined;
63
+ }
64
+ if (!Number.isFinite(value)) {
65
+ return undefined;
66
+ }
67
+ return value;
68
+ }
69
+ function asRecord(value) {
70
+ if (value && typeof value === "object" && !Array.isArray(value)) {
71
+ return value;
72
+ }
73
+ return {};
74
+ }
75
+ function asEnvMap(env) {
76
+ return {
77
+ ...process.env,
78
+ ...(env ?? {})
79
+ };
80
+ }
81
+ function formatToolText(payload) {
82
+ try {
83
+ return JSON.stringify(payload);
84
+ }
85
+ catch {
86
+ return String(payload);
87
+ }
88
+ }
89
+ function toToolResult(payload) {
90
+ return {
91
+ content: [
92
+ {
93
+ type: "text",
94
+ text: formatToolText(payload)
95
+ }
96
+ ],
97
+ data: payload
98
+ };
99
+ }
100
+ function skillRequestTool(client) {
101
+ return {
102
+ name: "statocyst_skill_request",
103
+ description: "Send a Statocyst skill_request envelope to a peer and wait for the corresponding skill_result over the realtime websocket bus.",
104
+ parameters: skillRequestInputSchema,
105
+ execute: async (_callID, params) => {
106
+ const request = parseSkillExecutionRequest(asRecord(params));
107
+ const result = await client().requestSkillExecution(request);
108
+ return toToolResult(result);
109
+ }
110
+ };
111
+ }
112
+ function sessionStatusTool(client) {
113
+ return {
114
+ name: "statocyst_session_status",
115
+ description: "Check Statocyst realtime websocket connectivity for this plugin session.",
116
+ parameters: sessionStatusInputSchema,
117
+ execute: async () => {
118
+ const result = await client().checkSession();
119
+ return toToolResult(result);
120
+ }
121
+ };
122
+ }
123
+ function buildClient(api, factory) {
124
+ const config = resolveConfig({
125
+ config: api.pluginConfig ?? {},
126
+ env: asEnvMap(api.env)
127
+ });
128
+ return factory(config);
129
+ }
130
+ export function createStatocystOpenClawPlugin(deps) {
131
+ const factory = deps?.createClient ?? ((config) => new StatocystClient(config));
132
+ return {
133
+ id: "statocyst-openclaw",
134
+ name: "Statocyst Realtime",
135
+ description: "Molten AI maintained plugin for realtime skill request/result exchange via Statocyst.",
136
+ version: "0.1.0",
137
+ register: (api) => {
138
+ const client = buildClient(api, factory);
139
+ api.registerTool(skillRequestTool(() => client));
140
+ api.registerTool(sessionStatusTool(() => client));
141
+ }
142
+ };
143
+ }
@@ -0,0 +1,30 @@
1
+ import type { ResolveConfigInput, SkillExecutionRequest, SkillExecutionResult, StatocystPluginConfig } from "./types.js";
2
+ export interface WebSocketLike {
3
+ on: (event: string, listener: (...args: unknown[]) => void) => WebSocketLike;
4
+ send: (data: string, callback?: (error?: Error) => void) => void;
5
+ close: (code?: number) => void;
6
+ }
7
+ export type WebSocketFactory = (url: string, headers: Record<string, string>) => WebSocketLike;
8
+ export interface StatocystClientDeps {
9
+ fetchImpl: typeof fetch;
10
+ wsFactory: WebSocketFactory;
11
+ now: () => Date;
12
+ randomID: () => string;
13
+ }
14
+ export declare class StatocystClient {
15
+ private readonly config;
16
+ private readonly deps;
17
+ constructor(config: StatocystPluginConfig, deps?: Partial<StatocystClientDeps>);
18
+ registerPlugin(): Promise<void>;
19
+ checkSession(): Promise<{
20
+ status: string;
21
+ sessionKey: string;
22
+ transport: string;
23
+ }>;
24
+ requestSkillExecution(request: SkillExecutionRequest): Promise<SkillExecutionResult>;
25
+ private openSession;
26
+ private waitForResponse;
27
+ private ackDelivery;
28
+ private nackDelivery;
29
+ }
30
+ export declare function resolveConfig(context: ResolveConfigInput): StatocystPluginConfig;
@@ -0,0 +1,421 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import WebSocket from "ws";
3
+ const defaultTimeoutMs = 20_000;
4
+ const defaultPluginID = "statocyst-openclaw";
5
+ const defaultPluginPackage = "@moltenbot/openclaw-plugin-statocyst";
6
+ const defaultPluginVersion = "0.1.0";
7
+ const defaultDeps = {
8
+ fetchImpl: fetch,
9
+ wsFactory: (url, headers) => new WebSocket(url, { headers }),
10
+ now: () => new Date(),
11
+ randomID: () => randomUUID()
12
+ };
13
+ class MessageQueue {
14
+ queue = [];
15
+ pending = [];
16
+ push(message) {
17
+ if (this.pending.length > 0) {
18
+ const resolver = this.pending.shift();
19
+ if (resolver) {
20
+ resolver(message);
21
+ }
22
+ return;
23
+ }
24
+ this.queue.push(message);
25
+ }
26
+ next(timeoutMs) {
27
+ if (this.queue.length > 0) {
28
+ const message = this.queue.shift();
29
+ if (message) {
30
+ return Promise.resolve(message);
31
+ }
32
+ }
33
+ return new Promise((resolve, reject) => {
34
+ const timer = setTimeout(() => {
35
+ this.pending = this.pending.filter((fn) => fn !== resolver);
36
+ reject(new Error(`timed out waiting for websocket message after ${timeoutMs}ms`));
37
+ }, timeoutMs);
38
+ const resolver = (message) => {
39
+ clearTimeout(timer);
40
+ resolve(message);
41
+ };
42
+ this.pending.push(resolver);
43
+ });
44
+ }
45
+ }
46
+ class WebSocketSession {
47
+ socket;
48
+ queue = new MessageQueue();
49
+ constructor(socket) {
50
+ this.socket = socket;
51
+ }
52
+ attach() {
53
+ this.socket.on("message", (raw) => {
54
+ this.queue.push(parseWSMessage(raw));
55
+ });
56
+ this.socket.on("error", (error) => {
57
+ this.queue.push({
58
+ type: "__error__",
59
+ error: String(error)
60
+ });
61
+ });
62
+ this.socket.on("close", () => {
63
+ this.queue.push({ type: "__close__" });
64
+ });
65
+ }
66
+ async send(payload) {
67
+ await new Promise((resolve, reject) => {
68
+ this.socket.send(JSON.stringify(payload), (error) => {
69
+ if (error) {
70
+ reject(error);
71
+ return;
72
+ }
73
+ resolve();
74
+ });
75
+ });
76
+ }
77
+ async next(timeoutMs) {
78
+ return this.queue.next(timeoutMs);
79
+ }
80
+ close() {
81
+ this.socket.close(1000);
82
+ }
83
+ }
84
+ export class StatocystClient {
85
+ config;
86
+ deps;
87
+ constructor(config, deps) {
88
+ this.config = config;
89
+ this.deps = {
90
+ ...defaultDeps,
91
+ ...deps
92
+ };
93
+ }
94
+ async registerPlugin() {
95
+ const response = await this.deps.fetchImpl(`${this.config.baseUrl}/openclaw/messages/register-plugin`, {
96
+ method: "POST",
97
+ headers: {
98
+ "Content-Type": "application/json",
99
+ Authorization: `Bearer ${this.config.token}`
100
+ },
101
+ body: JSON.stringify({
102
+ plugin_id: this.config.pluginId,
103
+ package: this.config.pluginPackage,
104
+ version: this.config.pluginVersion,
105
+ transport: "websocket",
106
+ session_mode: "dedicated",
107
+ session_key: this.config.sessionKey
108
+ })
109
+ });
110
+ if (!response.ok) {
111
+ const body = await safeReadText(response);
112
+ throw new Error(`statocyst plugin registration failed (${response.status}): ${body}`);
113
+ }
114
+ }
115
+ async checkSession() {
116
+ await this.registerPlugin();
117
+ const session = await this.openSession(this.config.sessionKey, this.config.timeoutMs);
118
+ try {
119
+ return {
120
+ status: "ok",
121
+ sessionKey: this.config.sessionKey,
122
+ transport: "websocket"
123
+ };
124
+ }
125
+ finally {
126
+ session.close();
127
+ }
128
+ }
129
+ async requestSkillExecution(request) {
130
+ const targetUUID = trimOrEmpty(request.toAgentUUID);
131
+ const targetURI = trimOrEmpty(request.toAgentURI);
132
+ const skillName = trimOrEmpty(request.skillName);
133
+ if (!targetUUID && !targetURI) {
134
+ throw new Error("toAgentUUID or toAgentURI is required");
135
+ }
136
+ if (!skillName) {
137
+ throw new Error("skillName is required");
138
+ }
139
+ const timeoutMs = normalizeTimeout(request.timeoutMs ?? this.config.timeoutMs);
140
+ const requestId = trimOrEmpty(request.requestId) || this.deps.randomID();
141
+ const sessionKey = trimOrEmpty(request.sessionKey) || this.config.sessionKey;
142
+ await this.registerPlugin();
143
+ const session = await this.openSession(sessionKey, timeoutMs);
144
+ try {
145
+ const publishRequestID = `publish:${requestId}`;
146
+ await session.send({
147
+ type: "publish",
148
+ request_id: publishRequestID,
149
+ to_agent_uuid: targetUUID || undefined,
150
+ to_agent_uri: targetURI || undefined,
151
+ message: {
152
+ kind: "skill_request",
153
+ request_id: requestId,
154
+ skill_name: skillName,
155
+ reply_required: true,
156
+ input: request.input,
157
+ session_key: sessionKey,
158
+ timestamp: this.deps.now().toISOString()
159
+ }
160
+ });
161
+ await this.waitForResponse(session, publishRequestID, timeoutMs);
162
+ const deadline = Date.now() + timeoutMs;
163
+ for (;;) {
164
+ const remaining = deadline - Date.now();
165
+ const waitMs = Math.max(1, remaining);
166
+ let payload;
167
+ try {
168
+ payload = await session.next(waitMs);
169
+ }
170
+ catch {
171
+ throw new Error(`timed out waiting for skill_result for request_id=${requestId}`);
172
+ }
173
+ const payloadType = trimOrEmpty(payload.type);
174
+ if (payloadType === "__error__") {
175
+ throw new Error(`websocket error: ${trimOrEmpty(payload.error)}`);
176
+ }
177
+ if (payloadType === "__close__") {
178
+ throw new Error("websocket session closed");
179
+ }
180
+ if (payloadType === "delivery") {
181
+ const message = readObject(readObject(payload.result).openclaw_message);
182
+ const deliveryID = trimOrEmpty(readObject(readObject(payload.result).delivery).delivery_id);
183
+ const messageID = trimOrEmpty(readObject(readObject(payload.result).message).message_id);
184
+ const kind = trimOrEmpty(message.kind);
185
+ const resultRequestID = trimOrEmpty(message.request_id);
186
+ if (kind === "skill_result" && resultRequestID === requestId) {
187
+ await this.ackDelivery(session, deliveryID, timeoutMs);
188
+ return {
189
+ requestId,
190
+ skillName,
191
+ status: trimOrEmpty(message.status) || "ok",
192
+ output: message.output,
193
+ error: message.error,
194
+ messageId: messageID,
195
+ deliveryId: deliveryID
196
+ };
197
+ }
198
+ if (deliveryID) {
199
+ await this.nackDelivery(session, deliveryID);
200
+ }
201
+ continue;
202
+ }
203
+ if (payloadType === "response") {
204
+ const ok = Boolean(payload.ok);
205
+ if (!ok) {
206
+ const code = trimOrEmpty(readObject(payload.error).code) || "unknown_error";
207
+ const message = trimOrEmpty(readObject(payload.error).message) || "unknown error";
208
+ throw new Error(`statocyst websocket response error (${code}): ${message}`);
209
+ }
210
+ }
211
+ }
212
+ }
213
+ finally {
214
+ session.close();
215
+ }
216
+ }
217
+ async openSession(sessionKey, timeoutMs) {
218
+ const wsBase = this.config.baseUrl.replace(/^http/i, "ws");
219
+ const wsURL = `${wsBase}/openclaw/messages/ws?session_key=${encodeURIComponent(sessionKey)}`;
220
+ const socket = this.deps.wsFactory(wsURL, {
221
+ Authorization: `Bearer ${this.config.token}`
222
+ });
223
+ await waitForOpen(socket, timeoutMs);
224
+ const session = new WebSocketSession(socket);
225
+ session.attach();
226
+ const firstMessage = await session.next(timeoutMs);
227
+ if (trimOrEmpty(firstMessage.type) !== "session_ready") {
228
+ throw new Error(`unexpected websocket handshake message type=${trimOrEmpty(firstMessage.type)}`);
229
+ }
230
+ return session;
231
+ }
232
+ async waitForResponse(session, requestID, timeoutMs) {
233
+ const deadline = Date.now() + timeoutMs;
234
+ for (;;) {
235
+ const remaining = deadline - Date.now();
236
+ const waitMs = Math.max(1, remaining);
237
+ let payload;
238
+ try {
239
+ payload = await session.next(waitMs);
240
+ }
241
+ catch {
242
+ throw new Error(`timed out waiting for websocket response request_id=${requestID}`);
243
+ }
244
+ if (trimOrEmpty(payload.type) === "__error__") {
245
+ throw new Error(`websocket error: ${trimOrEmpty(payload.error)}`);
246
+ }
247
+ if (trimOrEmpty(payload.type) === "__close__") {
248
+ throw new Error("websocket session closed");
249
+ }
250
+ if (trimOrEmpty(payload.type) !== "response") {
251
+ if (trimOrEmpty(payload.type) === "delivery") {
252
+ const deliveryID = trimOrEmpty(readObject(readObject(payload.result).delivery).delivery_id);
253
+ if (deliveryID) {
254
+ await this.nackDelivery(session, deliveryID);
255
+ }
256
+ }
257
+ continue;
258
+ }
259
+ if (trimOrEmpty(payload.request_id) !== requestID) {
260
+ continue;
261
+ }
262
+ if (!Boolean(payload.ok)) {
263
+ const code = trimOrEmpty(readObject(payload.error).code) || "unknown_error";
264
+ const message = trimOrEmpty(readObject(payload.error).message) || "unknown error";
265
+ throw new Error(`statocyst websocket response error (${code}): ${message}`);
266
+ }
267
+ return;
268
+ }
269
+ }
270
+ async ackDelivery(session, deliveryID, timeoutMs) {
271
+ if (!deliveryID) {
272
+ return;
273
+ }
274
+ const requestID = `ack:${deliveryID}`;
275
+ await session.send({
276
+ type: "ack",
277
+ request_id: requestID,
278
+ delivery_id: deliveryID
279
+ });
280
+ await this.waitForResponse(session, requestID, timeoutMs);
281
+ }
282
+ async nackDelivery(session, deliveryID) {
283
+ await session.send({
284
+ type: "nack",
285
+ request_id: `nack:${deliveryID}`,
286
+ delivery_id: deliveryID
287
+ });
288
+ }
289
+ }
290
+ export function resolveConfig(context) {
291
+ const config = context.config ?? {};
292
+ const env = context.env ?? {};
293
+ const baseUrl = normalizeBaseURL(asString(config.baseUrl) ||
294
+ asString(config.baseURL) ||
295
+ env.STATOCYST_BASE_URL ||
296
+ env.STATOCYST_API_BASE ||
297
+ "");
298
+ const token = trimOrEmpty(asString(config.token) || env.STATOCYST_AGENT_TOKEN || "");
299
+ const sessionKey = trimOrEmpty(asString(config.sessionKey) || env.STATOCYST_SESSION_KEY || "main");
300
+ const timeoutMs = normalizeTimeout(asNumber(config.timeoutMs) ?? asNumber(env.STATOCYST_TIMEOUT_MS) ?? defaultTimeoutMs);
301
+ const pluginId = trimOrEmpty(asString(config.pluginId) || defaultPluginID);
302
+ const pluginPackage = trimOrEmpty(asString(config.pluginPackage) || defaultPluginPackage);
303
+ const pluginVersion = trimOrEmpty(asString(config.pluginVersion) || defaultPluginVersion);
304
+ if (!baseUrl) {
305
+ throw new Error("Statocyst plugin configuration requires baseUrl");
306
+ }
307
+ if (!token) {
308
+ throw new Error("Statocyst plugin configuration requires token");
309
+ }
310
+ return {
311
+ baseUrl,
312
+ token,
313
+ sessionKey,
314
+ timeoutMs,
315
+ pluginId,
316
+ pluginPackage,
317
+ pluginVersion
318
+ };
319
+ }
320
+ function waitForOpen(socket, timeoutMs) {
321
+ return new Promise((resolve, reject) => {
322
+ const timer = setTimeout(() => {
323
+ reject(new Error(`timed out waiting for websocket open after ${timeoutMs}ms`));
324
+ }, timeoutMs);
325
+ socket.on("open", () => {
326
+ clearTimeout(timer);
327
+ resolve();
328
+ });
329
+ socket.on("error", (error) => {
330
+ clearTimeout(timer);
331
+ reject(new Error(String(error)));
332
+ });
333
+ });
334
+ }
335
+ function parseWSMessage(raw) {
336
+ try {
337
+ const value = normalizeWSRawData(raw);
338
+ if (!value) {
339
+ return { type: "__invalid__", raw: "" };
340
+ }
341
+ const decoded = JSON.parse(value);
342
+ if (decoded && typeof decoded === "object") {
343
+ return decoded;
344
+ }
345
+ return { type: "__invalid__", raw: value };
346
+ }
347
+ catch {
348
+ return { type: "__invalid__", raw: String(raw) };
349
+ }
350
+ }
351
+ function normalizeWSRawData(raw) {
352
+ if (typeof raw === "string") {
353
+ return raw;
354
+ }
355
+ if (raw instanceof ArrayBuffer) {
356
+ return Buffer.from(raw).toString("utf8");
357
+ }
358
+ if (Buffer.isBuffer(raw)) {
359
+ return raw.toString("utf8");
360
+ }
361
+ const maybeWSRaw = raw;
362
+ if (Array.isArray(maybeWSRaw)) {
363
+ return Buffer.concat(maybeWSRaw).toString("utf8");
364
+ }
365
+ return String(raw ?? "");
366
+ }
367
+ function normalizeBaseURL(raw) {
368
+ const trimmed = trimOrEmpty(raw);
369
+ if (!trimmed) {
370
+ return "";
371
+ }
372
+ return trimmed.replace(/\/+$/, "");
373
+ }
374
+ function normalizeTimeout(raw) {
375
+ if (!Number.isFinite(raw) || raw <= 0) {
376
+ return defaultTimeoutMs;
377
+ }
378
+ if (raw > 60_000) {
379
+ return 60_000;
380
+ }
381
+ return Math.trunc(raw);
382
+ }
383
+ function trimOrEmpty(value) {
384
+ if (typeof value !== "string") {
385
+ return "";
386
+ }
387
+ return value.trim();
388
+ }
389
+ function asString(value) {
390
+ if (typeof value === "string") {
391
+ return value;
392
+ }
393
+ return "";
394
+ }
395
+ function asNumber(value) {
396
+ if (typeof value === "number") {
397
+ return value;
398
+ }
399
+ if (typeof value === "string") {
400
+ const parsed = Number.parseInt(value, 10);
401
+ if (Number.isNaN(parsed)) {
402
+ return undefined;
403
+ }
404
+ return parsed;
405
+ }
406
+ return undefined;
407
+ }
408
+ function readObject(value) {
409
+ if (value && typeof value === "object") {
410
+ return value;
411
+ }
412
+ return {};
413
+ }
414
+ async function safeReadText(response) {
415
+ try {
416
+ return (await response.text()).trim();
417
+ }
418
+ catch {
419
+ return "";
420
+ }
421
+ }
@@ -0,0 +1,52 @@
1
+ export interface OpenClawToolDefinition {
2
+ name: string;
3
+ description: string;
4
+ parameters: Record<string, unknown>;
5
+ execute: (callID: string, params: Record<string, unknown>) => Promise<unknown>;
6
+ }
7
+ export interface OpenClawToolRegisterOptions {
8
+ optional?: boolean;
9
+ }
10
+ export interface OpenClawPluginAPI {
11
+ registerTool: (tool: OpenClawToolDefinition, options?: OpenClawToolRegisterOptions) => void;
12
+ pluginConfig?: Record<string, unknown>;
13
+ env?: Record<string, string | undefined>;
14
+ }
15
+ export interface OpenClawPlugin {
16
+ id: string;
17
+ name: string;
18
+ description: string;
19
+ version: string;
20
+ register: (api: OpenClawPluginAPI) => void;
21
+ }
22
+ export interface ResolveConfigInput {
23
+ config?: Record<string, unknown>;
24
+ env?: Record<string, string | undefined>;
25
+ }
26
+ export interface StatocystPluginConfig {
27
+ baseUrl: string;
28
+ token: string;
29
+ sessionKey: string;
30
+ timeoutMs: number;
31
+ pluginId: string;
32
+ pluginPackage: string;
33
+ pluginVersion: string;
34
+ }
35
+ export interface SkillExecutionRequest {
36
+ toAgentUUID?: string;
37
+ toAgentURI?: string;
38
+ skillName: string;
39
+ input?: unknown;
40
+ timeoutMs?: number;
41
+ sessionKey?: string;
42
+ requestId?: string;
43
+ }
44
+ export interface SkillExecutionResult {
45
+ requestId: string;
46
+ skillName: string;
47
+ status: string;
48
+ output: unknown;
49
+ error?: unknown;
50
+ messageId: string;
51
+ deliveryId: string;
52
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ {
2
+ "id": "statocyst-openclaw",
3
+ "name": "Statocyst Realtime",
4
+ "description": "Realtime Statocyst messaging and skill request/result tools for OpenClaw.",
5
+ "version": "0.1.0",
6
+ "contracts": {
7
+ "tools": ["statocyst_skill_request", "statocyst_session_status"]
8
+ },
9
+ "configSchema": {
10
+ "type": "object",
11
+ "additionalProperties": false,
12
+ "required": ["baseUrl", "token"],
13
+ "properties": {
14
+ "baseUrl": {
15
+ "type": "string",
16
+ "description": "Statocyst API base URL, for example https://hub.example.com/v1"
17
+ },
18
+ "token": {
19
+ "type": "string",
20
+ "description": "Statocyst agent bearer token"
21
+ },
22
+ "sessionKey": {
23
+ "type": "string",
24
+ "description": "Dedicated realtime session key",
25
+ "default": "main"
26
+ },
27
+ "timeoutMs": {
28
+ "type": "number",
29
+ "description": "Request timeout in milliseconds",
30
+ "default": 20000
31
+ }
32
+ }
33
+ }
34
+ }
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@moltenbot/openclaw-plugin-statocyst",
3
+ "version": "0.1.0",
4
+ "description": "OpenClaw plugin for realtime Statocyst skill request/result messaging.",
5
+ "type": "module",
6
+ "homepage": "https://molten.bot",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Molten-Bot/statocyst",
10
+ "directory": "statocyst-openclaw-plugin"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/Molten-Bot/statocyst/issues"
14
+ },
15
+ "main": "dist/index.js",
16
+ "types": "dist/index.d.ts",
17
+ "openclaw": {
18
+ "extensions": [
19
+ "./dist/index.js"
20
+ ],
21
+ "install": {
22
+ "npmSpec": "@moltenbot/openclaw-plugin-statocyst",
23
+ "defaultChoice": "npm:@moltenbot/openclaw-plugin-statocyst"
24
+ }
25
+ },
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "openclaw.plugin.json",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.json",
40
+ "test": "vitest run",
41
+ "test:coverage": "vitest run --coverage",
42
+ "test:e2e:container": "node scripts/e2e-container.mjs",
43
+ "prepublishOnly": "npm run build && npm run test:coverage"
44
+ },
45
+ "engines": {
46
+ "node": ">=22"
47
+ },
48
+ "keywords": [
49
+ "openclaw",
50
+ "statocyst",
51
+ "plugin",
52
+ "realtime",
53
+ "molten-ai"
54
+ ],
55
+ "author": "Molten AI (https://molten.bot)",
56
+ "license": "MIT",
57
+ "dependencies": {
58
+ "ws": "^8.18.3"
59
+ },
60
+ "devDependencies": {
61
+ "@types/node": "^24.6.0",
62
+ "@types/ws": "^8.18.1",
63
+ "@vitest/coverage-v8": "^3.2.4",
64
+ "typescript": "^5.9.3",
65
+ "vitest": "^3.2.4"
66
+ }
67
+ }