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/bin/wispy.mjs +293 -0
- package/core/audit.mjs +322 -0
- package/core/cron.mjs +28 -16
- package/core/engine.mjs +332 -12
- package/core/index.mjs +5 -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 +353 -0
- package/core/tools.mjs +60 -0
- package/lib/wispy-repl.mjs +154 -0
- package/package.json +1 -1
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)
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
//
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|