clawmatrix 0.5.0 → 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 +20 -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 +38 -15
- package/src/router.ts +21 -3
- package/src/sentinel.ts +1 -1
- 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,25 @@ 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
|
+
|
|
281
|
+
// Normalize non-Buffer binary types to Buffer.
|
|
282
|
+
// Node.js 24+'s built-in WebSocket (undici) delivers binary frames as Blob
|
|
283
|
+
// (binaryType "blob") or ArrayBuffer (binaryType "arraybuffer").
|
|
284
|
+
// The ws package delivers Buffer (binaryType "nodebuffer").
|
|
285
|
+
if (data instanceof ArrayBuffer) {
|
|
286
|
+
data = Buffer.from(data);
|
|
287
|
+
} else if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
288
|
+
data = Buffer.from(await data.arrayBuffer());
|
|
289
|
+
}
|
|
290
|
+
|
|
272
291
|
let frame: AnyClusterFrame | undefined;
|
|
273
292
|
|
|
274
293
|
// Binary frame (Buffer) — decrypt directly without base64
|
|
@@ -560,6 +579,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
560
579
|
}
|
|
561
580
|
|
|
562
581
|
// auth_ok (decrypted from binary envelope, or plaintext legacy)
|
|
582
|
+
debug("auth", `Outbound received frame type=${frame.type} (authenticated=${this.authenticated})`);
|
|
563
583
|
if (frame.type === "auth_ok") {
|
|
564
584
|
const ok = frame as AuthOk;
|
|
565
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
|
}
|
|
@@ -973,7 +983,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
973
983
|
|
|
974
984
|
conn.on("message", (frame) => this.onFrame(frame, conn));
|
|
975
985
|
conn.on("latency", (latencyMs) => {
|
|
976
|
-
this.router.updateActiveChannel(nodeId);
|
|
986
|
+
const changed = this.router.updateActiveChannel(nodeId);
|
|
987
|
+
if (changed) this.scheduleLatencySync();
|
|
977
988
|
// Update baseline and check for spike
|
|
978
989
|
const baseline = this.latencyBaselines.get(nodeId) ?? latencyMs;
|
|
979
990
|
const newBaseline = baseline * 0.8 + latencyMs * 0.2;
|
|
@@ -1146,7 +1157,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
1146
1157
|
// Skip dedup for streaming chunks (same id across many chunks) and response
|
|
1147
1158
|
// frames (share id with their request — relay would otherwise drop the reply).
|
|
1148
1159
|
// handoff_input/cancel/status reuse the original handoff_req id.
|
|
1149
|
-
if (frame.id && !
|
|
1160
|
+
if (frame.id && !skipDedup(frame.type) && this.router.isDuplicate(frame.id)) return;
|
|
1150
1161
|
|
|
1151
1162
|
// Handle peer approval responses locally (don't emit to cluster-service)
|
|
1152
1163
|
if (frame.type === "peer_approval_res") {
|
|
@@ -1240,6 +1251,18 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
1240
1251
|
/** Last sync version sent to each direct peer. */
|
|
1241
1252
|
private peerSyncVersions = new Map<string, number>();
|
|
1242
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
|
+
|
|
1243
1266
|
private sendPeerSync(conn: Connection) {
|
|
1244
1267
|
const remoteNodeId = conn.remoteNodeId ?? "";
|
|
1245
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/sentinel.ts
CHANGED
|
@@ -177,7 +177,7 @@ function buildCapabilities(): NodeCapabilities {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
function connectToPeer(peer: { nodeId: string; url: string }) {
|
|
180
|
-
const ws = new
|
|
180
|
+
const ws = new WsWebSocket(peer.url, ["graphql-transport-ws"]);
|
|
181
181
|
const e2eeOpts: ConnectionE2eeOptions = {
|
|
182
182
|
e2ee: config.e2ee,
|
|
183
183
|
compression: config.compression,
|