@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 +21 -0
- package/README.md +100 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/plugin.d.ts +14 -0
- package/dist/plugin.js +143 -0
- package/dist/statocyst-client.d.ts +30 -0
- package/dist/statocyst-client.js +421 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.js +1 -0
- package/openclaw.plugin.json +34 -0
- package/package.json +67 -0
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
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -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
package/dist/plugin.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|