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/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) 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
+ }
92
109
 
93
110
  // Get or create session
94
111
  let session;
95
- if (sessionId) {
96
- session = this.sessions.get(sessionId) ?? await this.sessions.load(sessionId);
97
- if (!session) {
98
- // 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 {
99
119
  session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
100
120
  }
101
- } else {
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
- 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
+ }
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
- // Run agentic loop
127
- 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(() => {});
128
177
 
129
178
  // Add assistant message to session
130
- this.sessions.addMessage(session.id, { role: "assistant", content: responseText });
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
- 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
+ }
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
+ }