@clawdraw/skill 0.1.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.
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * WebSocket connection manager for sending strokes to the ClawDraw relay.
4
+ *
5
+ * Usage:
6
+ * import { connect, sendStrokes, addWaypoint, getWaypointUrl, disconnect } from './connection.mjs';
7
+ *
8
+ * const ws = await connect(token);
9
+ * await sendStrokes(ws, strokes, { waitForAcks: true });
10
+ * const wp = await addWaypoint(ws, { name: 'My Spot', x: 0, y: 0, zoom: 1 });
11
+ * console.log(getWaypointUrl(wp));
12
+ * disconnect(ws);
13
+ */
14
+
15
+ import WebSocket from 'ws';
16
+
17
+ const WS_URL = 'wss://relay.clawdraw.ai/ws';
18
+
19
+ const MAX_RETRIES = 5;
20
+ const BASE_DELAY_MS = 1000;
21
+
22
+ /**
23
+ * Connect to the relay WebSocket with auth token.
24
+ * Sends an initial viewport.update on open.
25
+ *
26
+ * @param {string} token - JWT from auth.mjs getToken()
27
+ * @param {object} [opts]
28
+ * @param {string} [opts.username] - Bot display name
29
+ * @param {{ x: number, y: number }} [opts.center] - Viewport center
30
+ * @param {number} [opts.zoom] - Viewport zoom
31
+ * @returns {Promise<WebSocket>}
32
+ */
33
+ export function connect(token, opts = {}) {
34
+ const username = opts.username || 'openclaw-bot';
35
+ const center = opts.center || { x: 0, y: 0 };
36
+ const zoom = opts.zoom || 0.2;
37
+
38
+ return new Promise((resolve, reject) => {
39
+ const ws = new WebSocket(WS_URL, {
40
+ headers: { Authorization: `Bearer ${token}` },
41
+ });
42
+
43
+ ws.on('open', () => {
44
+ // Send initial viewport so the relay knows where we are
45
+ const viewportMsg = {
46
+ type: 'viewport.update',
47
+ viewport: {
48
+ center,
49
+ zoom,
50
+ size: { width: 6000, height: 6000 },
51
+ },
52
+ cursor: center,
53
+ username,
54
+ };
55
+ ws.send(JSON.stringify(viewportMsg));
56
+
57
+ // Re-send presence every 30s to prevent 60s eviction timeout
58
+ ws._presenceHeartbeat = setInterval(() => {
59
+ if (ws.readyState === WebSocket.OPEN) {
60
+ ws.send(JSON.stringify(viewportMsg));
61
+ }
62
+ }, 30000);
63
+
64
+ // Wait for chunks.initial before resolving — strokes sent before
65
+ // subscription completes get rejected with REGION_FULL / chunk.full.
66
+ const subTimeout = setTimeout(() => {
67
+ ws.removeListener('message', onChunksInitial);
68
+ console.warn('[connection] chunks.initial not received within 5s, resolving anyway');
69
+ resolve(ws);
70
+ }, 5000);
71
+
72
+ function onChunksInitial(data) {
73
+ try {
74
+ const parsed = JSON.parse(data.toString());
75
+ const msgs = Array.isArray(parsed) ? parsed : [parsed];
76
+ for (const msg of msgs) {
77
+ if (msg.type === 'chunks.initial') {
78
+ clearTimeout(subTimeout);
79
+ ws.removeListener('message', onChunksInitial);
80
+ resolve(ws);
81
+ return;
82
+ }
83
+ }
84
+ } catch { /* ignore non-JSON frames */ }
85
+ }
86
+
87
+ ws.on('message', onChunksInitial);
88
+ });
89
+
90
+ ws.on('error', (err) => {
91
+ reject(new Error(`WebSocket connection failed: ${err.message}`));
92
+ });
93
+
94
+ // If it closes before opening, reject
95
+ ws.on('close', (code) => {
96
+ if (ws.readyState !== WebSocket.OPEN) {
97
+ reject(new Error(`WebSocket closed before open (code ${code})`));
98
+ }
99
+ });
100
+ });
101
+ }
102
+
103
+ /** Maximum strokes per batch message. */
104
+ export const BATCH_SIZE = 100;
105
+
106
+ /**
107
+ * Send an array of strokes to the relay, batched for efficiency.
108
+ * Groups strokes into batches of up to BATCH_SIZE and sends each as
109
+ * a single `strokes.add` message.
110
+ *
111
+ * @param {WebSocket} ws - Connected WebSocket
112
+ * @param {Array} strokes - Array of stroke objects (from helpers.mjs makeStroke)
113
+ * @param {object|number} [optsOrDelay={}] - Options object or legacy delayMs number
114
+ * @param {number} [optsOrDelay.delayMs=50] - Milliseconds between batch sends
115
+ * @param {number} [optsOrDelay.batchSize=100] - Max strokes per batch
116
+ * @param {boolean} [optsOrDelay.legacy=false] - Use single stroke.add per stroke
117
+ * @param {boolean} [optsOrDelay.waitForAcks=false] - Wait for stroke.ack/strokes.ack before returning
118
+ * @returns {Promise<number|{sent: number, acked: number}>} Number of strokes sent (or {sent, acked} when waitForAcks)
119
+ */
120
+ export async function sendStrokes(ws, strokes, optsOrDelay = {}) {
121
+ // Support legacy call signature: sendStrokes(ws, strokes, 50)
122
+ const opts = typeof optsOrDelay === 'number'
123
+ ? { delayMs: optsOrDelay }
124
+ : optsOrDelay;
125
+
126
+ const delayMs = opts.delayMs ?? 50;
127
+ const batchSize = opts.batchSize ?? BATCH_SIZE;
128
+ const legacy = opts.legacy ?? false;
129
+ const waitForAcks = opts.waitForAcks ?? false;
130
+
131
+ let sent = 0;
132
+ let expectedAcks = 0;
133
+
134
+ if (legacy) {
135
+ // Legacy mode: one stroke.add per stroke
136
+ for (const stroke of strokes) {
137
+ if (ws.readyState !== WebSocket.OPEN) {
138
+ console.warn(`[connection] WebSocket not open, sent ${sent}/${strokes.length}`);
139
+ break;
140
+ }
141
+ ws.send(JSON.stringify({ type: 'stroke.add', stroke }));
142
+ sent++;
143
+ expectedAcks++;
144
+ if (sent < strokes.length && delayMs > 0) {
145
+ await sleep(delayMs);
146
+ }
147
+ }
148
+ } else {
149
+ // Batched mode: group into strokes.add messages
150
+ for (let i = 0; i < strokes.length; i += batchSize) {
151
+ if (ws.readyState !== WebSocket.OPEN) {
152
+ console.warn(`[connection] WebSocket not open, sent ${sent}/${strokes.length}`);
153
+ break;
154
+ }
155
+ const batch = strokes.slice(i, i + batchSize);
156
+ ws.send(JSON.stringify({ type: 'strokes.add', strokes: batch }));
157
+ sent += batch.length;
158
+ expectedAcks++;
159
+ if (i + batchSize < strokes.length && delayMs > 0) {
160
+ await sleep(delayMs);
161
+ }
162
+ }
163
+ }
164
+
165
+ if (!waitForAcks || expectedAcks === 0) {
166
+ return waitForAcks ? { sent, acked: 0 } : sent;
167
+ }
168
+
169
+ // Wait for ack messages
170
+ const acked = await new Promise((resolve) => {
171
+ let ackCount = 0;
172
+ const timeout = setTimeout(() => {
173
+ ws.removeListener('message', handler);
174
+ resolve(ackCount);
175
+ }, 10000);
176
+
177
+ function handler(data) {
178
+ try {
179
+ const parsed = JSON.parse(data.toString());
180
+ const msgs = Array.isArray(parsed) ? parsed : [parsed];
181
+ for (const msg of msgs) {
182
+ if (msg.type === 'stroke.ack' || msg.type === 'strokes.ack') {
183
+ ackCount++;
184
+ if (ackCount >= expectedAcks) {
185
+ clearTimeout(timeout);
186
+ ws.removeListener('message', handler);
187
+ resolve(ackCount);
188
+ return;
189
+ }
190
+ }
191
+ }
192
+ } catch { /* ignore */ }
193
+ }
194
+
195
+ ws.on('message', handler);
196
+ });
197
+
198
+ return { sent, acked };
199
+ }
200
+
201
+ /**
202
+ * Drop a waypoint on the canvas and wait for server confirmation.
203
+ *
204
+ * @param {WebSocket} ws - Connected WebSocket
205
+ * @param {object} opts
206
+ * @param {string} opts.name - Waypoint display name (max 64 chars)
207
+ * @param {number} opts.x - X coordinate
208
+ * @param {number} opts.y - Y coordinate
209
+ * @param {number} opts.zoom - Zoom level
210
+ * @param {string} [opts.description] - Optional description (max 512 chars)
211
+ * @returns {Promise<object>} The created waypoint object (with id, name, x, y, zoom)
212
+ */
213
+ export function addWaypoint(ws, { name, x, y, zoom, description }) {
214
+ return new Promise((resolve, reject) => {
215
+ const timeout = setTimeout(() => {
216
+ ws.removeListener('message', handler);
217
+ reject(new Error('Waypoint response timeout (5s)'));
218
+ }, 5000);
219
+
220
+ function handler(data) {
221
+ try {
222
+ const parsed = JSON.parse(data.toString());
223
+ const msgs = Array.isArray(parsed) ? parsed : [parsed];
224
+ for (const msg of msgs) {
225
+ if (msg.type === 'waypoint.added') {
226
+ clearTimeout(timeout);
227
+ ws.removeListener('message', handler);
228
+ resolve(msg.waypoint);
229
+ } else if (msg.type === 'sync.error') {
230
+ clearTimeout(timeout);
231
+ ws.removeListener('message', handler);
232
+ reject(new Error(msg.message || msg.code));
233
+ }
234
+ }
235
+ } catch { /* ignore */ }
236
+ }
237
+
238
+ ws.on('message', handler);
239
+ ws.send(JSON.stringify({
240
+ type: 'waypoint.add',
241
+ waypoint: { name, x, y, zoom, description: description || undefined },
242
+ }));
243
+ });
244
+ }
245
+
246
+ /**
247
+ * Build a shareable URL for a waypoint.
248
+ *
249
+ * @param {object} waypoint - Waypoint object with id property
250
+ * @returns {string} Shareable URL
251
+ */
252
+ export function getWaypointUrl(waypoint) {
253
+ return `https://clawdraw.ai/?wp=${waypoint.id}`;
254
+ }
255
+
256
+ /**
257
+ * Disconnect gracefully.
258
+ *
259
+ * @param {WebSocket} ws
260
+ */
261
+ export function disconnect(ws) {
262
+ if (ws && ws._presenceHeartbeat) {
263
+ clearInterval(ws._presenceHeartbeat);
264
+ ws._presenceHeartbeat = null;
265
+ }
266
+ if (ws && ws.readyState === WebSocket.OPEN) {
267
+ ws.close(1000, 'done');
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Connect with automatic reconnection on disconnect.
273
+ * Returns a wrapper that transparently reconnects.
274
+ *
275
+ * @param {string} token - JWT
276
+ * @param {object} [opts] - Same as connect() opts
277
+ * @returns {Promise<{ ws: WebSocket, sendStrokes: Function, disconnect: Function }>}
278
+ */
279
+ export async function connectWithRetry(token, opts = {}) {
280
+ let ws = null;
281
+ let retries = 0;
282
+ let closed = false;
283
+
284
+ async function doConnect() {
285
+ ws = await connect(token, opts);
286
+ retries = 0;
287
+
288
+ ws.on('close', async (code) => {
289
+ if (ws._presenceHeartbeat) {
290
+ clearInterval(ws._presenceHeartbeat);
291
+ ws._presenceHeartbeat = null;
292
+ }
293
+ if (closed) return;
294
+ if (retries >= MAX_RETRIES) {
295
+ console.error(`[connection] Max retries (${MAX_RETRIES}) exceeded, giving up`);
296
+ return;
297
+ }
298
+ const delay = BASE_DELAY_MS * Math.pow(2, retries);
299
+ retries++;
300
+ console.warn(`[connection] Disconnected (code ${code}), reconnecting in ${delay}ms (attempt ${retries})`);
301
+ await sleep(delay);
302
+ if (!closed) {
303
+ try { await doConnect(); } catch (e) {
304
+ console.error(`[connection] Reconnect failed:`, e.message);
305
+ }
306
+ }
307
+ });
308
+
309
+ return ws;
310
+ }
311
+
312
+ await doConnect();
313
+
314
+ return {
315
+ get ws() { return ws; },
316
+ sendStrokes: (strokes, delayMs) => sendStrokes(ws, strokes, delayMs),
317
+ disconnect() {
318
+ closed = true;
319
+ disconnect(ws);
320
+ },
321
+ };
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Internal
326
+ // ---------------------------------------------------------------------------
327
+
328
+ function sleep(ms) {
329
+ return new Promise(resolve => setTimeout(resolve, ms));
330
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Symmetry system for ClawDraw stroke generation.
3
+ *
4
+ * Modes:
5
+ * - none: no copies, no constraints
6
+ * - vertical: mirror across Y axis (flip X around center)
7
+ * - horizontal: mirror across X axis (flip Y around center)
8
+ * - both: 4-fold mirror (3 copies: flip X, flip Y, flip both)
9
+ * - radial:N N copies rotated evenly around center
10
+ *
11
+ * All functions are pure (no global state). Symmetry center is passed explicitly.
12
+ */
13
+
14
+ let _symSeq = 0;
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Parsing
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Parse a symmetry mode string into a structured descriptor.
22
+ * @param {string} str - One of 'none', 'vertical', 'horizontal', 'both', 'radial:N'
23
+ * @returns {{ mode: string, folds: number }}
24
+ */
25
+ export function parseSymmetryMode(str) {
26
+ if (!str || str === 'none') return { mode: 'none', folds: 1 };
27
+
28
+ const lower = String(str).trim().toLowerCase();
29
+
30
+ if (lower === 'vertical') return { mode: 'vertical', folds: 2 };
31
+ if (lower === 'horizontal') return { mode: 'horizontal', folds: 2 };
32
+ if (lower === 'both') return { mode: 'both', folds: 4 };
33
+
34
+ const radialMatch = /^radial[:\s]+(\d+)$/i.exec(lower);
35
+ if (radialMatch) {
36
+ const n = Math.max(2, parseInt(radialMatch[1], 10));
37
+ return { mode: 'radial', folds: n };
38
+ }
39
+
40
+ return { mode: 'none', folds: 1 };
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Internal helper
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Deep-clone a stroke with a new ID and transformed points.
49
+ * @param {object} stroke - Source stroke
50
+ * @param {(pt: {x:number, y:number}) => {x:number, y:number}} transformFn
51
+ * @returns {object} New stroke with transformed points
52
+ */
53
+ function cloneStroke(stroke, transformFn) {
54
+ const id = `sym-${Date.now().toString(36)}-${(++_symSeq).toString(36)}`;
55
+ return {
56
+ ...stroke,
57
+ id,
58
+ points: stroke.points.map(p => {
59
+ const np = transformFn(p);
60
+ return { ...p, x: np.x, y: np.y };
61
+ }),
62
+ };
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Constraint enforcement
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Mutate stroke points so the stroke's centroid lies in the canonical region
71
+ * for the given symmetry mode. This ensures the original stroke is in the
72
+ * "primary" zone before copies are generated.
73
+ *
74
+ * - vertical: centroid must be in right half (x >= centerX)
75
+ * - horizontal: centroid must be in top half (y <= centerY)
76
+ * - both: centroid must be in top-right quadrant
77
+ * - radial: centroid must be in wedge [0, 2pi/N)
78
+ *
79
+ * The entire stroke is transformed as a rigid body to preserve shape.
80
+ *
81
+ * @param {object} stroke - Stroke to mutate in place
82
+ * @param {string} mode - 'none' | 'vertical' | 'horizontal' | 'both' | 'radial'
83
+ * @param {number} folds - Number of folds (only used for radial)
84
+ * @param {number} centerX - Symmetry center X
85
+ * @param {number} centerY - Symmetry center Y
86
+ */
87
+ export function enforceConstraints(stroke, mode, folds, centerX, centerY) {
88
+ if (mode === 'none') return;
89
+ if (!stroke.points || stroke.points.length === 0) return;
90
+
91
+ // Compute centroid
92
+ let sumX = 0, sumY = 0;
93
+ for (const pt of stroke.points) { sumX += pt.x; sumY += pt.y; }
94
+ const centX = sumX / stroke.points.length;
95
+ const centY = sumY / stroke.points.length;
96
+
97
+ if (mode === 'vertical') {
98
+ if (centX < centerX) {
99
+ for (const pt of stroke.points) pt.x = 2 * centerX - pt.x;
100
+ }
101
+ } else if (mode === 'horizontal') {
102
+ if (centY > centerY) {
103
+ for (const pt of stroke.points) pt.y = 2 * centerY - pt.y;
104
+ }
105
+ } else if (mode === 'both') {
106
+ if (centX < centerX) {
107
+ for (const pt of stroke.points) pt.x = 2 * centerX - pt.x;
108
+ }
109
+ if (centY > centerY) {
110
+ for (const pt of stroke.points) pt.y = 2 * centerY - pt.y;
111
+ }
112
+ } else if (mode === 'radial') {
113
+ const dx = centX - centerX, dy = centY - centerY;
114
+ const r = Math.sqrt(dx * dx + dy * dy);
115
+ if (r < 1) return; // Centered at origin — inherently symmetric
116
+
117
+ const maxAngle = (2 * Math.PI) / folds;
118
+ let angle = Math.atan2(dy, dx);
119
+ if (angle < 0) angle += 2 * Math.PI;
120
+
121
+ if (angle > maxAngle) {
122
+ const targetAngle = angle % maxAngle;
123
+ const rotationDelta = targetAngle - angle;
124
+ const cos = Math.cos(rotationDelta), sin = Math.sin(rotationDelta);
125
+ for (const pt of stroke.points) {
126
+ const pdx = pt.x - centerX, pdy = pt.y - centerY;
127
+ pt.x = centerX + pdx * cos - pdy * sin;
128
+ pt.y = centerY + pdx * sin + pdy * cos;
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Copy generation
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /**
139
+ * Generate symmetry copies of a stroke (not including the original).
140
+ *
141
+ * @param {object} stroke - Original stroke
142
+ * @param {string} mode - 'none' | 'vertical' | 'horizontal' | 'both' | 'radial'
143
+ * @param {number} folds - Number of folds (only used for radial)
144
+ * @param {number} centerX - Symmetry center X
145
+ * @param {number} centerY - Symmetry center Y
146
+ * @returns {object[]} Array of cloned strokes with transformed points
147
+ */
148
+ export function generateCopies(stroke, mode, folds, centerX, centerY) {
149
+ if (mode === 'none') return [];
150
+
151
+ const cx = centerX;
152
+ const cy = centerY;
153
+
154
+ if (mode === 'vertical') {
155
+ return [cloneStroke(stroke, p => ({ x: 2 * cx - p.x, y: p.y }))];
156
+ }
157
+
158
+ if (mode === 'horizontal') {
159
+ return [cloneStroke(stroke, p => ({ x: p.x, y: 2 * cy - p.y }))];
160
+ }
161
+
162
+ if (mode === 'both') {
163
+ return [
164
+ cloneStroke(stroke, p => ({ x: 2 * cx - p.x, y: p.y })),
165
+ cloneStroke(stroke, p => ({ x: p.x, y: 2 * cy - p.y })),
166
+ cloneStroke(stroke, p => ({ x: 2 * cx - p.x, y: 2 * cy - p.y })),
167
+ ];
168
+ }
169
+
170
+ if (mode === 'radial') {
171
+ const copies = [];
172
+ for (let i = 1; i < folds; i++) {
173
+ const angle = (i / folds) * Math.PI * 2;
174
+ const cos = Math.cos(angle), sin = Math.sin(angle);
175
+ copies.push(cloneStroke(stroke, p => {
176
+ const dx = p.x - cx, dy = p.y - cy;
177
+ return {
178
+ x: cx + dx * cos - dy * sin,
179
+ y: cy + dx * sin + dy * cos,
180
+ };
181
+ }));
182
+ }
183
+ return copies;
184
+ }
185
+
186
+ return [];
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // High-level API
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Apply symmetry to an array of strokes. Returns a new array containing
195
+ * the originals plus all generated symmetry copies.
196
+ *
197
+ * Constraints are enforced on the originals before copies are generated,
198
+ * so originals may be mutated in place.
199
+ *
200
+ * @param {object[]} strokes - Original strokes
201
+ * @param {string} mode - 'none' | 'vertical' | 'horizontal' | 'both' | 'radial'
202
+ * @param {number} folds - Number of folds
203
+ * @param {number} centerX - Symmetry center X
204
+ * @param {number} centerY - Symmetry center Y
205
+ * @returns {object[]} Originals + symmetry copies
206
+ */
207
+ export function applySymmetry(strokes, mode, folds, centerX, centerY) {
208
+ if (mode === 'none') return strokes;
209
+
210
+ const result = [];
211
+ for (const stroke of strokes) {
212
+ enforceConstraints(stroke, mode, folds, centerX, centerY);
213
+ result.push(stroke);
214
+ result.push(...generateCopies(stroke, mode, folds, centerX, centerY));
215
+ }
216
+ return result;
217
+ }