clawmatrix 0.3.1 → 0.4.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/src/tool-proxy.ts CHANGED
@@ -10,6 +10,9 @@ import type {
10
10
  } from "./types.ts";
11
11
  import type { PluginLogger } from "openclaw/plugin-sdk";
12
12
  import { isLocalTool, executeLocally } from "./local-tools.ts";
13
+ import { writeFileSync, mkdirSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
13
16
 
14
17
  const DEFAULT_TOOL_TIMEOUT = 30_000;
15
18
 
@@ -136,13 +139,41 @@ export class ToolProxy {
136
139
 
137
140
  if (frame.payload.success && frame.payload.result) {
138
141
  this.logger.info(`[clawmatrix] Tool response: id=${frame.id} from="${frame.from}" success`);
139
- pending.resolve(frame.payload.result);
142
+ const result = this.extractInlineImage(frame.payload.result);
143
+ pending.resolve(result);
140
144
  } else {
141
145
  this.logger.warn(`[clawmatrix] Tool response: id=${frame.id} from="${frame.from}" failed: ${frame.payload.error}`);
142
146
  pending.reject(new Error(frame.payload.error ?? "Remote tool execution failed"));
143
147
  }
144
148
  }
145
149
 
150
+ /**
151
+ * If the tool result contains inline base64 image data (mime: "image/*" + data),
152
+ * save it to a local temp file and replace `data` with `localPath`.
153
+ * This avoids flooding the LLM context with base64 text (saves ~tens of thousands of tokens).
154
+ */
155
+ private extractInlineImage(result: Record<string, unknown>): Record<string, unknown> {
156
+ const mime = result.mime;
157
+ const data = result.data;
158
+ if (typeof mime !== "string" || !mime.startsWith("image/") || typeof data !== "string") {
159
+ return result;
160
+ }
161
+
162
+ try {
163
+ const ext = mime === "image/png" ? ".png" : mime === "image/webp" ? ".webp" : ".jpg";
164
+ const dir = join(tmpdir(), "clawmatrix-images");
165
+ mkdirSync(dir, { recursive: true });
166
+ const localPath = join(dir, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
167
+ writeFileSync(localPath, Buffer.from(data, "base64"));
168
+ this.logger.info(`[clawmatrix] Saved inline image (${(data.length * 0.75 / 1024).toFixed(0)}KB) to ${localPath}`);
169
+ const { data: _stripped, ...rest } = result;
170
+ return { ...rest, localPath };
171
+ } catch (err) {
172
+ this.logger.warn(`[clawmatrix] Failed to extract inline image: ${err}`);
173
+ return result;
174
+ }
175
+ }
176
+
146
177
  // ── Incoming request: execute via local Gateway ────────────────
147
178
  async handleRequest(frame: ToolProxyRequest): Promise<void> {
148
179
  const { id, from, payload } = frame;
@@ -288,7 +319,14 @@ export class ToolProxy {
288
319
 
289
320
  clearTimeout(pending.timer);
290
321
  this.pendingBatch.delete(frame.id);
291
- pending.resolve(frame.payload.results);
322
+ // Extract inline images from batch results
323
+ const results = frame.payload.results.map((item) => {
324
+ if (item.success && item.result) {
325
+ return { ...item, result: this.extractInlineImage(item.result) };
326
+ }
327
+ return item;
328
+ });
329
+ pending.resolve(results);
292
330
  }
293
331
 
294
332
  // ── Incoming batch request: execute sequentially via local Gateway ──
@@ -0,0 +1,132 @@
1
+ import type { AnyAgentTool } from "openclaw/plugin-sdk";
2
+ import { getClusterRuntime } from "../cluster-service.ts";
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ export function createClusterNotifyTool(): AnyAgentTool {
6
+ return {
7
+ name: "cluster_notify",
8
+ label: "Cluster Notify",
9
+ description:
10
+ "Push a notification to mobile devices in the cluster (triggers Dynamic Island / Live Activity on iOS). " +
11
+ "Use this when you are about to start a long-running task so the user can track progress on their phone. " +
12
+ 'Actions: "start" begins a new activity, "update" changes detail/progress, "end" dismisses it.',
13
+ parameters: {
14
+ type: "object",
15
+ properties: {
16
+ action: {
17
+ type: "string",
18
+ enum: ["start", "update", "end"],
19
+ description: 'Action to perform. Default: "start"',
20
+ },
21
+ taskId: {
22
+ type: "string",
23
+ description: "Task ID for update/end actions. Returned by start action. Omit for start to auto-generate.",
24
+ },
25
+ title: {
26
+ type: "string",
27
+ description: "Activity title (shown in Dynamic Island and lock screen)",
28
+ },
29
+ detail: {
30
+ type: "string",
31
+ description: "Activity detail text (e.g. current step description)",
32
+ },
33
+ progress: {
34
+ type: "number",
35
+ description: "Progress value 0.0 to 1.0 (optional, shows progress bar when provided)",
36
+ },
37
+ tool: {
38
+ type: "string",
39
+ description: "Current tool being executed (shown as a tag in the activity)",
40
+ },
41
+ success: {
42
+ type: "boolean",
43
+ description: 'For "end" action: true for completed, false for failed. Default: true',
44
+ },
45
+ },
46
+ required: [],
47
+ },
48
+ async execute(_toolCallId, params) {
49
+ const {
50
+ action = "start",
51
+ taskId: providedTaskId,
52
+ title,
53
+ detail,
54
+ progress,
55
+ tool,
56
+ success = true,
57
+ } = params as {
58
+ action?: "start" | "update" | "end";
59
+ taskId?: string;
60
+ title?: string;
61
+ detail?: string;
62
+ progress?: number;
63
+ tool?: string;
64
+ success?: boolean;
65
+ };
66
+
67
+ try {
68
+ const runtime = getClusterRuntime();
69
+ const peers = runtime.peerManager.router.getAllPeers();
70
+ const mobileTargets = peers.filter((p) =>
71
+ p.tags.some((t) => t === "mobile" || t === "ios" || t === "phone"),
72
+ );
73
+
74
+ if (mobileTargets.length === 0) {
75
+ return {
76
+ content: [{ type: "text" as const, text: "No mobile peers connected" }],
77
+ details: { error: true },
78
+ };
79
+ }
80
+
81
+ const taskId = providedTaskId || randomUUID();
82
+ const now = Date.now();
83
+
84
+ let status: string;
85
+ if (action === "start") status = "started";
86
+ else if (action === "end") status = success ? "completed" : "failed";
87
+ else status = "progress";
88
+
89
+ const frame = {
90
+ type: "task_activity" as const,
91
+ id: randomUUID(),
92
+ from: runtime.config.nodeId,
93
+ timestamp: now,
94
+ payload: {
95
+ taskId,
96
+ taskType: "notify" as const,
97
+ status,
98
+ agent: title || runtime.config.nodeId,
99
+ nodeId: runtime.config.nodeId,
100
+ title: title || runtime.config.nodeId,
101
+ detail: detail || (action === "end" ? (success ? "已完成" : "失败") : undefined),
102
+ startedAt: now,
103
+ elapsedMs: 0,
104
+ progress,
105
+ tool,
106
+ },
107
+ };
108
+
109
+ for (const target of mobileTargets) {
110
+ runtime.peerManager.sendTo(target.nodeId, { ...frame, to: target.nodeId });
111
+ }
112
+
113
+ const targetNames = mobileTargets.map((t) => t.nodeId).join(", ");
114
+ return {
115
+ content: [{
116
+ type: "text" as const,
117
+ text: `Notification ${action}ed → ${targetNames} (taskId: ${taskId})`,
118
+ }],
119
+ details: { taskId, action, targets: mobileTargets.length },
120
+ };
121
+ } catch (err) {
122
+ return {
123
+ content: [{
124
+ type: "text" as const,
125
+ text: `Notify error: ${err instanceof Error ? err.message : String(err)}`,
126
+ }],
127
+ details: { error: true },
128
+ };
129
+ }
130
+ },
131
+ };
132
+ }
package/src/types.ts CHANGED
@@ -620,7 +620,7 @@ export interface AcpSessionInfo {
620
620
  description?: string; // first user message (for display in session list)
621
621
  updatedAt?: string;
622
622
  agent?: string; // which ACP agent (claude, codex, etc.)
623
- status?: "active" | "idle"; // active = in-memory session with daemon, idle = persisted on disk
623
+ status?: "busy" | "active" | "idle"; // busy = currently executing a prompt, active = daemon alive, idle = on disk only
624
624
  }
625
625
 
626
626
  export interface AcpListRequest extends ClusterFrame {