wispy-cli 0.8.0 → 1.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.
package/core/engine.mjs CHANGED
@@ -20,6 +20,9 @@ import { ToolRegistry } from "./tools.mjs";
20
20
  import { SessionManager } from "./session.mjs";
21
21
  import { MCPManager, ensureDefaultMcpConfig } from "./mcp.mjs";
22
22
  import { MemoryManager } from "./memory.mjs";
23
+ import { SubAgentManager } from "./subagents.mjs";
24
+ import { PermissionManager } from "./permissions.mjs";
25
+ import { AuditLog, EVENT_TYPES } from "./audit.mjs";
23
26
 
24
27
  const MAX_TOOL_ROUNDS = 10;
25
28
  const MAX_CONTEXT_CHARS = 40_000;
@@ -32,6 +35,9 @@ export class WispyEngine {
32
35
  this.sessions = new SessionManager();
33
36
  this.mcpManager = new MCPManager(config.mcpConfigPath ?? MCP_CONFIG_PATH);
34
37
  this.memory = new MemoryManager(WISPY_DIR);
38
+ this.subagents = new SubAgentManager(this, this.sessions);
39
+ this.permissions = new PermissionManager(config.permissions ?? {});
40
+ this.audit = new AuditLog(WISPY_DIR);
35
41
  this._initialized = false;
36
42
  this._activeWorkstream = config.workstream
37
43
  ?? process.env.WISPY_WORKSTREAM
@@ -61,6 +67,9 @@ export class WispyEngine {
61
67
  // Register memory tools
62
68
  this._registerMemoryTools();
63
69
 
70
+ // Register node tools
71
+ this._registerNodeTools();
72
+
64
73
  // Initialize MCP
65
74
  if (!opts.skipMcp) {
66
75
  await ensureDefaultMcpConfig(this.mcpManager.configPath);
@@ -86,22 +95,40 @@ export class WispyEngine {
86
95
  * @returns {object} { role: "assistant", content: string, usage? }
87
96
  */
88
97
  async processMessage(sessionId, userMessage, opts = {}) {
89
- if (!this._initialized) await this.init();
98
+ if (!this._initialized) {
99
+ const initResult = await this.init(opts);
100
+ if (!initResult && !opts.allowNoProvider) {
101
+ return {
102
+ role: "assistant",
103
+ content: "⚠️ No AI provider configured. Set GOOGLE_AI_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, or another provider key.",
104
+ sessionId: null,
105
+ error: "NO_PROVIDER",
106
+ };
107
+ }
108
+ }
90
109
 
91
110
  // Get or create session
92
111
  let session;
93
- if (sessionId) {
94
- session = this.sessions.get(sessionId) ?? await this.sessions.load(sessionId);
95
- if (!session) {
96
- // Create new session with given ID context
112
+ try {
113
+ if (sessionId) {
114
+ session = this.sessions.get(sessionId) ?? await this.sessions.load(sessionId);
115
+ if (!session) {
116
+ session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
117
+ }
118
+ } else {
97
119
  session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
98
120
  }
99
- } else {
121
+ } catch (err) {
100
122
  session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
101
123
  }
102
124
 
103
125
  // Build messages array for the provider
104
- const systemPrompt = opts.systemPrompt ?? await this._buildSystemPrompt(userMessage);
126
+ let systemPrompt;
127
+ try {
128
+ systemPrompt = opts.systemPrompt ?? await this._buildSystemPrompt(userMessage);
129
+ } catch {
130
+ systemPrompt = "You are Wispy 🌿 — a helpful AI assistant in the terminal.";
131
+ }
105
132
 
106
133
  // Initialize messages with system prompt if empty
107
134
  let messages;
@@ -121,11 +148,37 @@ export class WispyEngine {
121
148
  messages.push({ role: "user", content: userMessage });
122
149
  this.sessions.addMessage(session.id, { role: "user", content: userMessage });
123
150
 
124
- // Run agentic loop
125
- const responseText = await this._agentLoop(messages, session, opts);
151
+ // Audit: log incoming message
152
+ this.audit.log({
153
+ type: EVENT_TYPES.MESSAGE_RECEIVED,
154
+ sessionId: session.id,
155
+ content: userMessage.slice(0, 500),
156
+ }).catch(() => {});
157
+
158
+ // Run agentic loop with error handling
159
+ let responseText;
160
+ try {
161
+ responseText = await this._agentLoop(messages, session, opts);
162
+ } catch (err) {
163
+ responseText = this._friendlyError(err);
164
+ this.audit.log({
165
+ type: EVENT_TYPES.ERROR,
166
+ sessionId: session.id,
167
+ message: err.message,
168
+ }).catch(() => {});
169
+ }
170
+
171
+ // Audit: log outgoing response
172
+ this.audit.log({
173
+ type: EVENT_TYPES.MESSAGE_SENT,
174
+ sessionId: session.id,
175
+ content: responseText.slice(0, 500),
176
+ }).catch(() => {});
126
177
 
127
178
  // Add assistant message to session
128
- this.sessions.addMessage(session.id, { role: "assistant", content: responseText });
179
+ try {
180
+ this.sessions.addMessage(session.id, { role: "assistant", content: responseText });
181
+ } catch { /* ignore */ }
129
182
 
130
183
  // Save session (async, don't await unless needed)
131
184
  if (!opts.noSave) {
@@ -136,10 +189,36 @@ export class WispyEngine {
136
189
  role: "assistant",
137
190
  content: responseText,
138
191
  sessionId: session.id,
139
- usage: { ...this.providers.sessionTokens },
192
+ usage: { ...(this.providers.sessionTokens ?? {}) },
140
193
  };
141
194
  }
142
195
 
196
+ /**
197
+ * Convert a provider error to a user-friendly message.
198
+ */
199
+ _friendlyError(err) {
200
+ const msg = err?.message ?? String(err);
201
+ if (msg.includes("401") || msg.toLowerCase().includes("api key") || msg.toLowerCase().includes("unauthorized")) {
202
+ return "⚠️ Invalid or missing API key. Check your provider credentials.";
203
+ }
204
+ if (msg.includes("429") || msg.toLowerCase().includes("rate limit")) {
205
+ return "⚠️ Rate limit reached. Please wait a moment and try again.";
206
+ }
207
+ if (msg.includes("timeout") || msg.toLowerCase().includes("timed out")) {
208
+ return "⚠️ Request timed out. The AI provider took too long to respond.";
209
+ }
210
+ if (msg.toLowerCase().includes("network") || msg.includes("ENOTFOUND") || msg.includes("ECONNREFUSED")) {
211
+ return "⚠️ Network error. Check your internet connection and try again.";
212
+ }
213
+ if (msg.toLowerCase().includes("no provider") || msg.toLowerCase().includes("not configured")) {
214
+ return "⚠️ No AI provider configured. Set an API key to get started.";
215
+ }
216
+ if (process.env.WISPY_DEBUG) {
217
+ return `⚠️ Error: ${msg}`;
218
+ }
219
+ return `⚠️ Something went wrong. Run with WISPY_DEBUG=1 for details. (${msg.slice(0, 80)})`;
220
+ }
221
+
143
222
  /**
144
223
  * Process tool calls manually.
145
224
  * @param {Array} toolCalls - Array of { name, args }
@@ -178,7 +257,22 @@ export class WispyEngine {
178
257
  for (const call of result.calls) {
179
258
  if (opts.onToolCall) opts.onToolCall(call.name, call.args);
180
259
 
181
- const toolResult = await this._executeTool(call.name, call.args, messages, session, opts);
260
+ let toolResult;
261
+ try {
262
+ // Enforce 60s timeout on individual tool calls
263
+ const TOOL_TIMEOUT_MS = opts.toolTimeoutMs ?? 60_000;
264
+ toolResult = await Promise.race([
265
+ this._executeTool(call.name, call.args, messages, session, opts),
266
+ new Promise((_, reject) =>
267
+ setTimeout(() => reject(new Error(`Tool '${call.name}' timed out after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)
268
+ ),
269
+ ]);
270
+ } catch (err) {
271
+ toolResult = { error: err.message, success: false };
272
+ if (process.env.WISPY_DEBUG) {
273
+ console.error(`[wispy] Tool '${call.name}' error: ${err.message}`);
274
+ }
275
+ }
182
276
 
183
277
  if (opts.onToolResult) opts.onToolResult(call.name, toolResult);
184
278
 
@@ -198,6 +292,71 @@ export class WispyEngine {
198
292
  * Execute a tool, handling engine-level tools (spawn_agent, etc.) specially.
199
293
  */
200
294
  async _executeTool(name, args, messages, session, opts) {
295
+ // ── Permission check ─────────────────────────────────────────────────────
296
+ const permResult = await this.permissions.check(name, args, { session });
297
+ const permLevel = permResult.level ?? "auto";
298
+
299
+ if (!permResult.allowed) {
300
+ const reason = permResult.reason ?? "Permission denied";
301
+ this.audit.log({
302
+ type: EVENT_TYPES.APPROVAL_DENIED,
303
+ sessionId: session?.id,
304
+ tool: name,
305
+ args,
306
+ }).catch(() => {});
307
+ return { success: false, error: reason, denied: true };
308
+ }
309
+
310
+ if (permResult.needsApproval && permResult.approved) {
311
+ this.audit.log({
312
+ type: EVENT_TYPES.APPROVAL_GRANTED,
313
+ sessionId: session?.id,
314
+ tool: name,
315
+ args,
316
+ }).catch(() => {});
317
+ }
318
+
319
+ // ── Audit: log tool call ──────────────────────────────────────────────────
320
+ const callStart = Date.now();
321
+ this.audit.log({
322
+ type: EVENT_TYPES.TOOL_CALL,
323
+ sessionId: session?.id,
324
+ tool: name,
325
+ args,
326
+ permissionLevel: permLevel,
327
+ }).catch(() => {});
328
+
329
+ // ── Execute ───────────────────────────────────────────────────────────────
330
+ let result;
331
+ try {
332
+ result = await this._executeToolInner(name, args, messages, session, opts);
333
+ } catch (err) {
334
+ this.audit.log({
335
+ type: EVENT_TYPES.ERROR,
336
+ sessionId: session?.id,
337
+ tool: name,
338
+ message: err.message,
339
+ duration: Date.now() - callStart,
340
+ }).catch(() => {});
341
+ throw err;
342
+ }
343
+
344
+ // ── Audit: log tool result ────────────────────────────────────────────────
345
+ this.audit.log({
346
+ type: EVENT_TYPES.TOOL_RESULT,
347
+ sessionId: session?.id,
348
+ tool: name,
349
+ result: JSON.stringify(result).slice(0, 500),
350
+ duration: Date.now() - callStart,
351
+ }).catch(() => {});
352
+
353
+ return result;
354
+ }
355
+
356
+ /**
357
+ * Internal tool dispatch (after permissions/audit).
358
+ */
359
+ async _executeToolInner(name, args, messages, session, opts) {
201
360
  // Engine-level tools that need conversation context
202
361
  switch (name) {
203
362
  case "spawn_agent":
@@ -226,6 +385,24 @@ export class WispyEngine {
226
385
  return this._toolMemoryAppend(args);
227
386
  case "memory_delete":
228
387
  return this._toolMemoryDelete(args);
388
+ // Sub-agent tools (v0.9)
389
+ case "spawn_subagent":
390
+ return this._toolSpawnSubagent(args, opts);
391
+ case "list_subagents":
392
+ return this._toolListSubagents();
393
+ case "get_subagent_result":
394
+ return this._toolGetSubagentResult(args);
395
+ case "kill_subagent":
396
+ return this._toolKillSubagent(args);
397
+ case "steer_subagent":
398
+ return this._toolSteerSubagent(args);
399
+ // Node tools (v1.1)
400
+ case "node_list":
401
+ return this._toolNodeList();
402
+ case "node_status":
403
+ return this._toolNodeStatus();
404
+ case "node_execute":
405
+ return this._toolNodeExecute(args);
229
406
  default:
230
407
  return this.tools.execute(name, args);
231
408
  }
@@ -438,6 +615,95 @@ export class WispyEngine {
438
615
  return { success: true, iterations: MAX_ITERATIONS, result: lastResult, verified: false, message: "Max iterations reached" };
439
616
  }
440
617
 
618
+ // ── Sub-agent tools (v0.9) ──────────────────────────────────────────────────
619
+
620
+ async _toolSpawnSubagent(args, parentOpts = {}) {
621
+ const agent = await this.subagents.spawn({
622
+ task: args.task,
623
+ label: args.label,
624
+ model: args.model ?? null,
625
+ timeout: args.timeout_seconds ?? 300,
626
+ workstream: this._activeWorkstream,
627
+ onNotify: parentOpts?.onSubagentNotify ?? null,
628
+ });
629
+
630
+ this.audit.log({
631
+ type: EVENT_TYPES.SUBAGENT_SPAWNED,
632
+ agentId: agent.id,
633
+ label: agent.label,
634
+ task: args.task?.slice(0, 200),
635
+ }).catch(() => {});
636
+
637
+ return {
638
+ success: true,
639
+ id: agent.id,
640
+ label: agent.label,
641
+ status: agent.status,
642
+ message: `Sub-agent '${agent.label}' spawned (id: ${agent.id}). Use get_subagent_result to check when done.`,
643
+ };
644
+ }
645
+
646
+ async _toolListSubagents() {
647
+ const inMemory = this.subagents.list();
648
+ const history = await this.subagents.listHistory(20);
649
+
650
+ // Merge: in-memory takes precedence
651
+ const inMemoryIds = new Set(inMemory.map(a => a.id));
652
+ const combined = [
653
+ ...inMemory.map(a => a.toJSON()),
654
+ ...history.filter(h => !inMemoryIds.has(h.id)),
655
+ ].slice(0, 30);
656
+
657
+ return {
658
+ success: true,
659
+ agents: combined.map(a => ({
660
+ id: a.id,
661
+ label: a.label,
662
+ status: a.status,
663
+ task: a.task?.slice(0, 80),
664
+ model: a.model,
665
+ createdAt: a.createdAt,
666
+ completedAt: a.completedAt,
667
+ })),
668
+ total: combined.length,
669
+ };
670
+ }
671
+
672
+ async _toolGetSubagentResult(args) {
673
+ const inMemory = this.subagents.get(args.id);
674
+ if (inMemory) {
675
+ return {
676
+ success: true,
677
+ ...inMemory.toJSON(),
678
+ result_preview: inMemory.result?.slice(0, 500),
679
+ };
680
+ }
681
+
682
+ // Try disk
683
+ const disk = await this.subagents.loadFromDisk(args.id);
684
+ if (disk) {
685
+ return { success: true, ...disk, result_preview: disk.result?.slice(0, 500) };
686
+ }
687
+
688
+ return { success: false, error: `Sub-agent not found: ${args.id}` };
689
+ }
690
+
691
+ _toolKillSubagent(args) {
692
+ const agent = this.subagents.get(args.id);
693
+ if (!agent) return { success: false, error: `Sub-agent not found: ${args.id}` };
694
+ this.subagents.kill(args.id);
695
+ return { success: true, id: args.id, message: `Sub-agent '${agent.label}' killed.` };
696
+ }
697
+
698
+ _toolSteerSubagent(args) {
699
+ try {
700
+ this.subagents.steer(args.id, args.message);
701
+ return { success: true, id: args.id, message: `Guidance sent to sub-agent.` };
702
+ } catch (err) {
703
+ return { success: false, error: err.message };
704
+ }
705
+ }
706
+
441
707
  // ── Context optimization ─────────────────────────────────────────────────────
442
708
 
443
709
  _optimizeContext(messages, maxTokens = 30_000) {
@@ -685,6 +951,60 @@ export class WispyEngine {
685
951
  }
686
952
  }
687
953
 
954
+ // ── Node tools (v1.1) ────────────────────────────────────────────────────────
955
+
956
+ _registerNodeTools() {
957
+ const nodeTools = [
958
+ {
959
+ name: "node_list",
960
+ description: "List all registered remote nodes.",
961
+ parameters: { type: "object", properties: {}, required: [] },
962
+ },
963
+ {
964
+ name: "node_status",
965
+ description: "Ping all nodes and check their status.",
966
+ parameters: { type: "object", properties: {}, required: [] },
967
+ },
968
+ {
969
+ name: "node_execute",
970
+ description: "Execute a command on a specific remote node.",
971
+ parameters: {
972
+ type: "object",
973
+ properties: {
974
+ node_id: { type: "string", description: "Node ID to execute on" },
975
+ command: { type: "object", description: "Command to execute on the node" },
976
+ },
977
+ required: ["node_id", "command"],
978
+ },
979
+ },
980
+ ];
981
+ for (const tool of nodeTools) {
982
+ this.tools._definitions.set(tool.name, tool);
983
+ }
984
+ }
985
+
986
+ async _toolNodeList() {
987
+ if (!this.nodes) return { success: false, error: "Node manager not initialized" };
988
+ const list = await this.nodes.list();
989
+ return { success: true, nodes: list };
990
+ }
991
+
992
+ async _toolNodeStatus() {
993
+ if (!this.nodes) return { success: false, error: "Node manager not initialized" };
994
+ const statuses = await this.nodes.status();
995
+ return { success: true, statuses };
996
+ }
997
+
998
+ async _toolNodeExecute(args) {
999
+ if (!this.nodes) return { success: false, error: "Node manager not initialized" };
1000
+ try {
1001
+ const result = await this.nodes.send(args.node_id, args.command);
1002
+ return { success: true, result };
1003
+ } catch (err) {
1004
+ return { success: false, error: err.message };
1005
+ }
1006
+ }
1007
+
688
1008
  // ── Cleanup ──────────────────────────────────────────────────────────────────
689
1009
 
690
1010
  destroy() {
package/core/index.mjs CHANGED
@@ -12,3 +12,8 @@ export { MCPClient, MCPManager, ensureDefaultMcpConfig } from "./mcp.mjs";
12
12
  export { loadConfig, saveConfig, detectProvider, PROVIDERS, WISPY_DIR, MCP_CONFIG_PATH, SESSIONS_DIR, CONVERSATIONS_DIR, MEMORY_DIR } from "./config.mjs";
13
13
  export { MemoryManager } from "./memory.mjs";
14
14
  export { CronManager } from "./cron.mjs";
15
+ export { SubAgentManager, SubAgent } from "./subagents.mjs";
16
+ export { PermissionManager, DEFAULT_POLICIES, BUILT_IN_SCOPES } from "./permissions.mjs";
17
+ export { AuditLog, EVENT_TYPES, getAuditLog } from "./audit.mjs";
18
+ export { WispyServer } from "./server.mjs";
19
+ export { NodeManager, CAPABILITIES } from "./nodes.mjs";
package/core/nodes.mjs ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * core/nodes.mjs — Local Node System for Wispy
3
+ *
4
+ * Allows pairing remote machines as "nodes" with specific capabilities.
5
+ */
6
+
7
+ import path from "node:path";
8
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
9
+ import { randomBytes } from "node:crypto";
10
+
11
+ import { WISPY_DIR } from "./config.mjs";
12
+
13
+ const NODES_FILE = path.join(WISPY_DIR, "nodes.json");
14
+ const PAIR_CODES_FILE = path.join(WISPY_DIR, "pair-codes.json");
15
+ const PAIR_CODE_TTL_MS = 60 * 60 * 1000; // 1 hour
16
+
17
+ export const CAPABILITIES = {
18
+ browser: "Browser automation (Playwright/Puppeteer)",
19
+ filesystem: "Local file access",
20
+ desktop: "Desktop app control",
21
+ camera: "Camera/screenshot",
22
+ notifications: "System notifications",
23
+ clipboard: "Clipboard access",
24
+ };
25
+
26
+ export class NodeManager {
27
+ constructor(wispyDir = WISPY_DIR, server = null) {
28
+ this.wispyDir = wispyDir;
29
+ this.server = server;
30
+ this._nodesFile = path.join(wispyDir, "nodes.json");
31
+ this._pairCodesFile = path.join(wispyDir, "pair-codes.json");
32
+ }
33
+
34
+ // ── Storage ──────────────────────────────────────────────────────────────────
35
+
36
+ async _loadNodes() {
37
+ try {
38
+ return JSON.parse(await readFile(this._nodesFile, "utf8"));
39
+ } catch { return []; }
40
+ }
41
+
42
+ async _saveNodes(nodes) {
43
+ await mkdir(this.wispyDir, { recursive: true });
44
+ await writeFile(this._nodesFile, JSON.stringify(nodes, null, 2) + "\n", "utf8");
45
+ }
46
+
47
+ async _loadPairCodes() {
48
+ try {
49
+ return JSON.parse(await readFile(this._pairCodesFile, "utf8"));
50
+ } catch { return []; }
51
+ }
52
+
53
+ async _savePairCodes(codes) {
54
+ await mkdir(this.wispyDir, { recursive: true });
55
+ await writeFile(this._pairCodesFile, JSON.stringify(codes, null, 2) + "\n", "utf8");
56
+ }
57
+
58
+ // ── Registration ─────────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Register a new node.
62
+ * @param {{ name, capabilities, host, port? }} node
63
+ * @returns {{ id, token }}
64
+ */
65
+ async register(node) {
66
+ const nodes = await this._loadNodes();
67
+ const id = `node-${randomBytes(6).toString("hex")}`;
68
+ const token = randomBytes(32).toString("hex");
69
+ const newNode = {
70
+ id,
71
+ name: node.name ?? id,
72
+ host: node.host ?? "localhost",
73
+ port: node.port ?? 18791,
74
+ token,
75
+ capabilities: node.capabilities ?? [],
76
+ registeredAt: new Date().toISOString(),
77
+ lastSeen: new Date().toISOString(),
78
+ };
79
+ nodes.push(newNode);
80
+ await this._saveNodes(nodes);
81
+ return { id, token };
82
+ }
83
+
84
+ /**
85
+ * Remove a node.
86
+ */
87
+ async remove(id) {
88
+ let nodes = await this._loadNodes();
89
+ const before = nodes.length;
90
+ nodes = nodes.filter(n => n.id !== id);
91
+ if (nodes.length === before) throw new Error(`Node not found: ${id}`);
92
+ await this._saveNodes(nodes);
93
+ }
94
+
95
+ /**
96
+ * List all registered nodes.
97
+ */
98
+ async list() {
99
+ return this._loadNodes();
100
+ }
101
+
102
+ /**
103
+ * Get a specific node.
104
+ */
105
+ async get(id) {
106
+ const nodes = await this._loadNodes();
107
+ return nodes.find(n => n.id === id) ?? null;
108
+ }
109
+
110
+ // ── Pairing ───────────────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Generate a 6-digit pair code (expires in 1 hour).
114
+ */
115
+ async generatePairCode() {
116
+ const code = Math.floor(100000 + Math.random() * 900000).toString();
117
+ const codes = (await this._loadPairCodes()).filter(c =>
118
+ Date.now() - new Date(c.createdAt).getTime() < PAIR_CODE_TTL_MS
119
+ );
120
+ codes.push({
121
+ code,
122
+ createdAt: new Date().toISOString(),
123
+ expiresAt: new Date(Date.now() + PAIR_CODE_TTL_MS).toISOString(),
124
+ });
125
+ await this._savePairCodes(codes);
126
+ return code;
127
+ }
128
+
129
+ /**
130
+ * Confirm pairing with a code.
131
+ * @param {string} code - 6-digit code
132
+ * @param {{ name, capabilities, host, port? }} nodeInfo
133
+ * @returns {{ id, token }}
134
+ */
135
+ async confirmPair(code, nodeInfo) {
136
+ const codes = await this._loadPairCodes();
137
+ const match = codes.find(c =>
138
+ c.code === code &&
139
+ Date.now() - new Date(c.createdAt).getTime() < PAIR_CODE_TTL_MS
140
+ );
141
+ if (!match) throw new Error("Invalid or expired pair code.");
142
+
143
+ // Remove used code
144
+ const remaining = codes.filter(c => c.code !== code);
145
+ await this._savePairCodes(remaining);
146
+
147
+ return this.register(nodeInfo);
148
+ }
149
+
150
+ // ── Capability routing ────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Find a node that supports the given capability.
154
+ * @param {string} capability
155
+ * @returns {Node|null}
156
+ */
157
+ async route(capability) {
158
+ const nodes = await this._loadNodes();
159
+ return nodes.find(n => n.capabilities.includes(capability)) ?? null;
160
+ }
161
+
162
+ // ── Communication ─────────────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * Send a command to a node (HTTP).
166
+ */
167
+ async send(nodeId, command) {
168
+ const node = await this.get(nodeId);
169
+ if (!node) throw new Error(`Node not found: ${nodeId}`);
170
+
171
+ const url = `http://${node.host}:${node.port}/api/execute`;
172
+ const response = await fetch(url, {
173
+ method: "POST",
174
+ headers: {
175
+ "Content-Type": "application/json",
176
+ "Authorization": `Bearer ${node.token}`,
177
+ },
178
+ body: JSON.stringify(command),
179
+ signal: AbortSignal.timeout(30000),
180
+ });
181
+
182
+ if (!response.ok) throw new Error(`Node returned ${response.status}`);
183
+ return response.json();
184
+ }
185
+
186
+ // ── Status ────────────────────────────────────────────────────────────────────
187
+
188
+ /**
189
+ * Ping a node and measure latency.
190
+ */
191
+ async ping(nodeId) {
192
+ const node = await this.get(nodeId);
193
+ if (!node) return { alive: false, latency: null, error: "Node not found" };
194
+
195
+ const start = Date.now();
196
+ try {
197
+ const response = await fetch(`http://${node.host}:${node.port}/api/status`, {
198
+ signal: AbortSignal.timeout(5000),
199
+ headers: { "Authorization": `Bearer ${node.token}` },
200
+ });
201
+ const latency = Date.now() - start;
202
+ if (response.ok) {
203
+ // Update lastSeen
204
+ const nodes = await this._loadNodes();
205
+ const idx = nodes.findIndex(n => n.id === nodeId);
206
+ if (idx !== -1) {
207
+ nodes[idx].lastSeen = new Date().toISOString();
208
+ await this._saveNodes(nodes);
209
+ }
210
+ return { alive: true, latency, nodeId };
211
+ }
212
+ return { alive: false, latency, error: `HTTP ${response.status}`, nodeId };
213
+ } catch (err) {
214
+ return { alive: false, latency: null, error: err.message, nodeId };
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Ping all nodes.
220
+ */
221
+ async status() {
222
+ const nodes = await this._loadNodes();
223
+ return Promise.all(nodes.map(async (node) => {
224
+ const pingResult = await this.ping(node.id);
225
+ return { ...node, ...pingResult };
226
+ }));
227
+ }
228
+ }