wispy-cli 0.9.0 → 1.1.1
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/README.md +488 -190
- package/bin/wispy.mjs +293 -0
- package/core/audit.mjs +322 -0
- package/core/cron.mjs +30 -16
- package/core/engine.mjs +237 -12
- package/core/index.mjs +4 -0
- package/core/nodes.mjs +228 -0
- package/core/permissions.mjs +248 -0
- package/core/server.mjs +522 -0
- package/core/session.mjs +13 -1
- package/core/subagents.mjs +13 -3
- package/lib/wispy-repl.mjs +82 -0
- package/package.json +25 -6
package/core/engine.mjs
CHANGED
|
@@ -21,6 +21,8 @@ import { SessionManager } from "./session.mjs";
|
|
|
21
21
|
import { MCPManager, ensureDefaultMcpConfig } from "./mcp.mjs";
|
|
22
22
|
import { MemoryManager } from "./memory.mjs";
|
|
23
23
|
import { SubAgentManager } from "./subagents.mjs";
|
|
24
|
+
import { PermissionManager } from "./permissions.mjs";
|
|
25
|
+
import { AuditLog, EVENT_TYPES } from "./audit.mjs";
|
|
24
26
|
|
|
25
27
|
const MAX_TOOL_ROUNDS = 10;
|
|
26
28
|
const MAX_CONTEXT_CHARS = 40_000;
|
|
@@ -34,6 +36,8 @@ export class WispyEngine {
|
|
|
34
36
|
this.mcpManager = new MCPManager(config.mcpConfigPath ?? MCP_CONFIG_PATH);
|
|
35
37
|
this.memory = new MemoryManager(WISPY_DIR);
|
|
36
38
|
this.subagents = new SubAgentManager(this, this.sessions);
|
|
39
|
+
this.permissions = new PermissionManager(config.permissions ?? {});
|
|
40
|
+
this.audit = new AuditLog(WISPY_DIR);
|
|
37
41
|
this._initialized = false;
|
|
38
42
|
this._activeWorkstream = config.workstream
|
|
39
43
|
?? process.env.WISPY_WORKSTREAM
|
|
@@ -63,6 +67,9 @@ export class WispyEngine {
|
|
|
63
67
|
// Register memory tools
|
|
64
68
|
this._registerMemoryTools();
|
|
65
69
|
|
|
70
|
+
// Register node tools
|
|
71
|
+
this._registerNodeTools();
|
|
72
|
+
|
|
66
73
|
// Initialize MCP
|
|
67
74
|
if (!opts.skipMcp) {
|
|
68
75
|
await ensureDefaultMcpConfig(this.mcpManager.configPath);
|
|
@@ -88,22 +95,40 @@ export class WispyEngine {
|
|
|
88
95
|
* @returns {object} { role: "assistant", content: string, usage? }
|
|
89
96
|
*/
|
|
90
97
|
async processMessage(sessionId, userMessage, opts = {}) {
|
|
91
|
-
if (!this._initialized)
|
|
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
|
+
}
|
|
92
109
|
|
|
93
110
|
// Get or create session
|
|
94
111
|
let session;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 {
|
|
99
119
|
session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
|
|
100
120
|
}
|
|
101
|
-
}
|
|
121
|
+
} catch (err) {
|
|
102
122
|
session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
|
|
103
123
|
}
|
|
104
124
|
|
|
105
125
|
// Build messages array for the provider
|
|
106
|
-
|
|
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
|
+
}
|
|
107
132
|
|
|
108
133
|
// Initialize messages with system prompt if empty
|
|
109
134
|
let messages;
|
|
@@ -123,11 +148,37 @@ export class WispyEngine {
|
|
|
123
148
|
messages.push({ role: "user", content: userMessage });
|
|
124
149
|
this.sessions.addMessage(session.id, { role: "user", content: userMessage });
|
|
125
150
|
|
|
126
|
-
//
|
|
127
|
-
|
|
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(() => {});
|
|
128
177
|
|
|
129
178
|
// Add assistant message to session
|
|
130
|
-
|
|
179
|
+
try {
|
|
180
|
+
this.sessions.addMessage(session.id, { role: "assistant", content: responseText });
|
|
181
|
+
} catch { /* ignore */ }
|
|
131
182
|
|
|
132
183
|
// Save session (async, don't await unless needed)
|
|
133
184
|
if (!opts.noSave) {
|
|
@@ -138,10 +189,36 @@ export class WispyEngine {
|
|
|
138
189
|
role: "assistant",
|
|
139
190
|
content: responseText,
|
|
140
191
|
sessionId: session.id,
|
|
141
|
-
usage: { ...this.providers.sessionTokens },
|
|
192
|
+
usage: { ...(this.providers.sessionTokens ?? {}) },
|
|
142
193
|
};
|
|
143
194
|
}
|
|
144
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
|
+
|
|
145
222
|
/**
|
|
146
223
|
* Process tool calls manually.
|
|
147
224
|
* @param {Array} toolCalls - Array of { name, args }
|
|
@@ -180,7 +257,22 @@ export class WispyEngine {
|
|
|
180
257
|
for (const call of result.calls) {
|
|
181
258
|
if (opts.onToolCall) opts.onToolCall(call.name, call.args);
|
|
182
259
|
|
|
183
|
-
|
|
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
|
+
}
|
|
184
276
|
|
|
185
277
|
if (opts.onToolResult) opts.onToolResult(call.name, toolResult);
|
|
186
278
|
|
|
@@ -200,6 +292,71 @@ export class WispyEngine {
|
|
|
200
292
|
* Execute a tool, handling engine-level tools (spawn_agent, etc.) specially.
|
|
201
293
|
*/
|
|
202
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) {
|
|
203
360
|
// Engine-level tools that need conversation context
|
|
204
361
|
switch (name) {
|
|
205
362
|
case "spawn_agent":
|
|
@@ -239,6 +396,13 @@ export class WispyEngine {
|
|
|
239
396
|
return this._toolKillSubagent(args);
|
|
240
397
|
case "steer_subagent":
|
|
241
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);
|
|
242
406
|
default:
|
|
243
407
|
return this.tools.execute(name, args);
|
|
244
408
|
}
|
|
@@ -463,6 +627,13 @@ export class WispyEngine {
|
|
|
463
627
|
onNotify: parentOpts?.onSubagentNotify ?? null,
|
|
464
628
|
});
|
|
465
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
|
+
|
|
466
637
|
return {
|
|
467
638
|
success: true,
|
|
468
639
|
id: agent.id,
|
|
@@ -780,6 +951,60 @@ export class WispyEngine {
|
|
|
780
951
|
}
|
|
781
952
|
}
|
|
782
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
|
+
|
|
783
1008
|
// ── Cleanup ──────────────────────────────────────────────────────────────────
|
|
784
1009
|
|
|
785
1010
|
destroy() {
|
package/core/index.mjs
CHANGED
|
@@ -13,3 +13,7 @@ export { loadConfig, saveConfig, detectProvider, PROVIDERS, WISPY_DIR, MCP_CONFI
|
|
|
13
13
|
export { MemoryManager } from "./memory.mjs";
|
|
14
14
|
export { CronManager } from "./cron.mjs";
|
|
15
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
|
+
}
|