clawmatrix 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmatrix",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Decentralized mesh cluster plugin for OpenClaw — inter-gateway communication, model proxy, task handoff, and tool proxy.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -6,10 +6,29 @@ const AgentInfoSchema = z.object({
6
6
  tags: z.array(z.string()).default([]),
7
7
  });
8
8
 
9
+ const ModelParamsSchema = {
10
+ api: z.enum([
11
+ "openai-completions", "openai-responses", "openai-codex-responses",
12
+ "anthropic-messages", "google-generative-ai", "github-copilot",
13
+ "bedrock-converse-stream", "ollama",
14
+ ]).optional(),
15
+ contextWindow: z.number().optional(),
16
+ maxTokens: z.number().optional(),
17
+ reasoning: z.boolean().optional(),
18
+ input: z.array(z.enum(["text", "image"])).optional(),
19
+ cost: z.object({
20
+ input: z.number(),
21
+ output: z.number(),
22
+ cacheRead: z.number(),
23
+ cacheWrite: z.number(),
24
+ }).optional(),
25
+ };
26
+
9
27
  const ModelInfoSchema = z.object({
10
28
  id: z.string(),
11
29
  provider: z.string(),
12
30
  description: z.string().optional(),
31
+ ...ModelParamsSchema,
13
32
  });
14
33
 
15
34
  const PeerConfigSchema = z.object({
@@ -24,15 +43,23 @@ const ToolProxyConfigSchema = z.object({
24
43
  maxOutputBytes: z.number().default(1_048_576),
25
44
  });
26
45
 
46
+ const ProxyModelSchema = z.object({
47
+ id: z.string(),
48
+ nodeId: z.string().optional(),
49
+ description: z.string().optional(),
50
+ ...ModelParamsSchema,
51
+ });
52
+
27
53
  export const ClawMatrixConfigSchema = z.object({
28
54
  nodeId: z.string(),
29
- secret: z.string(),
55
+ secret: z.string().min(16, "secret must be at least 16 characters"),
30
56
  listen: z.boolean().default(false),
31
57
  listenHost: z.string().default("0.0.0.0"),
32
58
  listenPort: z.number().default(19000),
33
59
  peers: z.array(PeerConfigSchema).default([]),
34
60
  agents: z.array(AgentInfoSchema).default([]),
35
61
  models: z.array(ModelInfoSchema).default([]),
62
+ proxyModels: z.array(ProxyModelSchema).default([]),
36
63
  tags: z.array(z.string()).default([]),
37
64
  proxyPort: z.number().default(19001),
38
65
  toolProxy: ToolProxyConfigSchema.optional(),
package/src/connection.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import type {
3
+ AgentInfo,
3
4
  AnyClusterFrame,
4
5
  AuthChallenge,
5
6
  AuthOk,
6
7
  AuthFail,
7
8
  ClusterFrame,
9
+ ModelInfo,
8
10
  NodeCapabilities,
9
11
  } from "./types.ts";
10
12
  import { generateNonce, computeHmac, verifyHmac } from "./auth.ts";
@@ -12,6 +14,7 @@ import { generateNonce, computeHmac, verifyHmac } from "./auth.ts";
12
14
  const HEARTBEAT_BASE = 12_000;
13
15
  const HEARTBEAT_JITTER = 6_000;
14
16
  const HEARTBEAT_TIMEOUT_COUNT = 3;
17
+ const AUTH_TIMEOUT = 10_000;
15
18
 
16
19
  export type ConnectionRole = "inbound" | "outbound";
17
20
 
@@ -41,6 +44,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
41
44
  authenticated = false;
42
45
 
43
46
  private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
47
+ private authTimer: ReturnType<typeof setTimeout> | null = null;
44
48
  private missedPongs = 0;
45
49
  private pendingNonce: string | null = null;
46
50
  private closed = false;
@@ -61,6 +65,11 @@ export class Connection extends EventEmitter<ConnectionEvents> {
61
65
 
62
66
  if (role === "inbound") {
63
67
  this.sendAuthChallenge();
68
+ this.authTimer = setTimeout(() => {
69
+ if (!this.authenticated) {
70
+ this.close(4003, "auth timeout");
71
+ }
72
+ }, AUTH_TIMEOUT);
64
73
  }
65
74
  }
66
75
 
@@ -142,7 +151,13 @@ export class Connection extends EventEmitter<ConnectionEvents> {
142
151
  private async handleAuthMessage(frame: AnyClusterFrame) {
143
152
  if (this.role === "inbound") {
144
153
  if (frame.type !== "auth") return;
145
- const { nodeId, sig } = frame.payload as { nodeId: string; sig: string };
154
+ const { nodeId, sig, agents, models, tags } = frame.payload as {
155
+ nodeId: string;
156
+ sig: string;
157
+ agents?: AgentInfo[];
158
+ models?: ModelInfo[];
159
+ tags?: string[];
160
+ };
146
161
  if (!this.pendingNonce) return;
147
162
 
148
163
  const valid = await verifyHmac(this.pendingNonce, this.secret, sig);
@@ -160,7 +175,14 @@ export class Connection extends EventEmitter<ConnectionEvents> {
160
175
  }
161
176
 
162
177
  this.remoteNodeId = nodeId;
178
+ this.remoteCapabilities = {
179
+ nodeId,
180
+ agents: agents ?? [],
181
+ models: models ?? [],
182
+ tags: tags ?? [],
183
+ };
163
184
  this.authenticated = true;
185
+ this.clearAuthTimer();
164
186
 
165
187
  this.sendRaw({
166
188
  type: "auth_ok",
@@ -175,12 +197,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
175
197
  } satisfies AuthOk);
176
198
 
177
199
  this.startHeartbeat();
178
- this.emit("authenticated", {
179
- nodeId,
180
- agents: [],
181
- models: [],
182
- tags: [],
183
- });
200
+ this.emit("authenticated", this.remoteCapabilities);
184
201
  } else {
185
202
  if (frame.type === "auth_challenge") {
186
203
  const { nonce } = (frame as AuthChallenge).payload;
@@ -189,7 +206,13 @@ export class Connection extends EventEmitter<ConnectionEvents> {
189
206
  type: "auth",
190
207
  from: this.nodeId,
191
208
  timestamp: Date.now(),
192
- payload: { nodeId: this.nodeId, sig },
209
+ payload: {
210
+ nodeId: this.nodeId,
211
+ sig,
212
+ agents: this.localCapabilities.agents,
213
+ models: this.localCapabilities.models,
214
+ tags: this.localCapabilities.tags,
215
+ },
193
216
  });
194
217
  return;
195
218
  }
@@ -217,6 +240,13 @@ export class Connection extends EventEmitter<ConnectionEvents> {
217
240
  }
218
241
  }
219
242
 
243
+ private clearAuthTimer() {
244
+ if (this.authTimer) {
245
+ clearTimeout(this.authTimer);
246
+ this.authTimer = null;
247
+ }
248
+ }
249
+
220
250
  // ── Heartbeat ──────────────────────────────────────────────────
221
251
  private startHeartbeat() {
222
252
  const scheduleNext = () => {
@@ -243,6 +273,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
243
273
  close(code = 1000, reason = "normal") {
244
274
  if (this.closed) return;
245
275
  this.closed = true;
276
+ this.clearAuthTimer();
246
277
  if (this.heartbeatTimer) {
247
278
  clearTimeout(this.heartbeatTimer);
248
279
  this.heartbeatTimer = null;
package/src/index.ts CHANGED
@@ -74,21 +74,39 @@ const plugin = {
74
74
  if (peers.length === 0) return;
75
75
 
76
76
  const lines = [
77
- `[ClawMatrix Cluster] This node: ${config.nodeId}`,
78
- `Connected peers:`,
77
+ `[ClawMatrix Cluster]`,
78
+ `You are on node "${config.nodeId}"${config.tags.length ? ` (tags: ${config.tags.join(", ")})` : ""}.`,
79
79
  ];
80
+
81
+ if (config.agents.length > 0) {
82
+ const localAgent = config.agents[0]!;
83
+ lines.push(`Your role: ${localAgent.description}`);
84
+ }
85
+
86
+ lines.push("", "Remote nodes in the cluster:");
80
87
  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
88
  const status = peer.connection?.isOpen ? "connected" : "via relay";
84
- lines.push(
85
- ` - ${peer.nodeId} (${status}): agents=[${agents}], models=[${models}]`,
86
- );
89
+ const tags = peer.tags.length ? ` [${peer.tags.join(", ")}]` : "";
90
+ lines.push(` - ${peer.nodeId} (${status})${tags}`);
91
+ for (const agent of peer.agents) {
92
+ lines.push(` agent "${agent.id}": ${agent.description}`);
93
+ }
94
+ if (peer.models.length > 0) {
95
+ lines.push(` models: ${peer.models.map((m) => m.id).join(", ")}`);
96
+ }
87
97
  }
98
+
88
99
  lines.push(
89
100
  "",
90
- "Available cluster tools: cluster_handoff, cluster_send, cluster_peers, " +
91
- "cluster_exec, cluster_read, cluster_write, cluster_tool",
101
+ "When a task involves resources on a remote node (files, commands, services), " +
102
+ "use cluster tools to operate there directly:",
103
+ " - cluster_exec / cluster_read / cluster_write — run commands, read/write files on a remote node",
104
+ " - cluster_tool — invoke any OpenClaw tool on a remote node",
105
+ " - cluster_handoff — delegate a complex task to a remote agent for autonomous execution",
106
+ " - cluster_send — send a one-way message to a remote agent",
107
+ " - cluster_peers — inspect cluster topology",
108
+ "Use the node's description and tags to decide which node to target. " +
109
+ "For simple operations, prefer cluster_exec/read/write; for complex multi-step tasks, prefer cluster_handoff.",
92
110
  );
93
111
 
94
112
  return { prependContext: lines.join("\n") };
@@ -100,19 +118,24 @@ const plugin = {
100
118
  };
101
119
 
102
120
  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) => ({
121
+ // models = models this node serves to the cluster
122
+ // proxyModels = remote models this node wants to consume from the cluster
123
+ // Both need to be registered with the OpenClaw provider so it routes requests to our local proxy.
124
+ const seen = new Set<string>();
125
+ const all = [...config.models, ...config.proxyModels].filter((m) => {
126
+ if (seen.has(m.id)) return false;
127
+ seen.add(m.id);
128
+ return true;
129
+ });
130
+ return all.map((m) => ({
108
131
  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,
132
+ name: m.description ?? m.id,
133
+ api: m.api ?? ("openai-completions" as const),
134
+ reasoning: m.reasoning ?? false,
135
+ input: (m.input as Array<"text" | "image">) ?? ["text" as const],
136
+ cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
137
+ contextWindow: m.contextWindow ?? 128_000,
138
+ maxTokens: m.maxTokens ?? 4096,
116
139
  }));
117
140
  }
118
141
 
@@ -116,7 +116,11 @@ export class ModelProxy {
116
116
  }
117
117
 
118
118
  const modelId = body.model;
119
- const route = this.peerManager.router.resolveModel(modelId);
119
+ // Check proxyModels for a nodeId routing hint
120
+ const proxyModel = this.config.proxyModels.find((m) => m.id === modelId);
121
+ const route = proxyModel?.nodeId
122
+ ? this.peerManager.router.getRoute(proxyModel.nodeId)
123
+ : this.peerManager.router.resolveModel(modelId);
120
124
  if (!route) {
121
125
  return {
122
126
  status: 404,
@@ -256,6 +256,9 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
256
256
 
257
257
  // ── Message handling ───────────────────────────────────────────
258
258
  private onFrame(frame: AnyClusterFrame, from: Connection) {
259
+ // Validate from field: must be the direct peer or a known node (relayed)
260
+ if (frame.from && frame.from !== from.remoteNodeId && !this.router.getRoute(frame.from)) return;
261
+
259
262
  if (frame.id && this.router.isDuplicate(frame.id)) return;
260
263
 
261
264
  if (frame.type === "peer_sync") {
package/src/types.ts CHANGED
@@ -17,7 +17,13 @@ export interface AuthChallenge extends ClusterFrame {
17
17
 
18
18
  export interface AuthRequest extends ClusterFrame {
19
19
  type: "auth";
20
- payload: { nodeId: string; sig: string };
20
+ payload: {
21
+ nodeId: string;
22
+ sig: string;
23
+ agents?: AgentInfo[];
24
+ models?: ModelInfo[];
25
+ tags?: string[];
26
+ };
21
27
  }
22
28
 
23
29
  export interface AuthOk extends ClusterFrame {