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.
@@ -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) {