clawmatrix 0.1.20 → 0.1.22

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.20",
3
+ "version": "0.1.22",
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/cli.ts CHANGED
@@ -95,7 +95,9 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
95
95
  models: Array<{ id: string }>;
96
96
  tags: string[];
97
97
  connected: boolean;
98
+ status: "direct" | "relay" | "unreachable";
98
99
  latencyMs: number;
100
+ reachableVia: string | null;
99
101
  }>;
100
102
 
101
103
  if (!peers || peers.length === 0) {
@@ -105,9 +107,9 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
105
107
  return;
106
108
  }
107
109
 
108
- const connected = peers.filter((p) => p.connected).length;
109
- const countStr = `${connected}/${peers.length} connected`;
110
- const countColor = connected === peers.length ? green : connected > 0 ? yellow : red;
110
+ const reachable = peers.filter((p) => p.connected).length;
111
+ const countStr = `${reachable}/${peers.length} reachable`;
112
+ const countColor = reachable === peers.length ? green : reachable > 0 ? yellow : red;
111
113
 
112
114
  console.log(` ${bar}`);
113
115
  console.log(` ${cyan("◆")} ${bold("Peers")} ${countColor(countStr)}`);
@@ -115,10 +117,14 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
115
117
 
116
118
  for (let i = 0; i < peers.length; i++) {
117
119
  const peer = peers[i];
118
- const dot = peer.connected ? green("●") : red("○");
120
+ const dot = peer.status === "direct" ? green("●") : peer.status === "relay" ? yellow("●") : red("○");
119
121
  const latency = peer.connected && peer.latencyMs > 0 ? dim(` ${peer.latencyMs}ms`) : "";
120
- const status = peer.connected ? "" : red(" disconnected");
121
- console.log(` ${bar} ${dot} ${bold(peer.nodeId)}${status}${latency}`);
122
+ const statusLabel = peer.status === "relay"
123
+ ? yellow(` relay via ${peer.reachableVia}`)
124
+ : peer.status === "unreachable"
125
+ ? red(" unreachable")
126
+ : "";
127
+ console.log(` ${bar} ${dot} ${bold(peer.nodeId)}${statusLabel}${latency}`);
122
128
 
123
129
  if (peer.tags.length > 0) {
124
130
  console.log(` ${bar} ${lbl("Tags")}${peer.tags.join(dim(", "))}`);
@@ -65,7 +65,7 @@ export class ClusterRuntime {
65
65
  this.peerManager = new PeerManager(config, openclawVersion);
66
66
  this.handoffManager = new HandoffManager(config, this.peerManager);
67
67
  this.modelProxy = new ModelProxy(config, this.peerManager, gatewayInfo, openclawConfig);
68
- this.toolProxy = new ToolProxy(config, this.peerManager, gatewayInfo);
68
+ this.toolProxy = new ToolProxy(config, this.peerManager, gatewayInfo, logger);
69
69
  }
70
70
 
71
71
  start() {
@@ -101,6 +101,7 @@ export class ClusterRuntime {
101
101
  storePath: path.join(stateDir, "knowledge.automerge"),
102
102
  nodeId: this.config.nodeId,
103
103
  debounce: this.config.knowledge.debounce ?? 5000,
104
+ maxFileSize: this.config.knowledge.maxFileSize ?? 512 * 1024,
104
105
  peerManager: this.peerManager,
105
106
  });
106
107
  this.knowledgeSync.start().then(() => {
package/src/config.ts CHANGED
@@ -80,6 +80,7 @@ const ProxyModelGroupSchema = z.object({
80
80
  const KnowledgeConfigSchema = z.object({
81
81
  enabled: z.boolean().default(false),
82
82
  debounce: z.number().default(5000),
83
+ maxFileSize: z.number().default(512 * 1024),
83
84
  }).optional();
84
85
 
85
86
  const WebConfigSchema = z.object({
package/src/debug.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  const enabled = process.env.CLAWMATRIX_DEBUG !== "0";
2
2
 
3
3
  export function debug(tag: string, msg: string) {
4
- if (enabled) console.error(`[clawmatrix:${tag}] ${msg}`);
4
+ if (enabled) console.debug(`[clawmatrix:${tag}] ${msg}`);
5
5
  }
package/src/index.ts CHANGED
@@ -120,15 +120,19 @@ const plugin = {
120
120
  agents: config.agents.map((a) => ({ id: a.id, description: a.description })),
121
121
  models: config.models.map((m) => ({ id: m.id })),
122
122
  tags: config.tags,
123
- peers: peers.map((p) => ({
124
- nodeId: p.nodeId,
125
- agents: p.agents,
126
- models: p.models,
127
- tags: p.tags,
128
- connected: !!p.connection?.isOpen,
129
- reachableVia: p.reachableVia,
130
- latencyMs: p.latencyMs,
131
- })),
123
+ peers: peers.map((p) => {
124
+ const status = runtime.peerManager.router.getPeerStatus(p);
125
+ return {
126
+ nodeId: p.nodeId,
127
+ agents: p.agents,
128
+ models: p.models,
129
+ tags: p.tags,
130
+ connected: status !== "unreachable",
131
+ status,
132
+ reachableVia: p.reachableVia,
133
+ latencyMs: p.latencyMs,
134
+ };
135
+ }),
132
136
  });
133
137
  } catch {
134
138
  respond(false, { error: "ClawMatrix service not running" });
@@ -141,15 +145,19 @@ const plugin = {
141
145
  ({ respond }: GatewayRequestHandlerOptions) => {
142
146
  try {
143
147
  const runtime = getClusterRuntime();
144
- const peers = runtime.peerManager.router.getAllPeers().map((p) => ({
145
- nodeId: p.nodeId,
146
- agents: p.agents,
147
- models: p.models,
148
- tags: p.tags,
149
- connected: !!p.connection?.isOpen,
150
- reachableVia: p.reachableVia,
151
- latencyMs: p.latencyMs,
152
- }));
148
+ const peers = runtime.peerManager.router.getAllPeers().map((p) => {
149
+ const status = runtime.peerManager.router.getPeerStatus(p);
150
+ return {
151
+ nodeId: p.nodeId,
152
+ agents: p.agents,
153
+ models: p.models,
154
+ tags: p.tags,
155
+ connected: status !== "unreachable",
156
+ status,
157
+ reachableVia: p.reachableVia,
158
+ latencyMs: p.latencyMs,
159
+ };
160
+ });
153
161
  respond(true, peers);
154
162
  } catch {
155
163
  respond(true, []);
@@ -1,6 +1,6 @@
1
1
  import * as Automerge from "@automerge/automerge";
2
2
  import { watch, type FSWatcher } from "node:fs";
3
- import { readdir, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
3
+ import { readdir, readFile, stat as fsStat, writeFile, mkdir, unlink } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import ignore, { type Ignore } from "ignore";
6
6
 
@@ -23,18 +23,33 @@ export interface KnowledgeSyncOptions {
23
23
  nodeId: string;
24
24
  /** Debounce interval in ms for fs changes. */
25
25
  debounce: number;
26
+ /** Max file size in bytes. Files larger than this are skipped. */
27
+ maxFileSize: number;
26
28
  /** PeerManager for sending frames. */
27
29
  peerManager: PeerManager;
28
30
  }
29
31
 
30
32
  const TAG = "knowledge";
31
33
 
34
+ async function streamToString(stream: ReadableStream | null): Promise<string> {
35
+ if (!stream) return "";
36
+ const reader = stream.getReader();
37
+ const chunks: Uint8Array[] = [];
38
+ for (;;) {
39
+ const { done, value } = await reader.read();
40
+ if (done) break;
41
+ chunks.push(value);
42
+ }
43
+ return Buffer.concat(chunks).toString("utf-8");
44
+ }
45
+
32
46
  export class KnowledgeSync {
33
47
  private doc: Automerge.Doc<KnowledgeDoc>;
34
48
  private syncStates = new Map<string, Automerge.SyncState>();
35
49
  private watcher: FSWatcher | null = null;
36
50
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
37
- private suppressWatch = false;
51
+ /** Paths currently being written by exportToFs — suppressed from fs watcher. */
52
+ private writingPaths = new Set<string>();
38
53
  private opts: KnowledgeSyncOptions;
39
54
  private ig: Ignore = ignore();
40
55
 
@@ -74,7 +89,9 @@ export class KnowledgeSync {
74
89
 
75
90
  // Start watching for file changes
76
91
  this.watcher = watch(this.opts.workspacePath, { recursive: true }, (_event, filename) => {
77
- if (this.suppressWatch || !filename) return;
92
+ if (!filename) return;
93
+ // Ignore files currently being written by export
94
+ if (this.writingPaths.has(filename)) return;
78
95
  // Ignore hidden files
79
96
  if (filename.startsWith(".")) return;
80
97
  // Ignore gitignored files
@@ -193,8 +210,11 @@ export class KnowledgeSync {
193
210
  }
194
211
 
195
212
  for (const relPath of Object.keys(docFiles)) {
213
+ // Only treat as deleted if the file is genuinely gone, not just skipped by size limit
196
214
  if (!(relPath in currentFiles)) {
197
- deleted.push(relPath);
215
+ const absPath = path.join(this.opts.workspacePath, relPath);
216
+ const exists = await fsStat(absPath).then(() => true, () => false);
217
+ if (!exists) deleted.push(relPath);
198
218
  }
199
219
  }
200
220
 
@@ -208,19 +228,14 @@ export class KnowledgeSync {
208
228
  (deleted.length > 0 ? ` deleted=[${deleted.join(",")}]` : ""),
209
229
  );
210
230
 
211
- // Update automerge doc
231
+ // Update automerge doc — only write dirty files to minimize change history
212
232
  this.doc = Automerge.change(this.doc, (d) => {
213
233
  if (!d.files) {
214
234
  (d as KnowledgeDoc).files = {};
215
235
  }
216
- for (const [relPath, content] of Object.entries(currentFiles)) {
217
- d.files[relPath] = content;
218
- }
219
- for (const relPath of Object.keys(d.files)) {
220
- if (!(relPath in currentFiles)) {
221
- delete d.files[relPath];
222
- }
223
- }
236
+ for (const p of added) d.files[p] = currentFiles[p];
237
+ for (const p of modified) d.files[p] = currentFiles[p];
238
+ for (const p of deleted) delete d.files[p];
224
239
  });
225
240
 
226
241
  await this.saveDoc();
@@ -286,47 +301,47 @@ export class KnowledgeSync {
286
301
  await this.walkDir(base, relPath, result);
287
302
  } else if (entry.isFile()) {
288
303
  if (this.isIgnored(relPath)) continue;
289
- const content = await readFile(path.join(base, relPath), "utf-8");
304
+ const filePath = path.join(base, relPath);
305
+ const st = await fsStat(filePath);
306
+ if (st.size > this.opts.maxFileSize) continue;
307
+ const content = await readFile(filePath, "utf-8");
290
308
  result[relPath] = content;
291
309
  }
292
310
  }
293
311
  }
294
312
 
295
313
  private async exportToFs() {
296
- this.suppressWatch = true;
297
- try {
298
- const docFiles = this.doc.files ?? {};
299
- const currentFiles = await this.readWorkspaceFiles();
300
-
301
- let written = 0;
314
+ const docFiles = this.doc.files ?? {};
315
+ const currentFiles = await this.readWorkspaceFiles();
302
316
 
303
- for (const [relPath, content] of Object.entries(docFiles)) {
304
- // Don't export files that would be gitignored
305
- if (this.isIgnored(relPath)) continue;
306
- if (currentFiles[relPath] !== content) {
307
- const absPath = path.join(this.opts.workspacePath, relPath);
308
- await mkdir(path.dirname(absPath), { recursive: true });
309
- await writeFile(absPath, content, "utf-8");
310
- written++;
311
- }
317
+ let written = 0;
318
+
319
+ for (const [relPath, content] of Object.entries(docFiles)) {
320
+ // Don't export files that would be gitignored
321
+ if (this.isIgnored(relPath)) continue;
322
+ if (currentFiles[relPath] !== content) {
323
+ this.writingPaths.add(relPath);
324
+ const absPath = path.join(this.opts.workspacePath, relPath);
325
+ await mkdir(path.dirname(absPath), { recursive: true });
326
+ await writeFile(absPath, content, "utf-8");
327
+ setTimeout(() => this.writingPaths.delete(relPath), 500);
328
+ written++;
312
329
  }
330
+ }
313
331
 
314
- let removed = 0;
315
- for (const relPath of Object.keys(currentFiles)) {
316
- if (!(relPath in docFiles)) {
317
- const absPath = path.join(this.opts.workspacePath, relPath);
318
- await unlink(absPath).catch(() => {});
319
- removed++;
320
- }
332
+ let removed = 0;
333
+ for (const relPath of Object.keys(currentFiles)) {
334
+ if (!(relPath in docFiles)) {
335
+ this.writingPaths.add(relPath);
336
+ const absPath = path.join(this.opts.workspacePath, relPath);
337
+ await unlink(absPath).catch(() => {});
338
+ setTimeout(() => this.writingPaths.delete(relPath), 500);
339
+ removed++;
321
340
  }
341
+ }
322
342
 
323
- if (written > 0 || removed > 0) {
324
- debug(TAG, `exported to filesystem: ${written} written, ${removed} removed`);
325
- }
326
- } finally {
327
- setTimeout(() => {
328
- this.suppressWatch = false;
329
- }, 500);
343
+ if (written > 0 || removed > 0) {
344
+ debug(TAG, `exported to filesystem: ${written} written, ${removed} removed`);
330
345
  }
331
346
  }
332
347
 
@@ -353,6 +368,34 @@ export class KnowledgeSync {
353
368
  } else {
354
369
  debug(TAG, "git repo already initialized");
355
370
  }
371
+
372
+ // Ensure local git user config exists (so commits don't fail on unconfigured machines)
373
+ const nameCheck = spawnProcess(["git", "config", "user.name"], {
374
+ cwd: this.opts.workspacePath,
375
+ stdout: "pipe",
376
+ stderr: "pipe",
377
+ });
378
+ if ((await nameCheck.exited) !== 0) {
379
+ const setName = spawnProcess(["git", "config", "user.name", "clawmatrix"], {
380
+ cwd: this.opts.workspacePath,
381
+ stdout: "pipe",
382
+ stderr: "pipe",
383
+ });
384
+ await setName.exited;
385
+ }
386
+ const emailCheck = spawnProcess(["git", "config", "user.email"], {
387
+ cwd: this.opts.workspacePath,
388
+ stdout: "pipe",
389
+ stderr: "pipe",
390
+ });
391
+ if ((await emailCheck.exited) !== 0) {
392
+ const setEmail = spawnProcess(["git", "config", "user.email", "clawmatrix@local"], {
393
+ cwd: this.opts.workspacePath,
394
+ stdout: "pipe",
395
+ stderr: "pipe",
396
+ });
397
+ await setEmail.exited;
398
+ }
356
399
  } catch {
357
400
  debug(TAG, "git not available, skipping git integration");
358
401
  }
@@ -386,8 +429,13 @@ export class KnowledgeSync {
386
429
  stderr: "pipe",
387
430
  },
388
431
  );
389
- await commit.exited;
390
- debug(TAG, `git commit: ${message}`);
432
+ const commitCode = await commit.exited;
433
+ if (commitCode !== 0) {
434
+ const stderr = await streamToString(commit.stderr);
435
+ debug(TAG, `git commit failed (exit ${commitCode}): ${stderr}`);
436
+ } else {
437
+ debug(TAG, `git commit: ${message}`);
438
+ }
391
439
  } catch (err) {
392
440
  debug(TAG, `git commit failed: ${err}`);
393
441
  }