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/api.ts
CHANGED
|
@@ -10,6 +10,9 @@ import type { KnowledgeSync } from "./knowledge-sync.ts";
|
|
|
10
10
|
import { nanoid } from "nanoid";
|
|
11
11
|
import { timingSafeEqual } from "./auth.ts";
|
|
12
12
|
import { readBody } from "./http-utils.ts";
|
|
13
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { dirname } from "node:path";
|
|
13
16
|
|
|
14
17
|
const COOKIE_NAME = "clawmatrix_token";
|
|
15
18
|
const SESSION_MAX_AGE = 86400 * 7; // 7 days
|
|
@@ -27,6 +30,255 @@ const CLUSTER_TOOLS = [
|
|
|
27
30
|
"cluster_notify", "cluster_query",
|
|
28
31
|
];
|
|
29
32
|
|
|
33
|
+
/** Full tool definitions for /api/tools endpoint. */
|
|
34
|
+
const CLUSTER_TOOL_DEFS: Array<{ name: string; description: string; inputSchema: Record<string, unknown> }> = [
|
|
35
|
+
{
|
|
36
|
+
name: "cluster_peers",
|
|
37
|
+
description: "List all peers in the cluster with their agents, models, tools, tags, and connection status. Call this first to discover what's available before using other cluster tools.",
|
|
38
|
+
inputSchema: { type: "object", properties: {} },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "cluster_exec",
|
|
42
|
+
description: "Execute a shell command on a remote cluster node. Returns {stdout, stderr, exitCode}. Use nodeId or 'tags:<tag>' to specify the target.",
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
node: { type: "string", description: "Target nodeId or \"tags:<tag>\"" },
|
|
47
|
+
command: { type: "string", description: "Shell command to execute" },
|
|
48
|
+
workdir: { type: "string", description: "Working directory (optional)" },
|
|
49
|
+
timeout: { type: "number", description: "Timeout in seconds (default 1800)" },
|
|
50
|
+
},
|
|
51
|
+
required: ["node", "command"],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "cluster_read",
|
|
56
|
+
description: "Read a file from a remote cluster node. Returns {content} with the file text.",
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
node: { type: "string", description: "Target nodeId or \"tags:<tag>\"" },
|
|
61
|
+
path: { type: "string", description: "File path to read" },
|
|
62
|
+
},
|
|
63
|
+
required: ["node", "path"],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "cluster_write",
|
|
68
|
+
description: "Write content to a file on a remote cluster node (creates or overwrites).",
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
node: { type: "string", description: "Target nodeId or \"tags:<tag>\"" },
|
|
73
|
+
path: { type: "string", description: "File path to write" },
|
|
74
|
+
content: { type: "string", description: "Content to write" },
|
|
75
|
+
},
|
|
76
|
+
required: ["node", "path", "content"],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "cluster_edit",
|
|
81
|
+
description: "Edit a file on a remote cluster node by replacing exact text. Use cluster_read first to get exact file content.",
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
node: { type: "string", description: "Target nodeId or \"tags:<tag>\"" },
|
|
86
|
+
path: { type: "string", description: "File path to edit" },
|
|
87
|
+
oldText: { type: "string", description: "Exact text to find and replace" },
|
|
88
|
+
newText: { type: "string", description: "New text to replace with" },
|
|
89
|
+
},
|
|
90
|
+
required: ["node", "path", "oldText", "newText"],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "cluster_handoff",
|
|
95
|
+
description: "Hand off a complex task to another agent in the cluster and wait for the result. Use for multi-step tasks that require the remote agent's full capabilities.",
|
|
96
|
+
inputSchema: {
|
|
97
|
+
type: "object",
|
|
98
|
+
properties: {
|
|
99
|
+
target: { type: "string", description: "Agent ID or \"tags:<tag>\" expression" },
|
|
100
|
+
task: { type: "string", description: "Task description for the remote agent" },
|
|
101
|
+
context: { type: "string", description: "Additional context for the task" },
|
|
102
|
+
},
|
|
103
|
+
required: ["target", "task"],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "cluster_handoff_reply",
|
|
108
|
+
description: "Reply to a remote agent that requested more input during a handoff. Use the handoff_id returned by cluster_handoff.",
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: {
|
|
112
|
+
handoff_id: { type: "string", description: "The handoff ID from cluster_handoff's response" },
|
|
113
|
+
message: { type: "string", description: "Your reply to the remote agent's question" },
|
|
114
|
+
},
|
|
115
|
+
required: ["handoff_id", "message"],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "cluster_send",
|
|
120
|
+
description: "Send a one-way notification to a remote agent. Fire-and-forget: does not wait for a response.",
|
|
121
|
+
inputSchema: {
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: {
|
|
124
|
+
target: { type: "string", description: "Agent ID or \"tags:<tag>\" expression" },
|
|
125
|
+
message: { type: "string", description: "Message content to send" },
|
|
126
|
+
},
|
|
127
|
+
required: ["target", "message"],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "cluster_batch",
|
|
132
|
+
description: "Execute multiple tools on a remote node in a single round-trip. Tools run sequentially (supports dependencies like read→edit).",
|
|
133
|
+
inputSchema: {
|
|
134
|
+
type: "object",
|
|
135
|
+
properties: {
|
|
136
|
+
node: { type: "string", description: "Target nodeId or \"tags:<tag>\"" },
|
|
137
|
+
items: { type: "array", description: "Array of { tool, params } to execute in order", items: { type: "object", properties: { tool: { type: "string" }, params: { type: "object" } }, required: ["tool", "params"] } },
|
|
138
|
+
stopOnError: { type: "boolean", description: "Stop on first error (default true)" },
|
|
139
|
+
},
|
|
140
|
+
required: ["node", "items"],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "cluster_tool",
|
|
145
|
+
description: "Invoke any tool on a remote cluster node by name. Preferred for device-specific tools (screenshot, battery, location, clipboard, etc.).",
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: {
|
|
149
|
+
node: { type: "string", description: "Target nodeId or \"tags:<tag>\"" },
|
|
150
|
+
tool: { type: "string", description: "Remote tool name to invoke" },
|
|
151
|
+
params: { type: "object", description: "Parameters to pass to the remote tool" },
|
|
152
|
+
timeout: { type: "number", description: "Timeout in seconds (default 30)" },
|
|
153
|
+
},
|
|
154
|
+
required: ["node", "tool"],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "cluster_terminal",
|
|
159
|
+
description: "Interactive terminal (PTY) on a remote cluster node. Actions: open, input, read, resize, list, close. Prefer cluster_exec for one-shot commands.",
|
|
160
|
+
inputSchema: {
|
|
161
|
+
type: "object",
|
|
162
|
+
properties: {
|
|
163
|
+
action: { type: "string", enum: ["open", "input", "read", "resize", "list", "close"], description: "Action to perform (default: open)" },
|
|
164
|
+
node: { type: "string", description: "Target node ID (required for open)" },
|
|
165
|
+
sessionId: { type: "string", description: "Session ID (required for input/read/resize/close)" },
|
|
166
|
+
data: { type: "string", description: "Input data to send (for input action)" },
|
|
167
|
+
shell: { type: "string", description: "Shell to use (default: $SHELL)" },
|
|
168
|
+
cols: { type: "number", description: "Terminal columns (default: 80)" },
|
|
169
|
+
rows: { type: "number", description: "Terminal rows (default: 24)" },
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: "cluster_transfer",
|
|
175
|
+
description: "Transfer a file between local and remote nodes. Supports up to 100MB with chunked transfer and SHA-256 integrity check.",
|
|
176
|
+
inputSchema: {
|
|
177
|
+
type: "object",
|
|
178
|
+
properties: {
|
|
179
|
+
source_path: { type: "string", description: "File path on the source node" },
|
|
180
|
+
target_path: { type: "string", description: "File path on the target node" },
|
|
181
|
+
source_node: { type: "string", description: "Source nodeId (omit for local)" },
|
|
182
|
+
target_node: { type: "string", description: "Target nodeId (omit for local)" },
|
|
183
|
+
},
|
|
184
|
+
required: ["source_path", "target_path"],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "cluster_events",
|
|
189
|
+
description: "Query and consume events from external sources (iOS Shortcuts, webhooks, etc.). Events are persistent until consumed.",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
action: { type: "string", enum: ["query", "consume"], description: "\"query\" to list events, \"consume\" to mark as processed" },
|
|
194
|
+
type: { type: "string", description: "Filter by event type" },
|
|
195
|
+
source: { type: "string", description: "Filter by source" },
|
|
196
|
+
unconsumed: { type: "boolean", description: "Only unconsumed events (default true)" },
|
|
197
|
+
limit: { type: "number", description: "Max events to return (default 20)" },
|
|
198
|
+
ids: { type: "array", items: { type: "string" }, description: "Event IDs to consume" },
|
|
199
|
+
},
|
|
200
|
+
required: ["action"],
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: "cluster_diagnostic",
|
|
205
|
+
description: "Diagnose or execute commands on a remote node's sentinel process. Works even when the gateway is down.",
|
|
206
|
+
inputSchema: {
|
|
207
|
+
type: "object",
|
|
208
|
+
properties: {
|
|
209
|
+
node: { type: "string", description: "Target nodeId" },
|
|
210
|
+
action: { type: "string", enum: ["status", "exec"], description: "\"status\" to check health, \"exec\" to run a command" },
|
|
211
|
+
command: { type: "string", description: "Shell command (required for exec)" },
|
|
212
|
+
timeout: { type: "number", description: "Timeout in seconds (default 30)" },
|
|
213
|
+
},
|
|
214
|
+
required: ["node", "action"],
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: "cluster_acp",
|
|
219
|
+
description: "Run a task on a remote coding agent (Claude Code, Codex, Gemini CLI) via ACP protocol. Supports one-shot and persistent sessions.",
|
|
220
|
+
inputSchema: {
|
|
221
|
+
type: "object",
|
|
222
|
+
properties: {
|
|
223
|
+
action: { type: "string", enum: ["prompt", "list", "resume", "cancel", "set_mode", "get_modes", "close"], description: "Action to perform (default: prompt)" },
|
|
224
|
+
node: { type: "string", description: "Target node ID or \"tags:<tag>\"" },
|
|
225
|
+
agent: { type: "string", description: "ACP agent name: \"claude\", \"codex\", \"gemini\"" },
|
|
226
|
+
task: { type: "string", description: "Task or prompt to send" },
|
|
227
|
+
sessionId: { type: "string", description: "Session ID for follow-up or management" },
|
|
228
|
+
mode: { type: "string", enum: ["oneshot", "persistent"], description: "Session mode (default: oneshot)" },
|
|
229
|
+
cwd: { type: "string", description: "Working directory on the remote node" },
|
|
230
|
+
},
|
|
231
|
+
required: ["node"],
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: "cluster_kanban",
|
|
236
|
+
description: "Manage the distributed kanban board for tracking work items across the cluster. Supports create, list, get, claim, move, annotate, update, delete, summary.",
|
|
237
|
+
inputSchema: {
|
|
238
|
+
type: "object",
|
|
239
|
+
properties: {
|
|
240
|
+
action: { type: "string", enum: ["create", "list", "get", "claim", "move", "annotate", "update", "delete", "summary"], description: "Action to perform (default: list)" },
|
|
241
|
+
cardId: { type: "string", description: "Card ID for get/claim/move/annotate/update/delete" },
|
|
242
|
+
title: { type: "string", description: "Card title (for create or update)" },
|
|
243
|
+
description: { type: "string", description: "Card description in Markdown" },
|
|
244
|
+
stage: { type: "string", enum: ["backlog", "claimed", "in_progress", "review", "done", "archived"], description: "Target stage or filter" },
|
|
245
|
+
priority: { type: "string", enum: ["low", "medium", "high", "urgent"], description: "Card priority" },
|
|
246
|
+
labels: { type: "array", items: { type: "string" }, description: "Labels" },
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "cluster_notify",
|
|
252
|
+
description: "Push a notification to mobile devices in the cluster (Dynamic Island / Live Activity on iOS). Track long-running task progress.",
|
|
253
|
+
inputSchema: {
|
|
254
|
+
type: "object",
|
|
255
|
+
properties: {
|
|
256
|
+
action: { type: "string", enum: ["start", "update", "end"], description: "Action to perform (default: start)" },
|
|
257
|
+
taskId: { type: "string", description: "Task ID for update/end actions" },
|
|
258
|
+
title: { type: "string", description: "Activity title" },
|
|
259
|
+
detail: { type: "string", description: "Activity detail text" },
|
|
260
|
+
progress: { type: "number", description: "Progress 0.0 to 1.0" },
|
|
261
|
+
tool: { type: "string", description: "Current tool being executed" },
|
|
262
|
+
success: { type: "boolean", description: "For end: true=completed, false=failed" },
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: "cluster_query",
|
|
268
|
+
description: "Query replicated data from SQLite: audit_log, health_events, handoff_history. Data is replicated across peers for cluster-wide view.",
|
|
269
|
+
inputSchema: {
|
|
270
|
+
type: "object",
|
|
271
|
+
properties: {
|
|
272
|
+
table: { type: "string", enum: ["audit_log", "health_events", "handoff_history"], description: "Which table to query" },
|
|
273
|
+
since: { type: "number", description: "Events after this unix timestamp (ms). Negative for relative." },
|
|
274
|
+
node_id: { type: "string", description: "Filter by node ID" },
|
|
275
|
+
limit: { type: "number", description: "Max rows (default 50)" },
|
|
276
|
+
},
|
|
277
|
+
required: ["table"],
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
];
|
|
281
|
+
|
|
30
282
|
interface SatelliteEvent {
|
|
31
283
|
ts: number;
|
|
32
284
|
type: "peer_online" | "peer_offline" | "handoff_done" | "context_update" | "event_ingested" | "kanban";
|
|
@@ -216,6 +468,11 @@ export class ApiHandler {
|
|
|
216
468
|
return;
|
|
217
469
|
}
|
|
218
470
|
|
|
471
|
+
if (path === "/api/tools" && req.method === "GET") {
|
|
472
|
+
this.handleToolsList(res);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
219
476
|
if (path === "/api/chat" && req.method === "POST") {
|
|
220
477
|
this.handleChat(req, res);
|
|
221
478
|
return;
|
|
@@ -267,6 +524,16 @@ export class ApiHandler {
|
|
|
267
524
|
return;
|
|
268
525
|
}
|
|
269
526
|
|
|
527
|
+
if (path === "/api/config/read" && req.method === "GET") {
|
|
528
|
+
this.handleConfigRead(req, res);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (path === "/api/config/write" && req.method === "POST") {
|
|
533
|
+
this.handleConfigWrite(req, res);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
270
537
|
if (path === "/api/availability" && req.method === "GET") {
|
|
271
538
|
this.handleAvailability(req, res);
|
|
272
539
|
return;
|
|
@@ -298,6 +565,11 @@ export class ApiHandler {
|
|
|
298
565
|
return;
|
|
299
566
|
}
|
|
300
567
|
|
|
568
|
+
if (path === "/api/knowledge/content" && req.method === "GET") {
|
|
569
|
+
this.handleKnowledgeContent(req, res);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
301
573
|
// Board / Kanban API
|
|
302
574
|
if (path === "/api/board" && req.method === "GET") {
|
|
303
575
|
this.handleBoardSummary(res);
|
|
@@ -434,9 +706,138 @@ export class ApiHandler {
|
|
|
434
706
|
return;
|
|
435
707
|
}
|
|
436
708
|
|
|
437
|
-
|
|
709
|
+
try {
|
|
710
|
+
const result = this.healthTracker.getAvailability(range);
|
|
711
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
712
|
+
res.end(JSON.stringify(result));
|
|
713
|
+
} catch (err) {
|
|
714
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
715
|
+
res.end(JSON.stringify({ error: String((err as Error)?.message ?? err) }));
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ── Config file API (local node) ────────────────────────────────
|
|
720
|
+
|
|
721
|
+
/** Resolve config path, restricted to ~/.openclaw/ for safety. */
|
|
722
|
+
private resolveConfigPath(configPath: string): string | null {
|
|
723
|
+
const home = homedir();
|
|
724
|
+
const openclawDir = `${home}/.openclaw`;
|
|
725
|
+
// Normalize: replace ~ with home dir
|
|
726
|
+
const resolved = configPath.startsWith("~")
|
|
727
|
+
? configPath.replace(/^~/, home)
|
|
728
|
+
: configPath;
|
|
729
|
+
// Security: only allow files under ~/.openclaw/
|
|
730
|
+
if (!resolved.startsWith(openclawDir)) return null;
|
|
731
|
+
// Prevent path traversal
|
|
732
|
+
if (resolved.includes("..")) return null;
|
|
733
|
+
return resolved;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Read a config file (for gateway methods). */
|
|
737
|
+
async readConfigFile(configPath: string): Promise<{ success: boolean; content?: string; error?: string }> {
|
|
738
|
+
const resolved = this.resolveConfigPath(configPath);
|
|
739
|
+
if (!resolved) return { success: false, error: "Path must be under ~/.openclaw/" };
|
|
740
|
+
try {
|
|
741
|
+
const content = await readFile(resolved, "utf-8");
|
|
742
|
+
return { success: true, content };
|
|
743
|
+
} catch (err: unknown) {
|
|
744
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return { success: true, content: "{}" };
|
|
745
|
+
return { success: false, error: err instanceof Error ? err.message : "Failed to read config" };
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Write a config file (for gateway methods). */
|
|
750
|
+
async writeConfigFile(configPath: string, content: string): Promise<{ success: boolean; error?: string }> {
|
|
751
|
+
const resolved = this.resolveConfigPath(configPath);
|
|
752
|
+
if (!resolved) return { success: false, error: "Path must be under ~/.openclaw/" };
|
|
753
|
+
try {
|
|
754
|
+
await mkdir(dirname(resolved), { recursive: true });
|
|
755
|
+
await writeFile(resolved, content, "utf-8");
|
|
756
|
+
return { success: true };
|
|
757
|
+
} catch (err) {
|
|
758
|
+
return { success: false, error: err instanceof Error ? err.message : "Failed to write config" };
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** GET /api/config/read?path=<configPath> — read a local config file. */
|
|
763
|
+
private async handleConfigRead(req: IncomingMessage, res: ServerResponse) {
|
|
764
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
765
|
+
const configPath = url.searchParams.get("path");
|
|
766
|
+
if (!configPath) {
|
|
767
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
768
|
+
res.end(JSON.stringify({ error: "Missing required query param: path" }));
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const resolved = this.resolveConfigPath(configPath);
|
|
773
|
+
if (!resolved) {
|
|
774
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
775
|
+
res.end(JSON.stringify({ error: "Path must be under ~/.openclaw/" }));
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
try {
|
|
780
|
+
const content = await readFile(resolved, "utf-8");
|
|
781
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
782
|
+
res.end(JSON.stringify({ success: true, content }));
|
|
783
|
+
} catch (err: unknown) {
|
|
784
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
785
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
786
|
+
res.end(JSON.stringify({ success: true, content: "{}" }));
|
|
787
|
+
} else {
|
|
788
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
789
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Failed to read config" }));
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** POST /api/config/write — write a local config file. Body: { path, content } */
|
|
795
|
+
private async handleConfigWrite(req: IncomingMessage, res: ServerResponse) {
|
|
796
|
+
try {
|
|
797
|
+
const body = await readBody(req);
|
|
798
|
+
const { path: configPath, content } = JSON.parse(body);
|
|
799
|
+
if (!configPath || typeof content !== "string") {
|
|
800
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
801
|
+
res.end(JSON.stringify({ error: "path and content required" }));
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const resolved = this.resolveConfigPath(configPath);
|
|
806
|
+
if (!resolved) {
|
|
807
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
808
|
+
res.end(JSON.stringify({ error: "Path must be under ~/.openclaw/" }));
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Ensure directory exists
|
|
813
|
+
await mkdir(dirname(resolved), { recursive: true });
|
|
814
|
+
await writeFile(resolved, content, "utf-8");
|
|
815
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
816
|
+
res.end(JSON.stringify({ success: true }));
|
|
817
|
+
} catch (err) {
|
|
818
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
819
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Failed to write config" }));
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** GET /api/tools — list cluster tools with descriptions and input schemas. */
|
|
824
|
+
private handleToolsList(res: ServerResponse) {
|
|
825
|
+
// Build per-node tool info: local node tools + remote peer tools
|
|
826
|
+
const nodes: Array<{ nodeId: string; tools: typeof CLUSTER_TOOL_DEFS }> = [];
|
|
827
|
+
|
|
828
|
+
// Local node
|
|
829
|
+
nodes.push({ nodeId: this.config.nodeId, tools: CLUSTER_TOOL_DEFS });
|
|
830
|
+
|
|
831
|
+
// Remote peers — include cluster tools for each online peer with toolProxy
|
|
832
|
+
const peers = this.peerManager.router.getAllPeers();
|
|
833
|
+
for (const p of peers) {
|
|
834
|
+
if (!this.peerManager.canReach(p.nodeId)) continue;
|
|
835
|
+
if (!p.toolProxy?.enabled) continue;
|
|
836
|
+
nodes.push({ nodeId: p.nodeId, tools: CLUSTER_TOOL_DEFS });
|
|
837
|
+
}
|
|
838
|
+
|
|
438
839
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
439
|
-
res.end(JSON.stringify(
|
|
840
|
+
res.end(JSON.stringify({ nodes }));
|
|
440
841
|
}
|
|
441
842
|
|
|
442
843
|
private handleStatus(res: ServerResponse) {
|
|
@@ -1208,6 +1609,46 @@ export class ApiHandler {
|
|
|
1208
1609
|
return events.slice(-(opts?.limit ?? 50)).reverse();
|
|
1209
1610
|
}
|
|
1210
1611
|
|
|
1612
|
+
/** Ingest events programmatically (from gateway methods, CLI, etc.). */
|
|
1613
|
+
ingestEvents(items: Array<Record<string, unknown>>): { count: number; ids: string[] } {
|
|
1614
|
+
const ingested: IngestedEvent[] = [];
|
|
1615
|
+
for (const item of items) {
|
|
1616
|
+
if (!item.type) continue;
|
|
1617
|
+
const event: IngestedEvent = {
|
|
1618
|
+
id: nanoid(),
|
|
1619
|
+
source: String(item.source || "unknown"),
|
|
1620
|
+
type: String(item.type),
|
|
1621
|
+
data: (typeof item.data === "object" && item.data !== null ? item.data : { value: item.data ?? item }) as Record<string, unknown>,
|
|
1622
|
+
ts: typeof item.ts === "number" ? Math.min(item.ts as number, Date.now()) : Date.now(),
|
|
1623
|
+
consumed: false,
|
|
1624
|
+
};
|
|
1625
|
+
this.ingestedEvents.push(event);
|
|
1626
|
+
ingested.push(event);
|
|
1627
|
+
try {
|
|
1628
|
+
this.store?.insertIngestedEvent({
|
|
1629
|
+
id: event.id, ts: event.ts, type: event.type, source: event.source,
|
|
1630
|
+
data: JSON.stringify(event.data),
|
|
1631
|
+
});
|
|
1632
|
+
} catch { /* non-fatal */ }
|
|
1633
|
+
}
|
|
1634
|
+
if (this.ingestedEvents.length > MAX_INGESTED_EVENTS) {
|
|
1635
|
+
this.ingestedEvents.splice(0, this.ingestedEvents.length - MAX_INGESTED_EVENTS);
|
|
1636
|
+
}
|
|
1637
|
+
for (const event of ingested) {
|
|
1638
|
+
this.pushSatelliteEvent({
|
|
1639
|
+
ts: event.ts,
|
|
1640
|
+
type: "event_ingested" as SatelliteEvent["type"],
|
|
1641
|
+
data: { id: event.id, source: event.source, type: event.type },
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
if (this.automationManager) {
|
|
1645
|
+
for (const event of ingested) {
|
|
1646
|
+
this.automationManager.onEventIngested(event);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
return { count: ingested.length, ids: ingested.map(e => e.id) };
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1211
1652
|
/** Mark events as consumed by id(s). */
|
|
1212
1653
|
consumeEvents(ids: string[]): number {
|
|
1213
1654
|
if (this.store) {
|
|
@@ -1251,8 +1692,9 @@ export class ApiHandler {
|
|
|
1251
1692
|
return;
|
|
1252
1693
|
}
|
|
1253
1694
|
const files = this.knowledgeSync.listSyncedFiles();
|
|
1695
|
+
const workspacePath = this.knowledgeSync.workspacePath;
|
|
1254
1696
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1255
|
-
res.end(JSON.stringify({ success: true, files }));
|
|
1697
|
+
res.end(JSON.stringify({ success: true, files, workspacePath }));
|
|
1256
1698
|
}
|
|
1257
1699
|
|
|
1258
1700
|
/** GET /api/knowledge/history?path=<relPath> — file change history from CRDT. */
|
|
@@ -1292,6 +1734,37 @@ export class ApiHandler {
|
|
|
1292
1734
|
res.end(JSON.stringify({ success: true, path: filePath, history }));
|
|
1293
1735
|
}
|
|
1294
1736
|
|
|
1737
|
+
/** GET /api/knowledge/content?path=<relPath> — read synced file content from CRDT. */
|
|
1738
|
+
private handleKnowledgeContent(req: IncomingMessage, res: ServerResponse) {
|
|
1739
|
+
if (!this.knowledgeSync) {
|
|
1740
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1741
|
+
res.end(JSON.stringify({ error: "Knowledge sync not enabled" }));
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
1745
|
+
const filePath = url.searchParams.get("path");
|
|
1746
|
+
if (!filePath) {
|
|
1747
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1748
|
+
res.end(JSON.stringify({ error: "Missing required query param: path" }));
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
const content = this.knowledgeSync.getFileContent(filePath);
|
|
1752
|
+
if (content === null) {
|
|
1753
|
+
// Distinguish between "not in registry" and "in registry but content not yet synced"
|
|
1754
|
+
const listed = this.knowledgeSync.listSyncedFiles().find(f => f.path === filePath);
|
|
1755
|
+
if (listed) {
|
|
1756
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
1757
|
+
res.end(JSON.stringify({ error: "File content not yet synced", syncing: true }));
|
|
1758
|
+
} else {
|
|
1759
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1760
|
+
res.end(JSON.stringify({ error: "File not found" }));
|
|
1761
|
+
}
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1765
|
+
res.end(JSON.stringify({ success: true, path: filePath, content }));
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1295
1768
|
// ── Board / Kanban API handlers ─────────────────────────────────
|
|
1296
1769
|
|
|
1297
1770
|
private handleBoardSummary(res: ServerResponse) {
|
package/src/automation.ts
CHANGED
|
@@ -27,7 +27,14 @@ export interface AutomationTrigger {
|
|
|
27
27
|
type: "event" | "schedule";
|
|
28
28
|
source?: string;
|
|
29
29
|
eventType?: string;
|
|
30
|
+
/** Interval-based schedule: repeat every N milliseconds (min 10s). */
|
|
30
31
|
intervalMs?: number;
|
|
32
|
+
/** Daily schedule: time of day in "HH:MM" (24h) format. */
|
|
33
|
+
dailyTime?: string;
|
|
34
|
+
/** IANA timezone for daily schedule (default: system timezone). */
|
|
35
|
+
timezone?: string;
|
|
36
|
+
/** Days of week to run (0=Sun, 1=Mon, ..., 6=Sat). Empty/undefined = every day. */
|
|
37
|
+
daysOfWeek?: number[];
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
export interface AutomationCondition {
|
|
@@ -318,6 +325,27 @@ export class AutomationManager {
|
|
|
318
325
|
|
|
319
326
|
async saveRules(rules: AutomationRule[]): Promise<void> {
|
|
320
327
|
const normalized = rules.map((rule) => ({ ...rule, action: normalizeAction(rule.action) }));
|
|
328
|
+
for (const rule of normalized) {
|
|
329
|
+
const action = rule.action;
|
|
330
|
+
if (action.type === "handoff" || action.type == null) {
|
|
331
|
+
const handoff = action as AutomationHandoffAction;
|
|
332
|
+
if (!handoff.agent || !handoff.agent.trim()) {
|
|
333
|
+
throw new Error(`Rule "${rule.name || rule.id}": handoff action requires a non-empty agent`);
|
|
334
|
+
}
|
|
335
|
+
if (!handoff.task || !handoff.task.trim()) {
|
|
336
|
+
throw new Error(`Rule "${rule.name || rule.id}": handoff action requires a non-empty task`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (action.type === "tool") {
|
|
340
|
+
const tool = action as AutomationToolAction;
|
|
341
|
+
if (!tool.node || !tool.node.trim()) {
|
|
342
|
+
throw new Error(`Rule "${rule.name || rule.id}": tool action requires a non-empty node`);
|
|
343
|
+
}
|
|
344
|
+
if (!tool.tool || !tool.tool.trim()) {
|
|
345
|
+
throw new Error(`Rule "${rule.name || rule.id}": tool action requires a non-empty tool name`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
321
349
|
const content: AutomationsFile = { rules: normalized };
|
|
322
350
|
await writeFile(this.filePath, JSON.stringify(content, null, 2), "utf-8");
|
|
323
351
|
this.stopScheduleTimers();
|
|
@@ -523,7 +551,7 @@ export class AutomationManager {
|
|
|
523
551
|
title,
|
|
524
552
|
detail,
|
|
525
553
|
progress,
|
|
526
|
-
status: success ? "completed" : "
|
|
554
|
+
status: success ? "completed" : "failed",
|
|
527
555
|
},
|
|
528
556
|
});
|
|
529
557
|
} catch {
|
|
@@ -577,6 +605,13 @@ export class AutomationManager {
|
|
|
577
605
|
for (const rule of this.rules) {
|
|
578
606
|
if (!rule.enabled) continue;
|
|
579
607
|
if (rule.trigger.type !== "schedule") continue;
|
|
608
|
+
|
|
609
|
+
if (rule.trigger.dailyTime) {
|
|
610
|
+
// Daily schedule: check every 30s if we should fire
|
|
611
|
+
this.scheduleDailyTimer(rule);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
|
|
580
615
|
if (!rule.trigger.intervalMs || rule.trigger.intervalMs < 10_000) continue;
|
|
581
616
|
|
|
582
617
|
const timer = setInterval(() => {
|
|
@@ -589,6 +624,60 @@ export class AutomationManager {
|
|
|
589
624
|
}
|
|
590
625
|
}
|
|
591
626
|
|
|
627
|
+
private scheduleDailyTimer(rule: AutomationRule): void {
|
|
628
|
+
const CHECK_INTERVAL = 30_000; // Check every 30s
|
|
629
|
+
const fired = new Set<string>(); // Track fired dates to prevent double-fire
|
|
630
|
+
|
|
631
|
+
const timer = setInterval(() => {
|
|
632
|
+
const { dailyTime, timezone, daysOfWeek } = rule.trigger;
|
|
633
|
+
if (!dailyTime) return;
|
|
634
|
+
|
|
635
|
+
const now = new Date();
|
|
636
|
+
// Format current time in the target timezone
|
|
637
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
638
|
+
timeZone: timezone || undefined,
|
|
639
|
+
hour: "2-digit",
|
|
640
|
+
minute: "2-digit",
|
|
641
|
+
hour12: false,
|
|
642
|
+
weekday: "short",
|
|
643
|
+
year: "numeric",
|
|
644
|
+
month: "2-digit",
|
|
645
|
+
day: "2-digit",
|
|
646
|
+
});
|
|
647
|
+
const parts = Object.fromEntries(
|
|
648
|
+
formatter.formatToParts(now).map((p) => [p.type, p.value]),
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
const currentTime = `${parts.hour}:${parts.minute}`;
|
|
652
|
+
const dateKey = `${parts.year}-${parts.month}-${parts.day}`;
|
|
653
|
+
|
|
654
|
+
// Check time match (within 1-minute window)
|
|
655
|
+
if (currentTime !== dailyTime) return;
|
|
656
|
+
|
|
657
|
+
// Check day-of-week filter
|
|
658
|
+
if (daysOfWeek && daysOfWeek.length > 0) {
|
|
659
|
+
const dayMap: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
660
|
+
const currentDay = dayMap[parts.weekday] ?? now.getDay();
|
|
661
|
+
if (!daysOfWeek.includes(currentDay)) return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Prevent double-fire within same date
|
|
665
|
+
if (fired.has(dateKey)) return;
|
|
666
|
+
fired.add(dateKey);
|
|
667
|
+
// Cleanup old entries
|
|
668
|
+
if (fired.size > 7) {
|
|
669
|
+
const entries = [...fired];
|
|
670
|
+
entries.slice(0, entries.length - 7).forEach((k) => fired.delete(k));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (!this.isOnCooldown(rule)) {
|
|
674
|
+
void this.executeRule(rule);
|
|
675
|
+
}
|
|
676
|
+
}, CHECK_INTERVAL);
|
|
677
|
+
|
|
678
|
+
this.scheduleTimers.set(rule.id, timer);
|
|
679
|
+
}
|
|
680
|
+
|
|
592
681
|
private stopScheduleTimers(): void {
|
|
593
682
|
for (const timer of this.scheduleTimers.values()) {
|
|
594
683
|
clearInterval(timer);
|