squidclaw 2.7.0 โ†’ 2.8.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/lib/engine.js CHANGED
@@ -228,6 +228,16 @@ export class SquidclawEngine {
228
228
  if (pending.c > 0) console.log(` โฐ Reminders: ${pending.c} pending`);
229
229
  } catch {}
230
230
 
231
+ // Nodes (Paired Devices)
232
+ try {
233
+ const { NodeManager } = await import('./features/nodes.js');
234
+ this.nodeManager = new NodeManager(this);
235
+ await this.nodeManager.startServer(9501);
236
+ const nodeCount = this.nodeManager.list('*').length;
237
+ if (nodeCount > 0) console.log(' ๐Ÿ“ก Nodes: ' + nodeCount + ' registered');
238
+ else console.log(' ๐Ÿ“ก Nodes: ws://0.0.0.0:9501');
239
+ } catch (err) { logger.error('engine', 'Nodes init failed: ' + err.message); }
240
+
231
241
  // Sandbox
232
242
  try {
233
243
  const { Sandbox } = await import('./features/sandbox.js');
@@ -0,0 +1,315 @@
1
+ /**
2
+ * ๐Ÿฆ‘ Nodes โ€” Paired Devices
3
+ * Connect phones, PCs, servers, IoT devices
4
+ * Real-time control via WebSocket
5
+ *
6
+ * Pairing: token-based (generate token โ†’ enter on device)
7
+ * Communication: WebSocket (ws://host:9501)
8
+ * Commands: camera, screen, location, run, notify, status
9
+ */
10
+
11
+ import { logger } from '../core/logger.js';
12
+ import crypto from 'crypto';
13
+ import { getHome } from '../core/config.js';
14
+
15
+ export class NodeManager {
16
+ constructor(engine) {
17
+ this.engine = engine;
18
+ this.nodes = new Map(); // nodeId -> { info, ws, status, lastSeen }
19
+ this.pendingPairs = new Map(); // token -> { agentId, createdAt, name }
20
+ this.wsServer = null;
21
+ this._initDb();
22
+ }
23
+
24
+ _initDb() {
25
+ this.engine.storage.db.exec(`
26
+ CREATE TABLE IF NOT EXISTS nodes (
27
+ id TEXT PRIMARY KEY,
28
+ agent_id TEXT NOT NULL,
29
+ name TEXT NOT NULL,
30
+ type TEXT DEFAULT 'generic',
31
+ platform TEXT DEFAULT 'unknown',
32
+ capabilities TEXT DEFAULT '[]',
33
+ paired_at TEXT DEFAULT (datetime('now')),
34
+ last_seen TEXT,
35
+ status TEXT DEFAULT 'offline',
36
+ metadata TEXT DEFAULT '{}'
37
+ )
38
+ `);
39
+ }
40
+
41
+ // โ”€โ”€ WebSocket Server โ”€โ”€
42
+
43
+ async startServer(port = 9501) {
44
+ try {
45
+ const { WebSocketServer } = await import('ws');
46
+
47
+ this.wsServer = new WebSocketServer({ port, path: '/nodes' });
48
+
49
+ this.wsServer.on('connection', (ws, req) => {
50
+ let nodeId = null;
51
+
52
+ ws.on('message', (data) => {
53
+ try {
54
+ const msg = JSON.parse(data.toString());
55
+ this._handleMessage(ws, msg).then(id => {
56
+ if (id) nodeId = id;
57
+ });
58
+ } catch (err) {
59
+ ws.send(JSON.stringify({ type: 'error', message: 'Invalid message' }));
60
+ }
61
+ });
62
+
63
+ ws.on('close', () => {
64
+ if (nodeId) {
65
+ const node = this.nodes.get(nodeId);
66
+ if (node) {
67
+ node.status = 'offline';
68
+ node.ws = null;
69
+ this.engine.storage.db.prepare("UPDATE nodes SET status = 'offline' WHERE id = ?").run(nodeId);
70
+ logger.info('nodes', `Node disconnected: ${node.info?.name || nodeId}`);
71
+ }
72
+ }
73
+ });
74
+
75
+ ws.on('error', () => {});
76
+
77
+ // Send hello
78
+ ws.send(JSON.stringify({ type: 'hello', message: 'Squidclaw Node Server ๐Ÿฆ‘' }));
79
+ });
80
+
81
+ // Load existing nodes from DB
82
+ const dbNodes = this.engine.storage.db.prepare('SELECT * FROM nodes').all();
83
+ for (const n of dbNodes) {
84
+ this.nodes.set(n.id, {
85
+ info: n,
86
+ ws: null,
87
+ status: 'offline',
88
+ lastSeen: n.last_seen,
89
+ pendingCommands: new Map(),
90
+ });
91
+ }
92
+
93
+ logger.info('nodes', `WebSocket server on port ${port} (${dbNodes.length} registered nodes)`);
94
+ return true;
95
+ } catch (err) {
96
+ logger.error('nodes', `Server failed: ${err.message}`);
97
+ return false;
98
+ }
99
+ }
100
+
101
+ // โ”€โ”€ Message Handler โ”€โ”€
102
+
103
+ async _handleMessage(ws, msg) {
104
+ const { type } = msg;
105
+
106
+ // Pairing
107
+ if (type === 'pair') {
108
+ return this._handlePair(ws, msg);
109
+ }
110
+
111
+ // Auth (reconnect with nodeId)
112
+ if (type === 'auth') {
113
+ const node = this.nodes.get(msg.nodeId);
114
+ if (!node) {
115
+ ws.send(JSON.stringify({ type: 'error', message: 'Unknown node' }));
116
+ return null;
117
+ }
118
+ node.ws = ws;
119
+ node.status = 'online';
120
+ node.lastSeen = new Date().toISOString();
121
+ this.engine.storage.db.prepare("UPDATE nodes SET status = 'online', last_seen = datetime('now') WHERE id = ?").run(msg.nodeId);
122
+ ws.send(JSON.stringify({ type: 'auth_ok', nodeId: msg.nodeId }));
123
+ logger.info('nodes', `Node reconnected: ${node.info.name}`);
124
+ return msg.nodeId;
125
+ }
126
+
127
+ // Command response
128
+ if (type === 'response') {
129
+ const node = this.nodes.get(msg.nodeId);
130
+ if (node) {
131
+ const pending = node.pendingCommands?.get(msg.commandId);
132
+ if (pending) {
133
+ pending.resolve(msg.data);
134
+ node.pendingCommands.delete(msg.commandId);
135
+ }
136
+ }
137
+ return msg.nodeId;
138
+ }
139
+
140
+ // Status update
141
+ if (type === 'status') {
142
+ const node = this.nodes.get(msg.nodeId);
143
+ if (node) {
144
+ node.lastSeen = new Date().toISOString();
145
+ if (msg.data) {
146
+ node.info = { ...node.info, ...msg.data };
147
+ }
148
+ this.engine.storage.db.prepare("UPDATE nodes SET last_seen = datetime('now') WHERE id = ?").run(msg.nodeId);
149
+ }
150
+ return msg.nodeId;
151
+ }
152
+
153
+ return null;
154
+ }
155
+
156
+ // โ”€โ”€ Pairing โ”€โ”€
157
+
158
+ _handlePair(ws, msg) {
159
+ const token = msg.token;
160
+ const pending = this.pendingPairs.get(token);
161
+
162
+ if (!pending) {
163
+ ws.send(JSON.stringify({ type: 'pair_error', message: 'Invalid or expired token' }));
164
+ return null;
165
+ }
166
+
167
+ const nodeId = 'node_' + crypto.randomBytes(4).toString('hex');
168
+
169
+ // Register node
170
+ const info = {
171
+ id: nodeId,
172
+ agent_id: pending.agentId,
173
+ name: pending.name || msg.name || 'Device',
174
+ type: msg.deviceType || 'generic',
175
+ platform: msg.platform || 'unknown',
176
+ capabilities: JSON.stringify(msg.capabilities || []),
177
+ };
178
+
179
+ this.engine.storage.db.prepare(
180
+ 'INSERT INTO nodes (id, agent_id, name, type, platform, capabilities, status) VALUES (?, ?, ?, ?, ?, ?, ?)'
181
+ ).run(info.id, info.agent_id, info.name, info.type, info.platform, info.capabilities, 'online');
182
+
183
+ this.nodes.set(nodeId, {
184
+ info,
185
+ ws,
186
+ status: 'online',
187
+ lastSeen: new Date().toISOString(),
188
+ pendingCommands: new Map(),
189
+ });
190
+
191
+ this.pendingPairs.delete(token);
192
+
193
+ ws.send(JSON.stringify({ type: 'paired', nodeId, name: info.name }));
194
+ logger.info('nodes', `New node paired: ${info.name} (${nodeId}) type=${info.type}`);
195
+
196
+ return nodeId;
197
+ }
198
+
199
+ // โ”€โ”€ Generate pairing token โ”€โ”€
200
+
201
+ generatePairToken(agentId, name = 'Device') {
202
+ const token = crypto.randomBytes(3).toString('hex').toUpperCase(); // 6-char code
203
+ this.pendingPairs.set(token, {
204
+ agentId,
205
+ name,
206
+ createdAt: Date.now(),
207
+ });
208
+
209
+ // Expire after 5 minutes
210
+ setTimeout(() => this.pendingPairs.delete(token), 300000);
211
+
212
+ return token;
213
+ }
214
+
215
+ // โ”€โ”€ Send command to node โ”€โ”€
216
+
217
+ async sendCommand(nodeId, command, data = {}, timeout = 30000) {
218
+ const node = this.nodes.get(nodeId);
219
+ if (!node) throw new Error('Node not found: ' + nodeId);
220
+ if (!node.ws || node.status !== 'online') throw new Error('Node offline: ' + (node.info?.name || nodeId));
221
+
222
+ const commandId = 'cmd_' + Date.now().toString(36);
223
+
224
+ return new Promise((resolve, reject) => {
225
+ const timer = setTimeout(() => {
226
+ node.pendingCommands?.delete(commandId);
227
+ reject(new Error('Command timeout'));
228
+ }, timeout);
229
+
230
+ node.pendingCommands = node.pendingCommands || new Map();
231
+ node.pendingCommands.set(commandId, {
232
+ resolve: (data) => { clearTimeout(timer); resolve(data); },
233
+ });
234
+
235
+ node.ws.send(JSON.stringify({
236
+ type: 'command',
237
+ commandId,
238
+ command,
239
+ data,
240
+ }));
241
+ });
242
+ }
243
+
244
+ // โ”€โ”€ High-level commands โ”€โ”€
245
+
246
+ async camera(nodeId, options = {}) {
247
+ return this.sendCommand(nodeId, 'camera', {
248
+ facing: options.facing || 'back',
249
+ ...options,
250
+ });
251
+ }
252
+
253
+ async screenshot(nodeId) {
254
+ return this.sendCommand(nodeId, 'screenshot');
255
+ }
256
+
257
+ async location(nodeId) {
258
+ return this.sendCommand(nodeId, 'location');
259
+ }
260
+
261
+ async notify(nodeId, title, body) {
262
+ return this.sendCommand(nodeId, 'notify', { title, body });
263
+ }
264
+
265
+ async run(nodeId, command) {
266
+ return this.sendCommand(nodeId, 'run', { command }, 60000);
267
+ }
268
+
269
+ async screenRecord(nodeId, durationMs = 10000) {
270
+ return this.sendCommand(nodeId, 'screen_record', { durationMs }, durationMs + 10000);
271
+ }
272
+
273
+ // โ”€โ”€ List/Status โ”€โ”€
274
+
275
+ list(agentId) {
276
+ return this.engine.storage.db.prepare(
277
+ 'SELECT * FROM nodes WHERE agent_id = ? ORDER BY name'
278
+ ).all(agentId).map(n => {
279
+ const live = this.nodes.get(n.id);
280
+ return {
281
+ ...n,
282
+ capabilities: JSON.parse(n.capabilities || '[]'),
283
+ status: live?.status || 'offline',
284
+ lastSeen: live?.lastSeen || n.last_seen,
285
+ };
286
+ });
287
+ }
288
+
289
+ getNode(nodeId) {
290
+ const db = this.engine.storage.db.prepare('SELECT * FROM nodes WHERE id = ?').get(nodeId);
291
+ if (!db) return null;
292
+ const live = this.nodes.get(nodeId);
293
+ return { ...db, status: live?.status || 'offline', capabilities: JSON.parse(db.capabilities || '[]') };
294
+ }
295
+
296
+ removeNode(nodeId) {
297
+ const node = this.nodes.get(nodeId);
298
+ if (node?.ws) {
299
+ node.ws.send(JSON.stringify({ type: 'unpaired' }));
300
+ node.ws.close();
301
+ }
302
+ this.nodes.delete(nodeId);
303
+ this.engine.storage.db.prepare('DELETE FROM nodes WHERE id = ?').run(nodeId);
304
+ logger.info('nodes', `Removed node: ${nodeId}`);
305
+ }
306
+
307
+ // โ”€โ”€ Cleanup โ”€โ”€
308
+
309
+ stop() {
310
+ if (this.wsServer) {
311
+ this.wsServer.close();
312
+ logger.info('nodes', 'WebSocket server stopped');
313
+ }
314
+ }
315
+ }
@@ -145,6 +145,54 @@ export async function commandsMiddleware(ctx, next) {
145
145
  return;
146
146
  }
147
147
 
148
+ if (cmd === '/nodes' || cmd === '/devices') {
149
+ if (!ctx.engine.nodeManager) { await ctx.reply('โŒ Nodes not available'); return; }
150
+ const args = msg.split(/\s+/).slice(1);
151
+ const sub = args[0];
152
+
153
+ if (!sub || sub === 'list') {
154
+ const nodes = ctx.engine.nodeManager.list(ctx.agentId);
155
+ if (nodes.length === 0) {
156
+ await ctx.reply('๐Ÿ“ก No paired devices\n\nUse /nodes pair <name> to add one');
157
+ return;
158
+ }
159
+ const lines = nodes.map(n =>
160
+ (n.status === 'online' ? '๐ŸŸข' : '๐Ÿ”ด') + ' *' + n.name + '* (' + n.type + ')\n ' + n.platform + ' ยท ' + n.id
161
+ );
162
+ await ctx.reply('๐Ÿ“ก *Paired Devices*\n\n' + lines.join('\n\n'));
163
+ return;
164
+ }
165
+
166
+ if (sub === 'pair') {
167
+ const name = args.slice(1).join(' ') || 'My Device';
168
+ const token = ctx.engine.nodeManager.generatePairToken(ctx.agentId, name);
169
+ await ctx.reply('๐Ÿ“ก *Pair: ' + name + '*\n\nCode: `' + token + '`\n\nEnter this on the device.\nExpires in 5 minutes.\n\nWebSocket: ws://' + (ctx.engine.config.api?.host || '0.0.0.0') + ':9501/nodes');
170
+ return;
171
+ }
172
+
173
+ if (sub === 'remove') {
174
+ const id = args[1];
175
+ if (!id) { await ctx.reply('Usage: /nodes remove <id>'); return; }
176
+ ctx.engine.nodeManager.removeNode(id);
177
+ await ctx.reply('โœ… Device removed');
178
+ return;
179
+ }
180
+
181
+ if (sub === 'notify') {
182
+ const id = args[1];
183
+ const message = args.slice(2).join(' ');
184
+ if (!id || !message) { await ctx.reply('Usage: /nodes notify <id> <message>'); return; }
185
+ try {
186
+ await ctx.engine.nodeManager.notify(id, 'Squidclaw', message);
187
+ await ctx.reply('๐Ÿ“จ Notification sent');
188
+ } catch (err) { await ctx.reply('โŒ ' + err.message); }
189
+ return;
190
+ }
191
+
192
+ await ctx.reply('๐Ÿ“ก *Node Commands*\n\n/nodes โ€” list devices\n/nodes pair <name> โ€” generate pairing code\n/nodes remove <id> โ€” unpair\n/nodes notify <id> <msg> โ€” send notification');
193
+ return;
194
+ }
195
+
148
196
  if (cmd === '/sandbox') {
149
197
  if (!ctx.engine.sandbox) { await ctx.reply('โŒ Sandbox not available'); return; }
150
198
  const args = msg.slice(9).trim();
@@ -169,6 +169,23 @@ export class ToolRouter {
169
169
  '---TOOL:handoff:reason---',
170
170
  'Transfer the conversation to a human agent. Use when you cannot help further.');
171
171
 
172
+ tools.push('', '### Pair Device',
173
+ '---TOOL:node_pair:device name---',
174
+ 'Generate a pairing code for a new device (phone, PC, server).',
175
+ '', '### List Devices',
176
+ '---TOOL:node_list:all---',
177
+ 'Show all paired devices and their status.',
178
+ '', '### Send to Device',
179
+ '---TOOL:node_cmd:node_id|command|data---',
180
+ 'Send a command to a paired device. Commands: camera, screenshot, location, notify, run.',
181
+ 'Example: ---TOOL:node_cmd:node_abc123|notify|Meeting in 5 minutes!---',
182
+ '', '### Device Camera',
183
+ '---TOOL:node_camera:node_id---',
184
+ 'Take a photo from a paired device camera.',
185
+ '', '### Device Location',
186
+ '---TOOL:node_location:node_id---',
187
+ 'Get GPS location of a paired device.');
188
+
172
189
  tools.push('', '### Run JavaScript (Sandboxed)',
173
190
  '---TOOL:js:console.log(2 + 2)---',
174
191
  'Execute JavaScript in a secure VM sandbox. No network, no filesystem, no require. Safe for math, logic, data processing.',
@@ -680,6 +697,57 @@ export class ToolRouter {
680
697
  }
681
698
  break;
682
699
  }
700
+ case 'node_pair': {
701
+ if (!this._engine?.nodeManager) { toolResult = 'Nodes not available'; break; }
702
+ const token = this._engine.nodeManager.generatePairToken(agentId, toolArg || 'Device');
703
+ toolResult = '๐Ÿ“ก Pairing code: *' + token + '*\n\nEnter this code on the device to pair.\nExpires in 5 minutes.\n\nConnect to: ws://<server>:9501/nodes';
704
+ break;
705
+ }
706
+ case 'node_list': {
707
+ if (!this._engine?.nodeManager) { toolResult = 'Nodes not available'; break; }
708
+ const nodes = this._engine.nodeManager.list(agentId);
709
+ if (nodes.length === 0) { toolResult = 'No paired devices. Use node_pair to add one.'; break; }
710
+ toolResult = nodes.map(n =>
711
+ (n.status === 'online' ? '๐ŸŸข' : '๐Ÿ”ด') + ' ' + n.name + ' (' + n.type + ')\n ID: ' + n.id + '\n Platform: ' + n.platform + '\n Last seen: ' + (n.lastSeen || 'never')
712
+ ).join('\n\n');
713
+ break;
714
+ }
715
+ case 'node_cmd': {
716
+ if (!this._engine?.nodeManager) { toolResult = 'Nodes not available'; break; }
717
+ try {
718
+ const parts = toolArg.split('|').map(p => p.trim());
719
+ const [nodeId, command, ...dataParts] = parts;
720
+ const data = dataParts.join('|');
721
+
722
+ if (command === 'notify') {
723
+ await this._engine.nodeManager.notify(nodeId, 'Squidclaw', data);
724
+ toolResult = '๐Ÿ“จ Notification sent to device';
725
+ } else if (command === 'run') {
726
+ const result = await this._engine.nodeManager.run(nodeId, data);
727
+ toolResult = 'Output: ' + JSON.stringify(result);
728
+ } else {
729
+ const result = await this._engine.nodeManager.sendCommand(nodeId, command, { data });
730
+ toolResult = JSON.stringify(result);
731
+ }
732
+ } catch (err) { toolResult = 'Failed: ' + err.message; }
733
+ break;
734
+ }
735
+ case 'node_camera': {
736
+ if (!this._engine?.nodeManager) { toolResult = 'Nodes not available'; break; }
737
+ try {
738
+ const result = await this._engine.nodeManager.camera(toolArg.trim());
739
+ toolResult = 'Camera captured: ' + JSON.stringify(result);
740
+ } catch (err) { toolResult = 'Failed: ' + err.message; }
741
+ break;
742
+ }
743
+ case 'node_location': {
744
+ if (!this._engine?.nodeManager) { toolResult = 'Nodes not available'; break; }
745
+ try {
746
+ const result = await this._engine.nodeManager.location(toolArg.trim());
747
+ toolResult = 'Location: ' + JSON.stringify(result);
748
+ } catch (err) { toolResult = 'Failed: ' + err.message; }
749
+ break;
750
+ }
683
751
  case 'sandbox_info': {
684
752
  if (this._engine?.sandbox) {
685
753
  const stats = this._engine.sandbox.getStats();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "๐Ÿฆ‘ AI agent platform โ€” human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {
@@ -56,6 +56,7 @@
56
56
  "qrcode-terminal": "^0.12.0",
57
57
  "sharp": "^0.34.5",
58
58
  "undici": "^7.22.0",
59
+ "ws": "^8.19.0",
59
60
  "yaml": "^2.8.2",
60
61
  "zod": "^4.3.6"
61
62
  }