clawmatrix 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/README.md +15 -0
- package/llms.txt +191 -0
- package/openclaw.plugin.json +76 -0
- package/package.json +43 -0
- package/src/auth.ts +38 -0
- package/src/cli.ts +84 -0
- package/src/cluster-service.ts +160 -0
- package/src/config.ts +50 -0
- package/src/connection.ts +261 -0
- package/src/handoff.ts +201 -0
- package/src/index.ts +119 -0
- package/src/model-proxy.ts +441 -0
- package/src/peer-manager.ts +333 -0
- package/src/router.ts +275 -0
- package/src/tool-proxy.ts +324 -0
- package/src/tools/cluster-exec.ts +66 -0
- package/src/tools/cluster-handoff.ts +82 -0
- package/src/tools/cluster-ls.ts +51 -0
- package/src/tools/cluster-peers.ts +55 -0
- package/src/tools/cluster-read.ts +48 -0
- package/src/tools/cluster-send.ts +79 -0
- package/src/tools/cluster-write.ts +61 -0
- package/src/types.ts +197 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import type {
|
|
3
|
+
AnyClusterFrame,
|
|
4
|
+
AuthChallenge,
|
|
5
|
+
AuthOk,
|
|
6
|
+
AuthFail,
|
|
7
|
+
ClusterFrame,
|
|
8
|
+
NodeCapabilities,
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
import { generateNonce, computeHmac, verifyHmac } from "./auth.ts";
|
|
11
|
+
|
|
12
|
+
const HEARTBEAT_BASE = 12_000;
|
|
13
|
+
const HEARTBEAT_JITTER = 6_000;
|
|
14
|
+
const HEARTBEAT_TIMEOUT_COUNT = 3;
|
|
15
|
+
|
|
16
|
+
export type ConnectionRole = "inbound" | "outbound";
|
|
17
|
+
|
|
18
|
+
/** Minimal transport interface that works with both standard WebSocket and Bun's ServerWebSocket. */
|
|
19
|
+
export interface WsTransport {
|
|
20
|
+
send(data: string): void;
|
|
21
|
+
close(code?: number, reason?: string): void;
|
|
22
|
+
readonly readyState: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ConnectionEvents {
|
|
26
|
+
message: [frame: AnyClusterFrame];
|
|
27
|
+
authenticated: [capabilities: NodeCapabilities];
|
|
28
|
+
close: [code: number, reason: string];
|
|
29
|
+
error: [error: Error];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class Connection extends EventEmitter<ConnectionEvents> {
|
|
33
|
+
readonly role: ConnectionRole;
|
|
34
|
+
private transport: WsTransport;
|
|
35
|
+
private nodeId: string;
|
|
36
|
+
private secret: string;
|
|
37
|
+
private localCapabilities: NodeCapabilities;
|
|
38
|
+
|
|
39
|
+
remoteNodeId: string | null = null;
|
|
40
|
+
remoteCapabilities: NodeCapabilities | null = null;
|
|
41
|
+
authenticated = false;
|
|
42
|
+
|
|
43
|
+
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
|
44
|
+
private missedPongs = 0;
|
|
45
|
+
private pendingNonce: string | null = null;
|
|
46
|
+
private closed = false;
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
transport: WsTransport,
|
|
50
|
+
role: ConnectionRole,
|
|
51
|
+
nodeId: string,
|
|
52
|
+
secret: string,
|
|
53
|
+
localCapabilities: NodeCapabilities,
|
|
54
|
+
) {
|
|
55
|
+
super();
|
|
56
|
+
this.transport = transport;
|
|
57
|
+
this.role = role;
|
|
58
|
+
this.nodeId = nodeId;
|
|
59
|
+
this.secret = secret;
|
|
60
|
+
this.localCapabilities = localCapabilities;
|
|
61
|
+
|
|
62
|
+
if (role === "inbound") {
|
|
63
|
+
this.sendAuthChallenge();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Bind standard WebSocket event listeners. Call this for outbound connections. */
|
|
68
|
+
bindWebSocket(ws: WebSocket) {
|
|
69
|
+
ws.addEventListener("message", (ev) => this.onRawMessage(ev.data));
|
|
70
|
+
ws.addEventListener("close", (ev) => {
|
|
71
|
+
this.close(ev.code, ev.reason);
|
|
72
|
+
});
|
|
73
|
+
ws.addEventListener("error", () => {
|
|
74
|
+
this.emit("error", new Error("WebSocket error"));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Feed a raw message into this connection (for Bun ServerWebSocket). */
|
|
79
|
+
feedMessage(data: string | Buffer) {
|
|
80
|
+
this.onRawMessage(data);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Notify this connection that the transport closed. */
|
|
84
|
+
feedClose(code: number, reason: string) {
|
|
85
|
+
this.close(code, reason);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Handshake (server side) ────────────────────────────────────
|
|
89
|
+
private async sendAuthChallenge() {
|
|
90
|
+
const nonce = generateNonce();
|
|
91
|
+
this.pendingNonce = nonce;
|
|
92
|
+
this.sendRaw({
|
|
93
|
+
type: "auth_challenge",
|
|
94
|
+
from: this.nodeId,
|
|
95
|
+
timestamp: Date.now(),
|
|
96
|
+
payload: { nonce },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Send helpers ───────────────────────────────────────────────
|
|
101
|
+
send(frame: ClusterFrame | AnyClusterFrame) {
|
|
102
|
+
if (this.closed) return;
|
|
103
|
+
this.sendRaw(frame);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private sendRaw(data: unknown) {
|
|
107
|
+
if (this.transport.readyState === WebSocket.OPEN) {
|
|
108
|
+
this.transport.send(JSON.stringify(data));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Message dispatch ───────────────────────────────────────────
|
|
113
|
+
private async onRawMessage(data: unknown) {
|
|
114
|
+
let frame: AnyClusterFrame;
|
|
115
|
+
try {
|
|
116
|
+
frame = JSON.parse(typeof data === "string" ? data : String(data));
|
|
117
|
+
} catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!this.authenticated) {
|
|
122
|
+
await this.handleAuthMessage(frame);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (frame.type === "ping") {
|
|
127
|
+
this.sendRaw({
|
|
128
|
+
type: "pong",
|
|
129
|
+
from: this.nodeId,
|
|
130
|
+
timestamp: Date.now(),
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (frame.type === "pong") {
|
|
135
|
+
this.missedPongs = 0;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.emit("message", frame);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async handleAuthMessage(frame: AnyClusterFrame) {
|
|
143
|
+
if (this.role === "inbound") {
|
|
144
|
+
if (frame.type !== "auth") return;
|
|
145
|
+
const { nodeId, sig } = frame.payload as { nodeId: string; sig: string };
|
|
146
|
+
if (!this.pendingNonce) return;
|
|
147
|
+
|
|
148
|
+
const valid = await verifyHmac(this.pendingNonce, this.secret, sig);
|
|
149
|
+
this.pendingNonce = null;
|
|
150
|
+
|
|
151
|
+
if (!valid) {
|
|
152
|
+
this.sendRaw({
|
|
153
|
+
type: "auth_fail",
|
|
154
|
+
from: this.nodeId,
|
|
155
|
+
timestamp: Date.now(),
|
|
156
|
+
payload: { reason: "Invalid signature" },
|
|
157
|
+
} satisfies AuthFail);
|
|
158
|
+
this.close(4001, "auth failed");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this.remoteNodeId = nodeId;
|
|
163
|
+
this.authenticated = true;
|
|
164
|
+
|
|
165
|
+
this.sendRaw({
|
|
166
|
+
type: "auth_ok",
|
|
167
|
+
from: this.nodeId,
|
|
168
|
+
timestamp: Date.now(),
|
|
169
|
+
payload: {
|
|
170
|
+
nodeId: this.nodeId,
|
|
171
|
+
agents: this.localCapabilities.agents,
|
|
172
|
+
models: this.localCapabilities.models,
|
|
173
|
+
tags: this.localCapabilities.tags,
|
|
174
|
+
},
|
|
175
|
+
} satisfies AuthOk);
|
|
176
|
+
|
|
177
|
+
this.startHeartbeat();
|
|
178
|
+
this.emit("authenticated", {
|
|
179
|
+
nodeId,
|
|
180
|
+
agents: [],
|
|
181
|
+
models: [],
|
|
182
|
+
tags: [],
|
|
183
|
+
});
|
|
184
|
+
} else {
|
|
185
|
+
if (frame.type === "auth_challenge") {
|
|
186
|
+
const { nonce } = (frame as AuthChallenge).payload;
|
|
187
|
+
const sig = await computeHmac(nonce, this.secret);
|
|
188
|
+
this.sendRaw({
|
|
189
|
+
type: "auth",
|
|
190
|
+
from: this.nodeId,
|
|
191
|
+
timestamp: Date.now(),
|
|
192
|
+
payload: { nodeId: this.nodeId, sig },
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (frame.type === "auth_ok") {
|
|
198
|
+
const ok = frame as AuthOk;
|
|
199
|
+
this.remoteNodeId = ok.payload.nodeId;
|
|
200
|
+
this.remoteCapabilities = {
|
|
201
|
+
nodeId: ok.payload.nodeId,
|
|
202
|
+
agents: ok.payload.agents,
|
|
203
|
+
models: ok.payload.models,
|
|
204
|
+
tags: ok.payload.tags,
|
|
205
|
+
};
|
|
206
|
+
this.authenticated = true;
|
|
207
|
+
this.startHeartbeat();
|
|
208
|
+
this.emit("authenticated", this.remoteCapabilities);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (frame.type === "auth_fail") {
|
|
213
|
+
const fail = frame as AuthFail;
|
|
214
|
+
this.emit("error", new Error(`Auth failed: ${fail.payload.reason}`));
|
|
215
|
+
this.close(4001, "auth failed");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Heartbeat ──────────────────────────────────────────────────
|
|
221
|
+
private startHeartbeat() {
|
|
222
|
+
const scheduleNext = () => {
|
|
223
|
+
const interval = HEARTBEAT_BASE + Math.random() * HEARTBEAT_JITTER;
|
|
224
|
+
this.heartbeatTimer = setTimeout(() => {
|
|
225
|
+
if (this.closed) return;
|
|
226
|
+
this.missedPongs++;
|
|
227
|
+
if (this.missedPongs >= HEARTBEAT_TIMEOUT_COUNT) {
|
|
228
|
+
this.close(4002, "heartbeat timeout");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
this.sendRaw({
|
|
232
|
+
type: "ping",
|
|
233
|
+
from: this.nodeId,
|
|
234
|
+
timestamp: Date.now(),
|
|
235
|
+
});
|
|
236
|
+
scheduleNext();
|
|
237
|
+
}, interval);
|
|
238
|
+
};
|
|
239
|
+
scheduleNext();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Cleanup ────────────────────────────────────────────────────
|
|
243
|
+
close(code = 1000, reason = "normal") {
|
|
244
|
+
if (this.closed) return;
|
|
245
|
+
this.closed = true;
|
|
246
|
+
if (this.heartbeatTimer) {
|
|
247
|
+
clearTimeout(this.heartbeatTimer);
|
|
248
|
+
this.heartbeatTimer = null;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
this.transport.close(code, reason);
|
|
252
|
+
} catch {
|
|
253
|
+
// already closed
|
|
254
|
+
}
|
|
255
|
+
this.emit("close", code, reason);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
get isOpen(): boolean {
|
|
259
|
+
return !this.closed && this.transport.readyState === WebSocket.OPEN;
|
|
260
|
+
}
|
|
261
|
+
}
|
package/src/handoff.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { PeerManager } from "./peer-manager.ts";
|
|
2
|
+
import type { ClawMatrixConfig } from "./config.ts";
|
|
3
|
+
import type {
|
|
4
|
+
HandoffRequest,
|
|
5
|
+
HandoffResponse,
|
|
6
|
+
AnyClusterFrame,
|
|
7
|
+
} from "./types.ts";
|
|
8
|
+
|
|
9
|
+
const HANDOFF_TIMEOUT = 120_000; // 2 minutes
|
|
10
|
+
const MAX_RETRIES = 2;
|
|
11
|
+
|
|
12
|
+
interface PendingHandoff {
|
|
13
|
+
resolve: (result: HandoffResponse["payload"]) => void;
|
|
14
|
+
reject: (error: Error) => void;
|
|
15
|
+
timer: ReturnType<typeof setTimeout>;
|
|
16
|
+
target: string;
|
|
17
|
+
retriesLeft: number;
|
|
18
|
+
task: string;
|
|
19
|
+
context?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class HandoffManager {
|
|
23
|
+
private config: ClawMatrixConfig;
|
|
24
|
+
private peerManager: PeerManager;
|
|
25
|
+
private pending = new Map<string, PendingHandoff>();
|
|
26
|
+
|
|
27
|
+
constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.peerManager = peerManager;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Send a handoff request and wait for the response. */
|
|
33
|
+
async handoff(
|
|
34
|
+
target: string,
|
|
35
|
+
task: string,
|
|
36
|
+
context?: string,
|
|
37
|
+
): Promise<HandoffResponse["payload"]> {
|
|
38
|
+
const route = this.peerManager.router.resolveAgent(target);
|
|
39
|
+
if (!route) {
|
|
40
|
+
throw new Error(`No reachable agent for target "${target}"`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return this.sendHandoff(route.nodeId, target, task, context, MAX_RETRIES);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private sendHandoff(
|
|
47
|
+
targetNodeId: string,
|
|
48
|
+
target: string,
|
|
49
|
+
task: string,
|
|
50
|
+
context: string | undefined,
|
|
51
|
+
retriesLeft: number,
|
|
52
|
+
): Promise<HandoffResponse["payload"]> {
|
|
53
|
+
const id = crypto.randomUUID();
|
|
54
|
+
|
|
55
|
+
return new Promise<HandoffResponse["payload"]>((resolve, reject) => {
|
|
56
|
+
const timer = setTimeout(() => {
|
|
57
|
+
this.pending.delete(id);
|
|
58
|
+
this.peerManager.router.markFailed(id);
|
|
59
|
+
|
|
60
|
+
// Retry with failover
|
|
61
|
+
if (retriesLeft > 0) {
|
|
62
|
+
const nextRoute = this.peerManager.router.resolveAgent(target);
|
|
63
|
+
if (nextRoute && nextRoute.nodeId !== targetNodeId) {
|
|
64
|
+
this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1)
|
|
65
|
+
.then(resolve)
|
|
66
|
+
.catch(reject);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
reject(new Error(`Handoff to "${target}" timed out`));
|
|
71
|
+
}, HANDOFF_TIMEOUT);
|
|
72
|
+
|
|
73
|
+
this.pending.set(id, { resolve, reject, timer, target, retriesLeft, task, context });
|
|
74
|
+
|
|
75
|
+
const frame: HandoffRequest = {
|
|
76
|
+
type: "handoff_req",
|
|
77
|
+
id,
|
|
78
|
+
from: this.config.nodeId,
|
|
79
|
+
to: targetNodeId,
|
|
80
|
+
timestamp: Date.now(),
|
|
81
|
+
payload: { target, task, context },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const sent = this.peerManager.sendTo(targetNodeId, frame);
|
|
85
|
+
if (!sent) {
|
|
86
|
+
this.pending.delete(id);
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
|
|
89
|
+
// Retry immediately with another node
|
|
90
|
+
if (retriesLeft > 0) {
|
|
91
|
+
const nextRoute = this.peerManager.router.resolveAgent(target);
|
|
92
|
+
if (nextRoute && nextRoute.nodeId !== targetNodeId) {
|
|
93
|
+
this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1)
|
|
94
|
+
.then(resolve)
|
|
95
|
+
.catch(reject);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
reject(new Error(`Cannot reach node "${targetNodeId}" for handoff`));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Handle incoming handoff response. */
|
|
105
|
+
handleResponse(frame: HandoffResponse) {
|
|
106
|
+
if (this.peerManager.router.isFailed(frame.id)) return;
|
|
107
|
+
|
|
108
|
+
const pending = this.pending.get(frame.id);
|
|
109
|
+
if (!pending) return;
|
|
110
|
+
|
|
111
|
+
clearTimeout(pending.timer);
|
|
112
|
+
this.pending.delete(frame.id);
|
|
113
|
+
pending.resolve(frame.payload);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Handle incoming handoff request (execute locally). */
|
|
117
|
+
async handleRequest(frame: HandoffRequest): Promise<void> {
|
|
118
|
+
const { id, from, payload } = frame;
|
|
119
|
+
|
|
120
|
+
// Find matching local agent
|
|
121
|
+
const agent = this.config.agents.find((a) => {
|
|
122
|
+
if (payload.target.startsWith("tags:")) {
|
|
123
|
+
const tag = payload.target.slice(5);
|
|
124
|
+
return a.tags.includes(tag);
|
|
125
|
+
}
|
|
126
|
+
return a.id === payload.target;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (!agent) {
|
|
130
|
+
this.peerManager.sendTo(from, {
|
|
131
|
+
type: "handoff_res",
|
|
132
|
+
id,
|
|
133
|
+
from: this.config.nodeId,
|
|
134
|
+
to: from,
|
|
135
|
+
timestamp: Date.now(),
|
|
136
|
+
payload: {
|
|
137
|
+
success: false,
|
|
138
|
+
error: `No matching agent for "${payload.target}"`,
|
|
139
|
+
},
|
|
140
|
+
} satisfies HandoffResponse);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
// Execute via openclaw agent subprocess
|
|
146
|
+
const message = payload.context
|
|
147
|
+
? `${payload.task}\n\nContext:\n${payload.context}`
|
|
148
|
+
: payload.task;
|
|
149
|
+
|
|
150
|
+
const proc = Bun.spawn(["openclaw", "agent", "--message", message], {
|
|
151
|
+
stdout: "pipe",
|
|
152
|
+
stderr: "pipe",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const stdout = await new Response(proc.stdout).text();
|
|
156
|
+
const exitCode = await proc.exited;
|
|
157
|
+
|
|
158
|
+
if (exitCode !== 0) {
|
|
159
|
+
const stderr = await new Response(proc.stderr).text();
|
|
160
|
+
throw new Error(`Agent exited with code ${exitCode}: ${stderr}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.peerManager.sendTo(from, {
|
|
164
|
+
type: "handoff_res",
|
|
165
|
+
id,
|
|
166
|
+
from: this.config.nodeId,
|
|
167
|
+
to: from,
|
|
168
|
+
timestamp: Date.now(),
|
|
169
|
+
payload: {
|
|
170
|
+
success: true,
|
|
171
|
+
nodeId: this.config.nodeId,
|
|
172
|
+
agent: agent.id,
|
|
173
|
+
result: stdout.trim(),
|
|
174
|
+
},
|
|
175
|
+
} satisfies HandoffResponse);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
this.peerManager.sendTo(from, {
|
|
178
|
+
type: "handoff_res",
|
|
179
|
+
id,
|
|
180
|
+
from: this.config.nodeId,
|
|
181
|
+
to: from,
|
|
182
|
+
timestamp: Date.now(),
|
|
183
|
+
payload: {
|
|
184
|
+
success: false,
|
|
185
|
+
nodeId: this.config.nodeId,
|
|
186
|
+
agent: agent.id,
|
|
187
|
+
error: err instanceof Error ? err.message : String(err),
|
|
188
|
+
},
|
|
189
|
+
} satisfies HandoffResponse);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Clean up on shutdown. */
|
|
194
|
+
destroy() {
|
|
195
|
+
for (const [id, pending] of this.pending) {
|
|
196
|
+
clearTimeout(pending.timer);
|
|
197
|
+
pending.reject(new Error("Shutting down"));
|
|
198
|
+
}
|
|
199
|
+
this.pending.clear();
|
|
200
|
+
}
|
|
201
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { ClawMatrixConfigSchema, parseConfig } from "./config.ts";
|
|
3
|
+
import { createClusterService, getClusterRuntime } from "./cluster-service.ts";
|
|
4
|
+
import { createClusterHandoffTool } from "./tools/cluster-handoff.ts";
|
|
5
|
+
import { createClusterSendTool } from "./tools/cluster-send.ts";
|
|
6
|
+
import { createClusterPeersTool } from "./tools/cluster-peers.ts";
|
|
7
|
+
import { createClusterExecTool } from "./tools/cluster-exec.ts";
|
|
8
|
+
import { createClusterReadTool } from "./tools/cluster-read.ts";
|
|
9
|
+
import { createClusterWriteTool } from "./tools/cluster-write.ts";
|
|
10
|
+
import { createClusterLsTool } from "./tools/cluster-ls.ts";
|
|
11
|
+
import { registerClusterCli } from "./cli.ts";
|
|
12
|
+
|
|
13
|
+
const plugin = {
|
|
14
|
+
id: "clawmatrix",
|
|
15
|
+
name: "ClawMatrix",
|
|
16
|
+
description:
|
|
17
|
+
"Decentralized mesh cluster for inter-gateway communication, " +
|
|
18
|
+
"model proxy, task handoff, and tool proxy.",
|
|
19
|
+
|
|
20
|
+
configSchema: {
|
|
21
|
+
safeParse(value: unknown) {
|
|
22
|
+
const result = ClawMatrixConfigSchema.safeParse(value);
|
|
23
|
+
if (result.success) {
|
|
24
|
+
return { success: true, data: result.data };
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
success: false,
|
|
28
|
+
error: {
|
|
29
|
+
issues: result.error.issues.map((i) => ({
|
|
30
|
+
path: i.path.map(String),
|
|
31
|
+
message: i.message,
|
|
32
|
+
})),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
register(api: OpenClawPluginApi) {
|
|
39
|
+
const config = parseConfig(api.pluginConfig);
|
|
40
|
+
|
|
41
|
+
// Background service: manages mesh connections, WS listener, heartbeat
|
|
42
|
+
api.registerService(createClusterService(config));
|
|
43
|
+
|
|
44
|
+
// Model provider: register clawmatrix as a provider pointing to local HTTP proxy
|
|
45
|
+
api.registerProvider({
|
|
46
|
+
id: "clawmatrix",
|
|
47
|
+
label: "ClawMatrix Cluster",
|
|
48
|
+
docsPath: "/plugins/clawmatrix",
|
|
49
|
+
auth: [],
|
|
50
|
+
models: {
|
|
51
|
+
baseUrl: `http://127.0.0.1:${config.proxyPort}`,
|
|
52
|
+
api: "openai-completions",
|
|
53
|
+
models: getAllClusterModels(config),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Agent tools
|
|
58
|
+
api.registerTool(createClusterHandoffTool(), { optional: true });
|
|
59
|
+
api.registerTool(createClusterSendTool(), { optional: true });
|
|
60
|
+
api.registerTool(createClusterPeersTool(), { optional: true });
|
|
61
|
+
api.registerTool(createClusterExecTool(), { optional: true });
|
|
62
|
+
api.registerTool(createClusterReadTool(), { optional: true });
|
|
63
|
+
api.registerTool(createClusterWriteTool(), { optional: true });
|
|
64
|
+
api.registerTool(createClusterLsTool(), { optional: true });
|
|
65
|
+
|
|
66
|
+
// CLI subcommand
|
|
67
|
+
api.registerCli(registerClusterCli, { commands: ["clawmatrix"] });
|
|
68
|
+
|
|
69
|
+
// Inject cluster context into agent prompts
|
|
70
|
+
api.on("before_prompt_build", () => {
|
|
71
|
+
try {
|
|
72
|
+
const runtime = getClusterRuntime();
|
|
73
|
+
const peers = runtime.peerManager.router.getAllPeers();
|
|
74
|
+
if (peers.length === 0) return;
|
|
75
|
+
|
|
76
|
+
const lines = [
|
|
77
|
+
`[ClawMatrix Cluster] This node: ${config.nodeId}`,
|
|
78
|
+
`Connected peers:`,
|
|
79
|
+
];
|
|
80
|
+
for (const peer of peers) {
|
|
81
|
+
const agents = peer.agents.map((a) => a.id).join(", ") || "none";
|
|
82
|
+
const models = peer.models.map((m) => m.id).join(", ") || "none";
|
|
83
|
+
const status = peer.connection?.isOpen ? "connected" : "via relay";
|
|
84
|
+
lines.push(
|
|
85
|
+
` - ${peer.nodeId} (${status}): agents=[${agents}], models=[${models}]`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
lines.push(
|
|
89
|
+
"",
|
|
90
|
+
"Available cluster tools: cluster_handoff, cluster_send, cluster_peers, " +
|
|
91
|
+
"cluster_exec, cluster_read, cluster_write, cluster_ls",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return { prependContext: lines.join("\n") };
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function getAllClusterModels(config: ReturnType<typeof parseConfig>) {
|
|
103
|
+
// We register placeholder models from config.
|
|
104
|
+
// The actual model list is dynamic (from cluster peers),
|
|
105
|
+
// but we need to declare known models at registration time.
|
|
106
|
+
// The local HTTP proxy handles routing to the right cluster node.
|
|
107
|
+
return config.models.map((m) => ({
|
|
108
|
+
id: m.id,
|
|
109
|
+
name: m.id,
|
|
110
|
+
api: "openai-completions" as const,
|
|
111
|
+
reasoning: false,
|
|
112
|
+
input: ["text" as const],
|
|
113
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
114
|
+
contextWindow: 128_000,
|
|
115
|
+
maxTokens: 4096,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export default plugin;
|