clawmatrix 0.5.1 → 0.6.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/cli/bin/clawmatrix.mjs +411 -12
- package/cli/skills/clawmatrix/SKILL.md +49 -0
- package/package.json +1 -1
- package/src/api.ts +476 -3
- package/src/automation.ts +90 -1
- package/src/cluster-service.ts +24 -9
- package/src/config.ts +22 -16
- package/src/connection.ts +10 -0
- package/src/device-info.ts +10 -0
- package/src/health-tracker.ts +25 -5
- package/src/index.ts +285 -0
- package/src/knowledge-sync.ts +7 -0
- package/src/peer-manager.ts +41 -22
- package/src/router.ts +21 -3
- package/src/types.ts +1 -0
package/src/cluster-service.ts
CHANGED
|
@@ -237,6 +237,10 @@ export class ClusterRuntime {
|
|
|
237
237
|
});
|
|
238
238
|
this.knowledgeSync.start().then(() => {
|
|
239
239
|
this.logger.info(`[clawmatrix] Knowledge sync started: ${workspacePath}`);
|
|
240
|
+
// Sync with any peers that connected before start() completed
|
|
241
|
+
for (const peerId of this.peerManager.router.getDirectPeerIds()) {
|
|
242
|
+
this.knowledgeSync?.initPeerSync(peerId);
|
|
243
|
+
}
|
|
240
244
|
}).catch((err) => {
|
|
241
245
|
this.logger.error(`[clawmatrix] Knowledge sync failed to start: ${err}`);
|
|
242
246
|
});
|
|
@@ -643,15 +647,26 @@ export class ClusterRuntime {
|
|
|
643
647
|
case "availability_req": {
|
|
644
648
|
const af = frame as AvailabilityRequest;
|
|
645
649
|
const range = af.payload.range ?? "24h";
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
650
|
+
try {
|
|
651
|
+
const data = this.healthTracker.getAvailability(range);
|
|
652
|
+
this.peerManager.sendTo(af.from, {
|
|
653
|
+
type: "availability_res",
|
|
654
|
+
id: af.id,
|
|
655
|
+
from: this.config.nodeId,
|
|
656
|
+
to: af.from,
|
|
657
|
+
timestamp: Date.now(),
|
|
658
|
+
payload: { success: true, data },
|
|
659
|
+
} as AvailabilityResponse);
|
|
660
|
+
} catch (err) {
|
|
661
|
+
this.peerManager.sendTo(af.from, {
|
|
662
|
+
type: "availability_res",
|
|
663
|
+
id: af.id,
|
|
664
|
+
from: this.config.nodeId,
|
|
665
|
+
to: af.from,
|
|
666
|
+
timestamp: Date.now(),
|
|
667
|
+
payload: { success: false, error: String((err as Error)?.message ?? err) },
|
|
668
|
+
} as AvailabilityResponse);
|
|
669
|
+
}
|
|
655
670
|
break;
|
|
656
671
|
}
|
|
657
672
|
case "acp_req":
|
package/src/config.ts
CHANGED
|
@@ -133,23 +133,29 @@ const TerminalConfigSchema = z.object({
|
|
|
133
133
|
allowFrom: z.array(z.string()).default([]),
|
|
134
134
|
}).optional();
|
|
135
135
|
|
|
136
|
-
const FileTransferConfigSchema = z.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
136
|
+
const FileTransferConfigSchema = z.preprocess(
|
|
137
|
+
(v) => v ?? {},
|
|
138
|
+
z.object({
|
|
139
|
+
enabled: z.boolean().default(true),
|
|
140
|
+
chunkSize: z.number().default(262_144), // 256KB
|
|
141
|
+
maxFileSize: z.number().default(104_857_600), // 100MB
|
|
142
|
+
timeout: z.number().default(300_000), // 5min per-chunk
|
|
143
|
+
allowedPaths: z.array(z.string()).default([]), // empty = no restriction
|
|
144
|
+
}),
|
|
145
|
+
).default({});
|
|
143
146
|
|
|
144
|
-
const KanbanConfigSchema = z.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
147
|
+
const KanbanConfigSchema = z.preprocess(
|
|
148
|
+
(v) => v ?? {},
|
|
149
|
+
z.object({
|
|
150
|
+
enabled: z.boolean().default(true),
|
|
151
|
+
/** Card ID prefix (e.g. "CM" → CM-1, CM-2). */
|
|
152
|
+
prefix: z.string().default("CM"),
|
|
153
|
+
/** Allow agents to auto-claim matching cards. */
|
|
154
|
+
autoAssign: z.boolean().default(true),
|
|
155
|
+
/** Archive cards older than this (ms). Default: 30 days. */
|
|
156
|
+
archiveAfterMs: z.number().default(30 * 24 * 60 * 60 * 1000),
|
|
157
|
+
}),
|
|
158
|
+
).default({});
|
|
153
159
|
|
|
154
160
|
const AcpConfigSchema = z.object({
|
|
155
161
|
enabled: z.boolean().default(false),
|
package/src/connection.ts
CHANGED
|
@@ -269,6 +269,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
269
269
|
private async onRawMessage(data: unknown) {
|
|
270
270
|
this.lastReceivedAt = Date.now();
|
|
271
271
|
|
|
272
|
+
// Debug: log data type for unauthenticated connections to diagnose auth issues
|
|
273
|
+
if (!this.authenticated && this.role === "outbound") {
|
|
274
|
+
const dtype = Buffer.isBuffer(data) ? `Buffer(${(data as Buffer).length})` :
|
|
275
|
+
data instanceof ArrayBuffer ? `ArrayBuffer(${data.byteLength})` :
|
|
276
|
+
typeof data === "string" ? `string(${data.length})` :
|
|
277
|
+
`${data?.constructor?.name ?? typeof data}`;
|
|
278
|
+
debug("auth", `onRawMessage[outbound] dataType=${dtype}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
272
281
|
// Normalize non-Buffer binary types to Buffer.
|
|
273
282
|
// Node.js 24+'s built-in WebSocket (undici) delivers binary frames as Blob
|
|
274
283
|
// (binaryType "blob") or ArrayBuffer (binaryType "arraybuffer").
|
|
@@ -570,6 +579,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
570
579
|
}
|
|
571
580
|
|
|
572
581
|
// auth_ok (decrypted from binary envelope, or plaintext legacy)
|
|
582
|
+
debug("auth", `Outbound received frame type=${frame.type} (authenticated=${this.authenticated})`);
|
|
573
583
|
if (frame.type === "auth_ok") {
|
|
574
584
|
const ok = frame as AuthOk;
|
|
575
585
|
this.remoteNodeId = ok.payload.nodeId;
|
package/src/device-info.ts
CHANGED
|
@@ -50,6 +50,15 @@ function resolveWorkspace(openclawConfig?: Record<string, unknown>): string | un
|
|
|
50
50
|
return undefined;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function resolveClawmatrixVersion(): string | undefined {
|
|
54
|
+
try {
|
|
55
|
+
const raw = readFileSync(join(dirname(import.meta.url.replace("file://", "")), "..", "package.json"), "utf-8");
|
|
56
|
+
const pkg = JSON.parse(raw) as { version?: string };
|
|
57
|
+
return pkg.version;
|
|
58
|
+
} catch { /* best effort */ }
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
53
62
|
/** Collect device info once at startup. */
|
|
54
63
|
export function collectDeviceInfo(openclawVersion?: string, openclawConfig?: Record<string, unknown>): DeviceInfo {
|
|
55
64
|
const cpuList = cpus();
|
|
@@ -61,6 +70,7 @@ export function collectDeviceInfo(openclawVersion?: string, openclawConfig?: Rec
|
|
|
61
70
|
totalMemoryMB: Math.round(totalmem() / (1024 * 1024)),
|
|
62
71
|
hostname: hostname(),
|
|
63
72
|
openclawVersion: resolveOpenclawVersion(openclawVersion),
|
|
73
|
+
clawmatrixVersion: resolveClawmatrixVersion(),
|
|
64
74
|
cwd: process.cwd(),
|
|
65
75
|
workspace: resolveWorkspace(openclawConfig),
|
|
66
76
|
};
|
package/src/health-tracker.ts
CHANGED
|
@@ -155,7 +155,10 @@ export class HealthTracker {
|
|
|
155
155
|
if (ev.peer) everConnected.add(ev.peer);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
const knownNodes = this.getKnownNodes();
|
|
158
|
+
const knownNodes = new Set(this.getKnownNodes());
|
|
159
|
+
// Also include nodes we've observed via peer_online events
|
|
160
|
+
for (const peerId of everConnected) knownNodes.add(peerId);
|
|
161
|
+
|
|
159
162
|
for (const nodeId of knownNodes) {
|
|
160
163
|
if (nodeId !== this.nodeId && !everConnected.has(nodeId)) continue;
|
|
161
164
|
|
|
@@ -181,12 +184,29 @@ export class HealthTracker {
|
|
|
181
184
|
observerGaps: Array<[number, number]>,
|
|
182
185
|
): NodeTimeline | null {
|
|
183
186
|
const sorted = [...events].sort((a, b) => a.ts - b.ts);
|
|
184
|
-
if (sorted.length === 0) return null;
|
|
185
187
|
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
+
// For remote nodes, also consider peer observation intervals
|
|
189
|
+
const peerIntervals = nodeId !== this.nodeId
|
|
190
|
+
? this.buildPeerIntervals(nodeId, startTs, endTs)
|
|
191
|
+
: [];
|
|
192
|
+
|
|
193
|
+
if (sorted.length === 0 && peerIntervals.length === 0) return null;
|
|
194
|
+
|
|
195
|
+
// Determine firstSeen/lastSeen from both self-reported events and peer observations
|
|
196
|
+
let firstSeen = Infinity;
|
|
197
|
+
let lastSeen = -Infinity;
|
|
198
|
+
if (sorted.length > 0) {
|
|
199
|
+
firstSeen = sorted[0]!.ts;
|
|
200
|
+
lastSeen = sorted[sorted.length - 1]!.ts;
|
|
201
|
+
}
|
|
202
|
+
for (const [s, e] of peerIntervals) {
|
|
203
|
+
if (s < firstSeen) firstSeen = s;
|
|
204
|
+
if (e > lastSeen) lastSeen = e;
|
|
205
|
+
}
|
|
188
206
|
|
|
189
|
-
const intervals = this.
|
|
207
|
+
const intervals = nodeId !== this.nodeId
|
|
208
|
+
? this.mergeIntervals([...this.buildSelfIntervals(sorted, startTs, endTs), ...peerIntervals])
|
|
209
|
+
: this.buildOnlineIntervals(nodeId, sorted, startTs, endTs);
|
|
190
210
|
|
|
191
211
|
const buckets: BucketState[] = [];
|
|
192
212
|
let totalOnline = 0;
|
package/src/index.ts
CHANGED
|
@@ -1537,6 +1537,291 @@ const plugin = {
|
|
|
1537
1537
|
},
|
|
1538
1538
|
);
|
|
1539
1539
|
|
|
1540
|
+
// ── Availability gateway method ──────────────────────────────────
|
|
1541
|
+
|
|
1542
|
+
api.registerGatewayMethod(
|
|
1543
|
+
"clawmatrix.availability",
|
|
1544
|
+
({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
1545
|
+
try {
|
|
1546
|
+
const runtime = getClusterRuntime();
|
|
1547
|
+
const { range } = (params ?? {}) as { range?: string };
|
|
1548
|
+
const validRanges = ["24h", "7d", "90d"];
|
|
1549
|
+
const r = validRanges.includes(range ?? "") ? (range as "24h" | "7d" | "90d") : "24h";
|
|
1550
|
+
const result = runtime.healthTracker.getAvailability(r);
|
|
1551
|
+
respond(true, result);
|
|
1552
|
+
} catch {
|
|
1553
|
+
respond(false, { error: "ClawMatrix service not running" });
|
|
1554
|
+
}
|
|
1555
|
+
},
|
|
1556
|
+
);
|
|
1557
|
+
|
|
1558
|
+
// ── Automation gateway methods ──────────────────────────────────
|
|
1559
|
+
|
|
1560
|
+
api.registerGatewayMethod(
|
|
1561
|
+
"clawmatrix.automations.rules",
|
|
1562
|
+
({ respond }: GatewayRequestHandlerOptions) => {
|
|
1563
|
+
try {
|
|
1564
|
+
const runtime = getClusterRuntime();
|
|
1565
|
+
if (!runtime.automationManager) {
|
|
1566
|
+
respond(true, { rules: [] });
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
respond(true, { rules: runtime.automationManager.getRules() });
|
|
1570
|
+
} catch {
|
|
1571
|
+
respond(false, { error: "ClawMatrix service not running" });
|
|
1572
|
+
}
|
|
1573
|
+
},
|
|
1574
|
+
);
|
|
1575
|
+
|
|
1576
|
+
api.registerGatewayMethod(
|
|
1577
|
+
"clawmatrix.automations.save",
|
|
1578
|
+
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
1579
|
+
try {
|
|
1580
|
+
const runtime = getClusterRuntime();
|
|
1581
|
+
if (!runtime.automationManager) {
|
|
1582
|
+
respond(false, { error: "Automations not available" });
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
const { rules } = (params ?? {}) as { rules?: unknown[] };
|
|
1586
|
+
if (!Array.isArray(rules)) {
|
|
1587
|
+
respond(false, { error: "Missing required param: rules (array)" });
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
await runtime.automationManager.saveRules(rules as Parameters<typeof runtime.automationManager.saveRules>[0]);
|
|
1591
|
+
respond(true, { ok: true, count: rules.length });
|
|
1592
|
+
} catch (err) {
|
|
1593
|
+
respond(false, { error: String(err) });
|
|
1594
|
+
}
|
|
1595
|
+
},
|
|
1596
|
+
);
|
|
1597
|
+
|
|
1598
|
+
api.registerGatewayMethod(
|
|
1599
|
+
"clawmatrix.automations.history",
|
|
1600
|
+
({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
1601
|
+
try {
|
|
1602
|
+
const runtime = getClusterRuntime();
|
|
1603
|
+
if (!runtime.automationManager) {
|
|
1604
|
+
respond(true, { executions: [] });
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
const { limit } = (params ?? {}) as { limit?: number };
|
|
1608
|
+
respond(true, { executions: runtime.automationManager.getExecutions(limit ?? 50) });
|
|
1609
|
+
} catch {
|
|
1610
|
+
respond(false, { error: "ClawMatrix service not running" });
|
|
1611
|
+
}
|
|
1612
|
+
},
|
|
1613
|
+
);
|
|
1614
|
+
|
|
1615
|
+
api.registerGatewayMethod(
|
|
1616
|
+
"clawmatrix.automations.run",
|
|
1617
|
+
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
1618
|
+
try {
|
|
1619
|
+
const runtime = getClusterRuntime();
|
|
1620
|
+
if (!runtime.automationManager) {
|
|
1621
|
+
respond(false, { error: "Automations not available" });
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
const { ruleId, event } = (params ?? {}) as { ruleId?: string; event?: Record<string, unknown> };
|
|
1625
|
+
if (!ruleId) {
|
|
1626
|
+
respond(false, { error: "Missing required param: ruleId" });
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
const evt = event ? {
|
|
1630
|
+
id: nanoid(),
|
|
1631
|
+
source: String(event.source || "cli"),
|
|
1632
|
+
type: String(event.type || "manual"),
|
|
1633
|
+
data: (event.data ?? {}) as Record<string, unknown>,
|
|
1634
|
+
ts: typeof event.ts === "number" ? event.ts : Date.now(),
|
|
1635
|
+
consumed: false,
|
|
1636
|
+
} as import("./types.ts").IngestedEvent : undefined;
|
|
1637
|
+
const execution = await runtime.automationManager.runRuleById(ruleId, evt);
|
|
1638
|
+
respond(true, { ok: true, execution });
|
|
1639
|
+
} catch (err) {
|
|
1640
|
+
respond(false, { error: String(err) });
|
|
1641
|
+
}
|
|
1642
|
+
},
|
|
1643
|
+
);
|
|
1644
|
+
|
|
1645
|
+
api.registerGatewayMethod(
|
|
1646
|
+
"clawmatrix.automations.replay",
|
|
1647
|
+
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
1648
|
+
try {
|
|
1649
|
+
const runtime = getClusterRuntime();
|
|
1650
|
+
if (!runtime.automationManager) {
|
|
1651
|
+
respond(false, { error: "Automations not available" });
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
const { executionId } = (params ?? {}) as { executionId?: string };
|
|
1655
|
+
if (!executionId) {
|
|
1656
|
+
respond(false, { error: "Missing required param: executionId" });
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
const execution = await runtime.automationManager.replayExecution(executionId);
|
|
1660
|
+
respond(true, { ok: true, execution });
|
|
1661
|
+
} catch (err) {
|
|
1662
|
+
respond(false, { error: String(err) });
|
|
1663
|
+
}
|
|
1664
|
+
},
|
|
1665
|
+
);
|
|
1666
|
+
|
|
1667
|
+
// ── Events ingest gateway method ──────────────────────────────────
|
|
1668
|
+
|
|
1669
|
+
api.registerGatewayMethod(
|
|
1670
|
+
"clawmatrix.events.ingest",
|
|
1671
|
+
({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
1672
|
+
try {
|
|
1673
|
+
const runtime = getClusterRuntime();
|
|
1674
|
+
if (!runtime.apiHandler) {
|
|
1675
|
+
respond(false, { error: "API handler not available (listen mode required)" });
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
const { events } = (params ?? {}) as { events?: unknown[] };
|
|
1679
|
+
if (!Array.isArray(events) || events.length === 0) {
|
|
1680
|
+
respond(false, { error: "Missing required param: events (non-empty array)" });
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
const result = runtime.apiHandler.ingestEvents(events as Array<Record<string, unknown>>);
|
|
1684
|
+
respond(true, { ok: true, ...result });
|
|
1685
|
+
} catch {
|
|
1686
|
+
respond(false, { error: "ClawMatrix service not running" });
|
|
1687
|
+
}
|
|
1688
|
+
},
|
|
1689
|
+
);
|
|
1690
|
+
|
|
1691
|
+
// ── Board update gateway method ──────────────────────────────────
|
|
1692
|
+
|
|
1693
|
+
api.registerGatewayMethod(
|
|
1694
|
+
"clawmatrix.board.update",
|
|
1695
|
+
({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
1696
|
+
try {
|
|
1697
|
+
const runtime = getClusterRuntime();
|
|
1698
|
+
if (!runtime.kanbanManager) {
|
|
1699
|
+
respond(false, { error: "Kanban not enabled" });
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
const { cardId, ...updates } = (params ?? {}) as {
|
|
1703
|
+
cardId?: string; title?: string; description?: string;
|
|
1704
|
+
priority?: string; targetNode?: string; targetAgent?: string;
|
|
1705
|
+
cwd?: string; labels?: string[];
|
|
1706
|
+
};
|
|
1707
|
+
if (!cardId) {
|
|
1708
|
+
respond(false, { error: "Missing required param: cardId" });
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
const card = runtime.kanbanManager.updateCard(cardId, updates as Parameters<typeof runtime.kanbanManager.updateCard>[1]);
|
|
1712
|
+
if (!card) {
|
|
1713
|
+
respond(false, { error: `Card not found: ${cardId}` });
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
respond(true, card);
|
|
1717
|
+
} catch {
|
|
1718
|
+
respond(false, { error: "ClawMatrix service not running" });
|
|
1719
|
+
}
|
|
1720
|
+
},
|
|
1721
|
+
);
|
|
1722
|
+
|
|
1723
|
+
// ── Config gateway method ──────────────────────────────────────────
|
|
1724
|
+
|
|
1725
|
+
api.registerGatewayMethod(
|
|
1726
|
+
"clawmatrix.config.get",
|
|
1727
|
+
({ respond }: GatewayRequestHandlerOptions) => {
|
|
1728
|
+
try {
|
|
1729
|
+
const runtime = getClusterRuntime();
|
|
1730
|
+
const c = runtime.config;
|
|
1731
|
+
respond(true, {
|
|
1732
|
+
nodeId: c.nodeId,
|
|
1733
|
+
listen: c.listen,
|
|
1734
|
+
tags: c.tags,
|
|
1735
|
+
agents: c.agents.map((a) => ({ id: a.id, model: a.model })),
|
|
1736
|
+
models: c.models.map((m) => ({ id: m.id, provider: m.provider })),
|
|
1737
|
+
e2ee: c.e2ee,
|
|
1738
|
+
toolProxy: c.toolProxy ? { enabled: c.toolProxy.enabled, allow: c.toolProxy.allow, deny: c.toolProxy.deny } : undefined,
|
|
1739
|
+
terminal: c.terminal,
|
|
1740
|
+
acp: c.acp ? { enabled: c.acp.enabled } : undefined,
|
|
1741
|
+
knowledge: c.knowledge ? { enabled: c.knowledge.enabled } : undefined,
|
|
1742
|
+
proxyModels: c.proxyModels?.length ?? 0,
|
|
1743
|
+
peers: c.peers?.length ?? 0,
|
|
1744
|
+
});
|
|
1745
|
+
} catch {
|
|
1746
|
+
respond(false, { error: "ClawMatrix service not running" });
|
|
1747
|
+
}
|
|
1748
|
+
},
|
|
1749
|
+
);
|
|
1750
|
+
|
|
1751
|
+
// ── Config file read/write gateway methods ──────────────────────
|
|
1752
|
+
|
|
1753
|
+
api.registerGatewayMethod(
|
|
1754
|
+
"clawmatrix.config.read",
|
|
1755
|
+
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
1756
|
+
try {
|
|
1757
|
+
const runtime = getClusterRuntime();
|
|
1758
|
+
if (!runtime.apiHandler) {
|
|
1759
|
+
respond(false, { error: "API handler not available" });
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
const { path: configPath } = (params ?? {}) as { path?: string };
|
|
1763
|
+
if (!configPath) {
|
|
1764
|
+
respond(false, { error: "Missing required param: path" });
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
const result = await runtime.apiHandler.readConfigFile(configPath);
|
|
1768
|
+
respond(result.success, result);
|
|
1769
|
+
} catch (err) {
|
|
1770
|
+
respond(false, { error: String(err) });
|
|
1771
|
+
}
|
|
1772
|
+
},
|
|
1773
|
+
);
|
|
1774
|
+
|
|
1775
|
+
api.registerGatewayMethod(
|
|
1776
|
+
"clawmatrix.config.write",
|
|
1777
|
+
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
1778
|
+
try {
|
|
1779
|
+
const runtime = getClusterRuntime();
|
|
1780
|
+
if (!runtime.apiHandler) {
|
|
1781
|
+
respond(false, { error: "API handler not available" });
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
const { path: configPath, content } = (params ?? {}) as { path?: string; content?: string };
|
|
1785
|
+
if (!configPath || typeof content !== "string") {
|
|
1786
|
+
respond(false, { error: "Missing required params: path, content" });
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
const result = await runtime.apiHandler.writeConfigFile(configPath, content);
|
|
1790
|
+
respond(result.success, result);
|
|
1791
|
+
} catch (err) {
|
|
1792
|
+
respond(false, { error: String(err) });
|
|
1793
|
+
}
|
|
1794
|
+
},
|
|
1795
|
+
);
|
|
1796
|
+
|
|
1797
|
+
// ── Knowledge content gateway method ──────────────────────────────
|
|
1798
|
+
|
|
1799
|
+
api.registerGatewayMethod(
|
|
1800
|
+
"clawmatrix.kb.content",
|
|
1801
|
+
({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
1802
|
+
try {
|
|
1803
|
+
const runtime = getClusterRuntime();
|
|
1804
|
+
if (!runtime.knowledgeSync) {
|
|
1805
|
+
respond(false, { error: "Knowledge sync not enabled" });
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
const { path } = (params ?? {}) as { path?: string };
|
|
1809
|
+
if (!path) {
|
|
1810
|
+
respond(false, { error: "Missing required param: path" });
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
const content = runtime.knowledgeSync.getFileContent(path);
|
|
1814
|
+
if (content === null) {
|
|
1815
|
+
respond(false, { error: "File not found or content not yet synced" });
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
respond(true, { path, content });
|
|
1819
|
+
} catch {
|
|
1820
|
+
respond(false, { error: "ClawMatrix service not running" });
|
|
1821
|
+
}
|
|
1822
|
+
},
|
|
1823
|
+
);
|
|
1824
|
+
|
|
1540
1825
|
// Log model selection on each LLM call (fire-and-forget)
|
|
1541
1826
|
api.on("llm_input", (event) => {
|
|
1542
1827
|
api.logger.debug(`[clawmatrix] llm_input: provider=${event.provider} model=${event.model}`);
|
package/src/knowledge-sync.ts
CHANGED
|
@@ -547,6 +547,13 @@ export class KnowledgeSync {
|
|
|
547
547
|
})).filter(f => !f.deleted);
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
+
/** Get the CRDT content of a synced file, or null if not found. */
|
|
551
|
+
getFileContent(relPath: string): string | null {
|
|
552
|
+
const doc = this.fileDocs.get(relPath);
|
|
553
|
+
if (!doc || doc.content === undefined) return null;
|
|
554
|
+
return doc.content;
|
|
555
|
+
}
|
|
556
|
+
|
|
550
557
|
/**
|
|
551
558
|
* Set pending attribution for a file path.
|
|
552
559
|
* Called by tool hooks (after_tool_call, ACP stream) before fsWatcher picks up the change.
|
package/src/peer-manager.ts
CHANGED
|
@@ -30,28 +30,32 @@ import type { KeyPair } from "./crypto.ts";
|
|
|
30
30
|
const RECONNECT_MAX = 60_000;
|
|
31
31
|
|
|
32
32
|
/** Frame types that bypass dedup (streams share one id across chunks; responses share id with request). */
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Frame types that bypass dedup. Response frames share the same `id` as their
|
|
35
|
+
* request, so the relay node would otherwise treat the response as a duplicate.
|
|
36
|
+
* Streaming chunks reuse a single `id` across many frames.
|
|
37
|
+
*
|
|
38
|
+
* Rather than maintaining a fragile manual list, we match by convention:
|
|
39
|
+
* - Any type ending in `_res` is a response frame
|
|
40
|
+
* - Explicit set covers streams, control frames, and file-transfer chunks
|
|
41
|
+
*/
|
|
42
|
+
const SKIP_DEDUP_EXPLICIT = new Set([
|
|
34
43
|
// Streaming
|
|
35
44
|
"model_stream", "handoff_stream", "acp_stream",
|
|
36
|
-
// Response frames (share id with their request)
|
|
37
|
-
"model_res", "tool_res", "tool_batch_res",
|
|
38
|
-
"handoff_res", "handoff_status_res", "handoff_input_required",
|
|
39
45
|
// Handoff control (reuse original handoff_req id)
|
|
40
46
|
"handoff_input", "handoff_cancel", "handoff_status",
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"acp_res", "acp_close_res", "acp_list_res", "acp_resume_res",
|
|
45
|
-
"acp_cancel_res", "acp_set_mode_res", "acp_get_modes_res",
|
|
46
|
-
"chat_history_res",
|
|
47
|
-
// Terminal
|
|
48
|
-
"terminal_open_res", "terminal_data", "terminal_resize",
|
|
49
|
-
"terminal_close", "terminal_close_res",
|
|
47
|
+
"handoff_input_required",
|
|
48
|
+
// Terminal data/control
|
|
49
|
+
"terminal_data", "terminal_resize", "terminal_close",
|
|
50
50
|
// File transfer
|
|
51
51
|
"file_transfer_chunk", "file_transfer_chunk_ack",
|
|
52
52
|
"file_transfer_ack", "file_transfer_complete",
|
|
53
53
|
]);
|
|
54
54
|
|
|
55
|
+
function skipDedup(type: string): boolean {
|
|
56
|
+
return type.endsWith("_res") || SKIP_DEDUP_EXPLICIT.has(type);
|
|
57
|
+
}
|
|
58
|
+
|
|
55
59
|
// Reconnect backoff params are now provided by retry.ts → getReconnectBackoff()
|
|
56
60
|
|
|
57
61
|
/** Classify WebSocket close code into a human-readable reason. */
|
|
@@ -111,6 +115,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
111
115
|
private probeTimer: ReturnType<typeof setInterval> | null = null;
|
|
112
116
|
/** EMA latency baseline per peer (for spike detection). */
|
|
113
117
|
private latencyBaselines = new Map<string, number>();
|
|
118
|
+
/** Throttle timer for latency-triggered peer_sync broadcasts. */
|
|
119
|
+
private latencySyncTimer: ReturnType<typeof setTimeout> | null = null;
|
|
114
120
|
/** Last time a latency-triggered probe was fired per peer (debounce). */
|
|
115
121
|
private lastProbeTime = new Map<string, number>();
|
|
116
122
|
/** Deferred disconnect timers — grace period before broadcasting peer_leave. */
|
|
@@ -225,6 +231,10 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
225
231
|
clearTimeout(this.gossipDebounceTimer);
|
|
226
232
|
this.gossipDebounceTimer = null;
|
|
227
233
|
}
|
|
234
|
+
if (this.latencySyncTimer) {
|
|
235
|
+
clearTimeout(this.latencySyncTimer);
|
|
236
|
+
this.latencySyncTimer = null;
|
|
237
|
+
}
|
|
228
238
|
for (const timer of this.reconnectTimers.values()) {
|
|
229
239
|
clearTimeout(timer);
|
|
230
240
|
}
|
|
@@ -643,14 +653,10 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
643
653
|
const attempt = this.reconnectAttempts.get(channelKey) ?? 0;
|
|
644
654
|
debug("peer", `connectToChannel(${nodeId}): attempt=${attempt} url=${url}`);
|
|
645
655
|
|
|
646
|
-
// Use
|
|
647
|
-
|
|
648
|
-
// causing binary frames to arrive as Blob instead of Buffer — which
|
|
649
|
-
// onRawMessage cannot handle, silently dropping encrypted frames (including auth_ok).
|
|
650
|
-
// The `ws` package defaults to binaryType "nodebuffer", avoiding this issue.
|
|
651
|
-
let ws: InstanceType<typeof WsWebSocket>;
|
|
656
|
+
// Use a common WS subprotocol for traffic disguise
|
|
657
|
+
let ws: WebSocket;
|
|
652
658
|
try {
|
|
653
|
-
ws = new
|
|
659
|
+
ws = new WebSocket(url, ["graphql-transport-ws"]);
|
|
654
660
|
} catch (err) {
|
|
655
661
|
debug("peer", `connectToChannel(${nodeId}): WebSocket constructor threw: ${err}`);
|
|
656
662
|
this.scheduleChannelReconnect(nodeId, url);
|
|
@@ -977,7 +983,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
977
983
|
|
|
978
984
|
conn.on("message", (frame) => this.onFrame(frame, conn));
|
|
979
985
|
conn.on("latency", (latencyMs) => {
|
|
980
|
-
this.router.updateActiveChannel(nodeId);
|
|
986
|
+
const changed = this.router.updateActiveChannel(nodeId);
|
|
987
|
+
if (changed) this.scheduleLatencySync();
|
|
981
988
|
// Update baseline and check for spike
|
|
982
989
|
const baseline = this.latencyBaselines.get(nodeId) ?? latencyMs;
|
|
983
990
|
const newBaseline = baseline * 0.8 + latencyMs * 0.2;
|
|
@@ -1150,7 +1157,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
1150
1157
|
// Skip dedup for streaming chunks (same id across many chunks) and response
|
|
1151
1158
|
// frames (share id with their request — relay would otherwise drop the reply).
|
|
1152
1159
|
// handoff_input/cancel/status reuse the original handoff_req id.
|
|
1153
|
-
if (frame.id && !
|
|
1160
|
+
if (frame.id && !skipDedup(frame.type) && this.router.isDuplicate(frame.id)) return;
|
|
1154
1161
|
|
|
1155
1162
|
// Handle peer approval responses locally (don't emit to cluster-service)
|
|
1156
1163
|
if (frame.type === "peer_approval_res") {
|
|
@@ -1244,6 +1251,18 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
1244
1251
|
/** Last sync version sent to each direct peer. */
|
|
1245
1252
|
private peerSyncVersions = new Map<string, number>();
|
|
1246
1253
|
|
|
1254
|
+
/** Throttled broadcast of peer_sync to all connections when latency changes.
|
|
1255
|
+
* Coalesces rapid updates into a single sync (max once per 5s). */
|
|
1256
|
+
private scheduleLatencySync() {
|
|
1257
|
+
if (this.latencySyncTimer) return; // already scheduled
|
|
1258
|
+
this.latencySyncTimer = setTimeout(() => {
|
|
1259
|
+
this.latencySyncTimer = null;
|
|
1260
|
+
for (const conn of this.router.getDirectConnections()) {
|
|
1261
|
+
this.sendPeerSync(conn);
|
|
1262
|
+
}
|
|
1263
|
+
}, 5_000);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1247
1266
|
private sendPeerSync(conn: Connection) {
|
|
1248
1267
|
const remoteNodeId = conn.remoteNodeId ?? "";
|
|
1249
1268
|
const sinceVersion = this.peerSyncVersions.get(remoteNodeId) ?? 0;
|
package/src/router.ts
CHANGED
|
@@ -192,10 +192,11 @@ export class Router {
|
|
|
192
192
|
return false;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
/** Re-evaluate the active (best) channel for a peer based on latency.
|
|
196
|
-
|
|
195
|
+
/** Re-evaluate the active (best) channel for a peer based on latency.
|
|
196
|
+
* Returns true if latency changed significantly (sync version bumped). */
|
|
197
|
+
updateActiveChannel(nodeId: string): boolean {
|
|
197
198
|
const channelSet = this.channels.get(nodeId);
|
|
198
|
-
if (!channelSet || channelSet.size === 0) return;
|
|
199
|
+
if (!channelSet || channelSet.size === 0) return false;
|
|
199
200
|
|
|
200
201
|
let best: Connection | null = null;
|
|
201
202
|
let bestLatency = Infinity;
|
|
@@ -213,10 +214,22 @@ export class Router {
|
|
|
213
214
|
this.connections.set(nodeId, best);
|
|
214
215
|
const route = this.routes.get(nodeId);
|
|
215
216
|
if (route) {
|
|
217
|
+
const prevLatency = route.latencyMs;
|
|
216
218
|
route.connection = best;
|
|
217
219
|
route.latencyMs = best.latencyMs;
|
|
220
|
+
// Bump sync version when latency changes significantly (>10% or crosses 0)
|
|
221
|
+
// so delta peer_sync propagates the update to satellite clients.
|
|
222
|
+
if (prevLatency !== route.latencyMs && (
|
|
223
|
+
prevLatency === 0 || route.latencyMs === 0 ||
|
|
224
|
+
Math.abs(route.latencyMs - prevLatency) / Math.max(prevLatency, 1) > 0.1
|
|
225
|
+
)) {
|
|
226
|
+
this.syncVersion++;
|
|
227
|
+
this.peerVersions.set(nodeId, this.syncVersion);
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
218
230
|
}
|
|
219
231
|
}
|
|
232
|
+
return false;
|
|
220
233
|
}
|
|
221
234
|
|
|
222
235
|
/** Get the number of live channels for a peer. */
|
|
@@ -537,6 +550,11 @@ export class Router {
|
|
|
537
550
|
return [...this.connections.values()];
|
|
538
551
|
}
|
|
539
552
|
|
|
553
|
+
/** Get nodeIds of all directly connected peers. */
|
|
554
|
+
getDirectPeerIds(): string[] {
|
|
555
|
+
return [...this.connections.keys()];
|
|
556
|
+
}
|
|
557
|
+
|
|
540
558
|
/** Current sync version for delta protocol. */
|
|
541
559
|
get currentSyncVersion(): number {
|
|
542
560
|
return this.syncVersion;
|
package/src/types.ts
CHANGED
|
@@ -388,6 +388,7 @@ export interface DeviceInfo {
|
|
|
388
388
|
totalMemoryMB: number; // total system memory in MB
|
|
389
389
|
hostname: string; // machine hostname
|
|
390
390
|
openclawVersion?: string; // e.g. "2026.3.7"
|
|
391
|
+
clawmatrixVersion?: string; // e.g. "0.5.0"
|
|
391
392
|
cwd?: string; // process.cwd() at gateway startup
|
|
392
393
|
workspace?: string; // OpenClaw workspace dir (agents.defaults.workspace)
|
|
393
394
|
}
|