wispy-cli 0.9.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.
@@ -0,0 +1,522 @@
1
+ /**
2
+ * core/server.mjs — Cloud/Server Mode for Wispy
3
+ *
4
+ * HTTP REST API + WebSocket streaming server.
5
+ * Default: localhost:18790
6
+ */
7
+
8
+ import { createServer } from "node:http";
9
+ import path from "node:path";
10
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
11
+ import { randomBytes, createHmac } from "node:crypto";
12
+
13
+ import { WISPY_DIR } from "./config.mjs";
14
+
15
+ const SERVER_CONFIG_FILE = path.join(WISPY_DIR, "server.json");
16
+ const DEFAULT_PORT = 18790;
17
+ const DEFAULT_HOST = "127.0.0.1";
18
+
19
+ // ── Helpers ───────────────────────────────────────────────────────────────────
20
+
21
+ function generateToken() {
22
+ return randomBytes(32).toString("hex");
23
+ }
24
+
25
+ async function loadOrCreateServerConfig() {
26
+ try {
27
+ const raw = await readFile(SERVER_CONFIG_FILE, "utf8");
28
+ const cfg = JSON.parse(raw);
29
+ if (!cfg.token) {
30
+ cfg.token = generateToken();
31
+ await saveServerConfig(cfg);
32
+ }
33
+ return cfg;
34
+ } catch {
35
+ const cfg = { token: generateToken(), createdAt: new Date().toISOString() };
36
+ await saveServerConfig(cfg);
37
+ return cfg;
38
+ }
39
+ }
40
+
41
+ async function saveServerConfig(cfg) {
42
+ await mkdir(WISPY_DIR, { recursive: true });
43
+ await writeFile(SERVER_CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", "utf8");
44
+ }
45
+
46
+ function jsonResponse(res, status, data) {
47
+ const body = JSON.stringify(data);
48
+ res.writeHead(status, {
49
+ "Content-Type": "application/json",
50
+ "Content-Length": Buffer.byteLength(body),
51
+ "Access-Control-Allow-Origin": "*",
52
+ "Access-Control-Allow-Headers": "Authorization, Content-Type",
53
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
54
+ });
55
+ res.end(body);
56
+ }
57
+
58
+ function errorResponse(res, status, message) {
59
+ jsonResponse(res, status, { error: message });
60
+ }
61
+
62
+ async function readBody(req) {
63
+ return new Promise((resolve, reject) => {
64
+ let body = "";
65
+ req.on("data", (chunk) => (body += chunk));
66
+ req.on("end", () => {
67
+ try { resolve(body ? JSON.parse(body) : {}); }
68
+ catch (err) { reject(new Error("Invalid JSON body")); }
69
+ });
70
+ req.on("error", reject);
71
+ });
72
+ }
73
+
74
+ // ── WispyServer ───────────────────────────────────────────────────────────────
75
+
76
+ export class WispyServer {
77
+ constructor(engine, opts = {}) {
78
+ this.engine = engine;
79
+ this.opts = opts;
80
+ this._port = opts.port ?? parseInt(process.env.WISPY_PORT ?? DEFAULT_PORT);
81
+ this._host = opts.host ?? process.env.WISPY_HOST ?? DEFAULT_HOST;
82
+ this._token = opts.token ?? process.env.WISPY_SERVER_TOKEN ?? null;
83
+ this._server = null;
84
+ this._wsClients = new Map(); // id → { ws, req }
85
+ this._startTime = null;
86
+ }
87
+
88
+ // ── Lifecycle ─────────────────────────────────────────────────────────────────
89
+
90
+ async start() {
91
+ // Load or create auth token
92
+ if (!this._token) {
93
+ const cfg = await loadOrCreateServerConfig();
94
+ this._token = cfg.token;
95
+ }
96
+
97
+ this._startTime = Date.now();
98
+ this._server = createServer(this._handleRequest.bind(this));
99
+
100
+ // WebSocket upgrade
101
+ this._server.on("upgrade", this._handleUpgrade.bind(this));
102
+
103
+ await new Promise((resolve, reject) => {
104
+ this._server.listen(this._port, this._host, () => resolve());
105
+ this._server.on("error", reject);
106
+ });
107
+
108
+ console.log(`🌿 Wispy API server running on http://${this._host}:${this._port}`);
109
+ console.log(` Token: ${this._token.slice(0, 8)}...`);
110
+ console.log(` WebSocket: ws://${this._host}:${this._port}/ws`);
111
+ }
112
+
113
+ stop() {
114
+ if (this._server) {
115
+ this._server.close();
116
+ this._server = null;
117
+ }
118
+ // Close all WebSocket connections
119
+ for (const [id, client] of this._wsClients) {
120
+ try { client.socket.destroy(); } catch {}
121
+ }
122
+ this._wsClients.clear();
123
+ }
124
+
125
+ get address() {
126
+ return `http://${this._host}:${this._port}`;
127
+ }
128
+
129
+ // ── Auth ──────────────────────────────────────────────────────────────────────
130
+
131
+ _authenticate(req) {
132
+ if (!this._token) return true; // No auth configured
133
+ const authHeader = req.headers.authorization ?? "";
134
+ if (authHeader.startsWith("Bearer ")) {
135
+ return authHeader.slice(7).trim() === this._token;
136
+ }
137
+ // Also allow query param ?token=xxx
138
+ const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
139
+ const qToken = url.searchParams.get("token");
140
+ return qToken === this._token;
141
+ }
142
+
143
+ // ── HTTP Handler ──────────────────────────────────────────────────────────────
144
+
145
+ async _handleRequest(req, res) {
146
+ // CORS preflight
147
+ if (req.method === "OPTIONS") {
148
+ res.writeHead(204, {
149
+ "Access-Control-Allow-Origin": "*",
150
+ "Access-Control-Allow-Headers": "Authorization, Content-Type",
151
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
152
+ });
153
+ res.end();
154
+ return;
155
+ }
156
+
157
+ const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
158
+ const pathname = url.pathname;
159
+
160
+ // Auth check (skip /api/status for health checks)
161
+ if (pathname !== "/api/status" && !this._authenticate(req)) {
162
+ return errorResponse(res, 401, "Unauthorized");
163
+ }
164
+
165
+ try {
166
+ await this._route(req, res, pathname, url);
167
+ } catch (err) {
168
+ errorResponse(res, 500, err.message);
169
+ }
170
+ }
171
+
172
+ async _route(req, res, pathname, url) {
173
+ const method = req.method;
174
+
175
+ // ── Status ──────────────────────────────────────────────────────────────
176
+ if (pathname === "/api/status" && method === "GET") {
177
+ const sessions = this.engine.sessions.list?.() ?? [];
178
+ return jsonResponse(res, 200, {
179
+ status: "running",
180
+ uptime: Math.floor((Date.now() - this._startTime) / 1000),
181
+ provider: this.engine.provider,
182
+ model: this.engine.model,
183
+ sessions: sessions.length,
184
+ version: "1.1.0",
185
+ });
186
+ }
187
+
188
+ // ── Chat ────────────────────────────────────────────────────────────────
189
+ if (pathname === "/api/chat" && method === "POST") {
190
+ const body = await readBody(req);
191
+ const { message, sessionId, workstream } = body;
192
+ if (!message) return errorResponse(res, 400, "message required");
193
+
194
+ const result = await this.engine.processMessage(sessionId ?? null, message, {
195
+ workstream,
196
+ noSave: false,
197
+ });
198
+ return jsonResponse(res, 200, result);
199
+ }
200
+
201
+ // ── Sessions ────────────────────────────────────────────────────────────
202
+ if (pathname === "/api/sessions" && method === "GET") {
203
+ const sessions = await this._listSessions();
204
+ return jsonResponse(res, 200, { sessions });
205
+ }
206
+
207
+ if (pathname.match(/^\/api\/sessions\/([^/]+)$/) && method === "GET") {
208
+ const id = pathname.split("/")[3];
209
+ const session = await this._getSession(id);
210
+ if (!session) return errorResponse(res, 404, "Session not found");
211
+ return jsonResponse(res, 200, session);
212
+ }
213
+
214
+ if (pathname.match(/^\/api\/sessions\/([^/]+)$/) && method === "DELETE") {
215
+ const id = pathname.split("/")[3];
216
+ await this._deleteSession(id);
217
+ return jsonResponse(res, 200, { success: true });
218
+ }
219
+
220
+ if (pathname.match(/^\/api\/sessions\/([^/]+)\/clear$/) && method === "POST") {
221
+ const id = pathname.split("/")[3];
222
+ await this._clearSession(id);
223
+ return jsonResponse(res, 200, { success: true });
224
+ }
225
+
226
+ // ── Memory ──────────────────────────────────────────────────────────────
227
+ if (pathname === "/api/memory" && method === "GET") {
228
+ const memories = await this.engine.memory.list();
229
+ return jsonResponse(res, 200, { memories });
230
+ }
231
+
232
+ if (pathname === "/api/memory/search" && method === "POST") {
233
+ const body = await readBody(req);
234
+ const results = await this.engine.memory.search(body.query ?? "", { limit: body.limit ?? 10 });
235
+ return jsonResponse(res, 200, { results });
236
+ }
237
+
238
+ if (pathname === "/api/memory" && method === "POST") {
239
+ const body = await readBody(req);
240
+ await this.engine.memory.save(body.key, body.content, { title: body.title });
241
+ return jsonResponse(res, 200, { success: true, key: body.key });
242
+ }
243
+
244
+ // ── Cron ────────────────────────────────────────────────────────────────
245
+ if (this.engine.cron) {
246
+ if (pathname === "/api/cron" && method === "GET") {
247
+ return jsonResponse(res, 200, { jobs: this.engine.cron.list() });
248
+ }
249
+
250
+ if (pathname === "/api/cron" && method === "POST") {
251
+ const body = await readBody(req);
252
+ const job = await this.engine.cron.add(body);
253
+ return jsonResponse(res, 201, { job });
254
+ }
255
+
256
+ if (pathname.match(/^\/api\/cron\/([^/]+)$/) && method === "DELETE") {
257
+ const id = pathname.split("/")[3];
258
+ await this.engine.cron.remove(id);
259
+ return jsonResponse(res, 200, { success: true });
260
+ }
261
+
262
+ if (pathname.match(/^\/api\/cron\/([^/]+)\/run$/) && method === "POST") {
263
+ const id = pathname.split("/")[3];
264
+ const result = await this.engine.cron.runNow(id);
265
+ return jsonResponse(res, 200, result);
266
+ }
267
+ }
268
+
269
+ // ── Sub-agents ──────────────────────────────────────────────────────────
270
+ if (pathname === "/api/subagents" && method === "GET") {
271
+ const result = await this.engine._toolListSubagents();
272
+ return jsonResponse(res, 200, result);
273
+ }
274
+
275
+ if (pathname === "/api/subagents" && method === "POST") {
276
+ const body = await readBody(req);
277
+ const result = await this.engine._toolSpawnSubagent(body);
278
+ return jsonResponse(res, 201, result);
279
+ }
280
+
281
+ if (pathname.match(/^\/api\/subagents\/([^/]+)$/) && method === "GET") {
282
+ const id = pathname.split("/")[3];
283
+ const result = await this.engine._toolGetSubagentResult({ id });
284
+ if (!result.success) return errorResponse(res, 404, result.error);
285
+ return jsonResponse(res, 200, result);
286
+ }
287
+
288
+ if (pathname.match(/^\/api\/subagents\/([^/]+)$/) && method === "DELETE") {
289
+ const id = pathname.split("/")[3];
290
+ const result = this.engine._toolKillSubagent({ id });
291
+ return jsonResponse(res, 200, result);
292
+ }
293
+
294
+ // ── Nodes ───────────────────────────────────────────────────────────────
295
+ if (this.engine.nodes) {
296
+ if (pathname === "/api/nodes" && method === "GET") {
297
+ const list = await this.engine.nodes.list();
298
+ return jsonResponse(res, 200, { nodes: list });
299
+ }
300
+
301
+ if (pathname === "/api/nodes/pair" && method === "POST") {
302
+ const body = await readBody(req);
303
+ try {
304
+ const result = await this.engine.nodes.confirmPair(body.code, {
305
+ name: body.name,
306
+ capabilities: body.capabilities ?? [],
307
+ host: body.host ?? "localhost",
308
+ port: body.port ?? 18791,
309
+ });
310
+ return jsonResponse(res, 200, result);
311
+ } catch (err) {
312
+ return errorResponse(res, 400, err.message);
313
+ }
314
+ }
315
+
316
+ if (pathname.match(/^\/api\/nodes\/([^/]+)$/) && method === "DELETE") {
317
+ const id = pathname.split("/")[3];
318
+ try {
319
+ await this.engine.nodes.remove(id);
320
+ return jsonResponse(res, 200, { success: true });
321
+ } catch (err) {
322
+ return errorResponse(res, 404, err.message);
323
+ }
324
+ }
325
+
326
+ if (pathname === "/api/nodes/generate-pair-code" && method === "POST") {
327
+ const code = await this.engine.nodes.generatePairCode();
328
+ return jsonResponse(res, 200, { code, expiresIn: "1 hour" });
329
+ }
330
+
331
+ if (pathname.match(/^\/api\/nodes\/([^/]+)\/ping$/) && method === "GET") {
332
+ const id = pathname.split("/")[3];
333
+ const result = await this.engine.nodes.ping(id);
334
+ return jsonResponse(res, 200, result);
335
+ }
336
+ }
337
+
338
+ // ── Audit ───────────────────────────────────────────────────────────────
339
+ if (pathname === "/api/audit" && method === "GET") {
340
+ const filter = {};
341
+ if (url.searchParams.has("session")) filter.sessionId = url.searchParams.get("session");
342
+ if (url.searchParams.has("type")) filter.type = url.searchParams.get("type");
343
+ if (url.searchParams.has("tool")) filter.tool = url.searchParams.get("tool");
344
+ if (url.searchParams.has("limit")) filter.limit = parseInt(url.searchParams.get("limit"));
345
+ if (url.searchParams.has("today")) filter.date = new Date().toISOString().slice(0, 10);
346
+
347
+ if (this.engine.audit) {
348
+ const events = await this.engine.audit.search(filter);
349
+ return jsonResponse(res, 200, { events });
350
+ }
351
+ return jsonResponse(res, 200, { events: [] });
352
+ }
353
+
354
+ return errorResponse(res, 404, "Not found");
355
+ }
356
+
357
+ // ── Session helpers ────────────────────────────────────────────────────────
358
+
359
+ async _listSessions() {
360
+ try {
361
+ const { CONVERSATIONS_DIR } = await import("./config.mjs");
362
+ const { readdir } = await import("node:fs/promises");
363
+ const files = await readdir(CONVERSATIONS_DIR);
364
+ return files.filter(f => f.endsWith(".json")).map(f => ({
365
+ id: f.replace(".json", ""),
366
+ file: f,
367
+ }));
368
+ } catch { return []; }
369
+ }
370
+
371
+ async _getSession(id) {
372
+ try {
373
+ const { CONVERSATIONS_DIR } = await import("./config.mjs");
374
+ const content = await readFile(path.join(CONVERSATIONS_DIR, `${id}.json`), "utf8");
375
+ return { id, messages: JSON.parse(content) };
376
+ } catch { return null; }
377
+ }
378
+
379
+ async _deleteSession(id) {
380
+ try {
381
+ const { CONVERSATIONS_DIR } = await import("./config.mjs");
382
+ const { unlink } = await import("node:fs/promises");
383
+ await unlink(path.join(CONVERSATIONS_DIR, `${id}.json`));
384
+ } catch {}
385
+ }
386
+
387
+ async _clearSession(id) {
388
+ try {
389
+ const { CONVERSATIONS_DIR } = await import("./config.mjs");
390
+ await writeFile(path.join(CONVERSATIONS_DIR, `${id}.json`), "[]", "utf8");
391
+ } catch {}
392
+ }
393
+
394
+ // ── WebSocket handler ─────────────────────────────────────────────────────
395
+
396
+ _handleUpgrade(req, socket, head) {
397
+ const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
398
+ if (url.pathname !== "/ws") {
399
+ socket.destroy();
400
+ return;
401
+ }
402
+
403
+ // Auth check for WS
404
+ if (!this._authenticate(req)) {
405
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
406
+ socket.destroy();
407
+ return;
408
+ }
409
+
410
+ // Simple WebSocket handshake
411
+ const key = req.headers["sec-websocket-key"];
412
+ if (!key) { socket.destroy(); return; }
413
+
414
+ this._doWsHandshake(socket, key, req);
415
+ }
416
+
417
+ async _doWsHandshake(socket, key, req) {
418
+ try {
419
+ const { createHash } = await import("node:crypto");
420
+ const MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
421
+ const accept = createHash("sha1")
422
+ .update(key + MAGIC)
423
+ .digest("base64");
424
+
425
+ socket.write(
426
+ "HTTP/1.1 101 Switching Protocols\r\n" +
427
+ "Upgrade: websocket\r\n" +
428
+ "Connection: Upgrade\r\n" +
429
+ `Sec-WebSocket-Accept: ${accept}\r\n\r\n`
430
+ );
431
+
432
+ const clientId = randomBytes(8).toString("hex");
433
+ const client = { id: clientId, socket, sessionId: null };
434
+ this._wsClients.set(clientId, client);
435
+
436
+ socket.on("data", (data) => this._handleWsData(clientId, data));
437
+ socket.on("close", () => this._wsClients.delete(clientId));
438
+ socket.on("error", () => this._wsClients.delete(clientId));
439
+ } catch (err) {
440
+ socket.destroy();
441
+ }
442
+ }
443
+
444
+ _handleWsData(clientId, data) {
445
+ const client = this._wsClients.get(clientId);
446
+ if (!client) return;
447
+
448
+ try {
449
+ const msg = this._parseWsFrame(data);
450
+ if (!msg) return;
451
+ const parsed = JSON.parse(msg);
452
+ this._handleWsMessage(clientId, parsed);
453
+ } catch {}
454
+ }
455
+
456
+ _parseWsFrame(data) {
457
+ if (data.length < 2) return null;
458
+ const opcode = data[0] & 0x0f;
459
+ if (opcode === 8) return null; // Close frame
460
+ if (opcode !== 1) return null; // Only text frames
461
+
462
+ const masked = !!(data[1] & 0x80);
463
+ let payloadLen = data[1] & 0x7f;
464
+ let offset = 2;
465
+
466
+ if (payloadLen === 126) { payloadLen = data.readUInt16BE(2); offset = 4; }
467
+ else if (payloadLen === 127) { payloadLen = Number(data.readBigUInt64BE(2)); offset = 10; }
468
+
469
+ if (masked) {
470
+ const mask = data.slice(offset, offset + 4);
471
+ offset += 4;
472
+ const payload = Buffer.allocUnsafe(payloadLen);
473
+ for (let i = 0; i < payloadLen; i++) {
474
+ payload[i] = data[offset + i] ^ mask[i % 4];
475
+ }
476
+ return payload.toString("utf8");
477
+ }
478
+ return data.slice(offset, offset + payloadLen).toString("utf8");
479
+ }
480
+
481
+ _sendWsFrame(socket, text) {
482
+ const payload = Buffer.from(text, "utf8");
483
+ const header = Buffer.allocUnsafe(payload.length < 126 ? 2 : 4);
484
+ header[0] = 0x81; // FIN + text
485
+ if (payload.length < 126) {
486
+ header[1] = payload.length;
487
+ socket.write(Buffer.concat([header, payload]));
488
+ } else if (payload.length < 65536) {
489
+ header[1] = 126;
490
+ header.writeUInt16BE(payload.length, 2);
491
+ socket.write(Buffer.concat([header.slice(0, 4), payload]));
492
+ } else {
493
+ // Large payload — just write text, not handling 64bit length here
494
+ socket.write(Buffer.concat([header, payload]));
495
+ }
496
+ }
497
+
498
+ async _handleWsMessage(clientId, msg) {
499
+ const client = this._wsClients.get(clientId);
500
+ if (!client) return;
501
+
502
+ if (msg.type === "chat") {
503
+ const sessionId = msg.sessionId ?? client.sessionId;
504
+ client.sessionId = sessionId;
505
+
506
+ const send = (obj) => {
507
+ try { this._sendWsFrame(client.socket, JSON.stringify(obj)); } catch {}
508
+ };
509
+
510
+ try {
511
+ await this.engine.processMessage(sessionId, msg.message, {
512
+ onChunk: (chunk) => send({ type: "chunk", content: chunk }),
513
+ onToolCall: (name, args) => send({ type: "tool_call", name, args }),
514
+ });
515
+ // Engine doesn't return full text via onChunk, so send done
516
+ send({ type: "done", sessionId });
517
+ } catch (err) {
518
+ send({ type: "error", message: err.message });
519
+ }
520
+ }
521
+ }
522
+ }
package/core/session.mjs CHANGED
@@ -127,12 +127,24 @@ export class SessionManager {
127
127
 
128
128
  /**
129
129
  * Load a session from disk.
130
+ * Returns null if file doesn't exist or is corrupted.
130
131
  */
131
132
  async load(id) {
132
133
  const filePath = path.join(SESSIONS_DIR, `${id}.json`);
133
134
  try {
134
135
  const raw = await readFile(filePath, "utf8");
135
- const data = JSON.parse(raw);
136
+ let data;
137
+ try {
138
+ data = JSON.parse(raw);
139
+ } catch {
140
+ // Corrupted JSON — treat as missing
141
+ console.error(`[wispy] Session file corrupted (id: ${id}), starting fresh.`);
142
+ return null;
143
+ }
144
+ // Validate required fields
145
+ if (!data || typeof data !== "object" || !data.id) {
146
+ return null;
147
+ }
136
148
  const session = new Session(data);
137
149
  this._sessions.set(id, session);
138
150
  if (session.channel && session.chatId) {
@@ -182,9 +182,19 @@ Reply in the same language as the task. Sign off with 🌿.`;
182
182
  messages.push({ role: "assistant", toolCalls: result.calls, content: "" });
183
183
 
184
184
  for (const call of result.calls) {
185
- const toolResult = await this._engine._executeTool(
186
- call.name, call.args, messages, session, {}
187
- );
185
+ let toolResult;
186
+ try {
187
+ // Enforce per-tool timeout of 60s to prevent runaway tools
188
+ const TOOL_TIMEOUT_MS = 60_000;
189
+ toolResult = await Promise.race([
190
+ this._engine._executeTool(call.name, call.args, messages, session, {}),
191
+ new Promise((_, reject) =>
192
+ setTimeout(() => reject(new Error(`Tool '${call.name}' timed out`)), TOOL_TIMEOUT_MS)
193
+ ),
194
+ ]);
195
+ } catch (err) {
196
+ toolResult = { error: err.message, success: false };
197
+ }
188
198
  messages.push({
189
199
  role: "tool_result",
190
200
  toolName: call.name,
@@ -336,6 +336,12 @@ ${bold("Sub-agent Commands (v0.9):")}
336
336
  ${cyan("/agent")} <id> Show sub-agent details and result
337
337
  ${cyan("/kill")} <id> Cancel a running sub-agent
338
338
 
339
+ ${bold("Permissions & Audit (v1.1):")}
340
+ ${cyan("/permissions")} Show current permission policies
341
+ ${cyan("/permit")} <tool> <level> Change policy (auto|notify|approve)
342
+ ${cyan("/audit")} Show last 10 audit events
343
+ ${cyan("/replay")} Replay current session steps
344
+
339
345
  ${cyan("/quit")} or ${cyan("/exit")} Exit
340
346
  `);
341
347
  return true;
@@ -686,6 +692,82 @@ ${bold("Sub-agent Commands (v0.9):")}
686
692
  return true;
687
693
  }
688
694
 
695
+ // ── Permission commands (v1.1) ────────────────────────────────────────────
696
+
697
+ if (cmd === "/permissions" || cmd === "/perms") {
698
+ await engine.permissions.load();
699
+ console.log(engine.permissions.formatTable());
700
+ return true;
701
+ }
702
+
703
+ if (cmd === "/permit") {
704
+ const tool = parts[1];
705
+ const level = parts[2];
706
+ if (!tool || !level) {
707
+ console.log(yellow("Usage: /permit <tool> <auto|notify|approve>"));
708
+ return true;
709
+ }
710
+ try {
711
+ engine.permissions.setPolicy(tool, level);
712
+ await engine.permissions.save();
713
+ console.log(green(`✅ ${tool} → ${level}`));
714
+ } catch (err) {
715
+ console.log(red(`Error: ${err.message}`));
716
+ }
717
+ return true;
718
+ }
719
+
720
+ // ── Audit commands (v1.1) ─────────────────────────────────────────────────
721
+
722
+ if (cmd === "/audit") {
723
+ const events = await engine.audit.getRecent(10);
724
+ if (events.length === 0) {
725
+ console.log(dim("No audit events yet."));
726
+ } else {
727
+ console.log(bold(`\n📋 Recent Events (${events.length}):\n`));
728
+ for (const evt of events) {
729
+ const ts = new Date(evt.timestamp).toLocaleTimeString();
730
+ const icons = {
731
+ tool_call: "🔧", tool_result: "✅", approval_requested: "⚠️ ",
732
+ approval_granted: "✅", approval_denied: "❌", message_sent: "🌿",
733
+ message_received: "👤", error: "🚨", subagent_spawned: "🤖",
734
+ };
735
+ const icon = icons[evt.type] ?? "•";
736
+ let detail = evt.tool ? ` ${cyan(evt.tool)}` : (evt.content ? ` ${dim(evt.content.slice(0, 60))}` : "");
737
+ console.log(` ${dim(ts)} ${icon} ${evt.type}${detail}`);
738
+ }
739
+ }
740
+ return true;
741
+ }
742
+
743
+ if (cmd === "/replay") {
744
+ // Replay current session
745
+ const sessionId = engine.sessions?.list?.()?.find(Boolean)?.id;
746
+ if (!sessionId) {
747
+ console.log(dim("No active session to replay."));
748
+ return true;
749
+ }
750
+ const steps = await engine.audit.getReplayTrace(sessionId);
751
+ if (steps.length === 0) {
752
+ console.log(dim("No events found for current session."));
753
+ } else {
754
+ console.log(bold(`\n🎬 Session Replay: ${dim(sessionId)}\n`));
755
+ for (const step of steps) {
756
+ const icons = {
757
+ user_message: "👤", assistant_message: "🌿", tool_call: "🔧",
758
+ tool_result: "✅", approval_requested: "⚠️ ", approval_granted: "✅",
759
+ approval_denied: "❌", subagent_spawned: "🤖",
760
+ };
761
+ const icon = icons[step.type] ?? "•";
762
+ let detail = "";
763
+ if (step.content) detail = dim(step.content.slice(0, 80));
764
+ if (step.tool) detail = `${cyan(step.tool)} ${dim(JSON.stringify(step.args ?? {}).slice(0, 50))}`;
765
+ console.log(` ${bold(`${step.step}.`)} ${icon} ${detail}`);
766
+ }
767
+ }
768
+ return true;
769
+ }
770
+
689
771
  if (cmd === "/quit" || cmd === "/exit") {
690
772
  console.log(dim(`🌿 Bye! (${engine.providers.formatCost()})`));
691
773
  engine.destroy();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "0.9.0",
3
+ "version": "1.1.0",
4
4
  "description": "🌿 Wispy — AI workspace assistant with multi-agent orchestration and multi-channel bot support",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Minseo & Poropo",