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/bin/wispy.mjs CHANGED
@@ -166,6 +166,299 @@ if (args[0] === "cron") {
166
166
  process.exit(0);
167
167
  }
168
168
 
169
+ // ── audit / log sub-command ───────────────────────────────────────────────────
170
+ if (args[0] === "audit" || args[0] === "log") {
171
+ const { AuditLog, WISPY_DIR } = await import(
172
+ path.join(__dirname, "..", "core", "index.mjs")
173
+ );
174
+ const { writeFile } = await import("node:fs/promises");
175
+
176
+ const audit = new AuditLog(WISPY_DIR);
177
+ const sub = args[1];
178
+
179
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
180
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
181
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
182
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
183
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
184
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
185
+
186
+ function formatEvent(evt) {
187
+ const ts = new Date(evt.timestamp).toLocaleTimeString();
188
+ const icons = {
189
+ tool_call: "🔧",
190
+ tool_result: "✅",
191
+ approval_requested: "⚠️ ",
192
+ approval_granted: "✅",
193
+ approval_denied: "❌",
194
+ message_sent: "🌿",
195
+ message_received: "👤",
196
+ error: "🚨",
197
+ subagent_spawned: "🤖",
198
+ subagent_completed: "🎉",
199
+ cron_executed: "🕐",
200
+ };
201
+ const icon = icons[evt.type] ?? "•";
202
+ let detail = "";
203
+ if (evt.tool) detail += ` ${cyan(evt.tool)}`;
204
+ if (evt.content) detail += ` ${dim(evt.content.slice(0, 60))}`;
205
+ if (evt.message) detail += ` ${dim(evt.message.slice(0, 60))}`;
206
+ if (evt.label) detail += ` ${dim(evt.label)}`;
207
+ const sid = evt.sessionId ? dim(` [${evt.sessionId.slice(-8)}]`) : "";
208
+ return ` ${dim(ts)} ${icon} ${evt.type}${detail}${sid}`;
209
+ }
210
+
211
+ if (sub === "replay" && args[2]) {
212
+ const sessionId = args[2];
213
+ const steps = await audit.getReplayTrace(sessionId);
214
+ if (steps.length === 0) {
215
+ console.log(dim(`No events found for session: ${sessionId}`));
216
+ } else {
217
+ console.log(`\n${bold("🎬 Replay:")} ${cyan(sessionId)}\n`);
218
+ for (const step of steps) {
219
+ const ts = new Date(step.timestamp).toLocaleTimeString();
220
+ const icons = {
221
+ user_message: "👤",
222
+ assistant_message: "🌿",
223
+ tool_call: "🔧",
224
+ tool_result: "✅",
225
+ approval_requested: "⚠️ ",
226
+ approval_granted: "✅",
227
+ approval_denied: "❌",
228
+ subagent_spawned: "🤖",
229
+ subagent_completed: "🎉",
230
+ };
231
+ const icon = icons[step.type] ?? "•";
232
+ let detail = "";
233
+ if (step.content) detail = dim(step.content.slice(0, 100));
234
+ if (step.tool) detail = `${cyan(step.tool)} ${dim(JSON.stringify(step.args ?? {}).slice(0, 60))}`;
235
+ console.log(` ${bold(`Step ${step.step}`)} ${dim(ts)} ${icon} ${detail}`);
236
+ }
237
+ }
238
+ process.exit(0);
239
+ }
240
+
241
+ if (sub === "export") {
242
+ const format = args.includes("--format") ? args[args.indexOf("--format") + 1] : "json";
243
+ const outputIdx = args.indexOf("--output");
244
+ const output = outputIdx !== -1 ? args[outputIdx + 1] : null;
245
+
246
+ let content;
247
+ if (format === "md" || format === "markdown") {
248
+ content = await audit.exportMarkdown();
249
+ } else {
250
+ content = await audit.exportJson();
251
+ }
252
+
253
+ if (output) {
254
+ await writeFile(output, content, "utf8");
255
+ console.log(green(`✅ Exported to ${output}`));
256
+ } else {
257
+ console.log(content);
258
+ }
259
+ process.exit(0);
260
+ }
261
+
262
+ // Build filter from flags
263
+ const filter = {};
264
+ const sessionIdx = args.indexOf("--session");
265
+ if (sessionIdx !== -1) filter.sessionId = args[sessionIdx + 1];
266
+ const toolIdx = args.indexOf("--tool");
267
+ if (toolIdx !== -1) filter.tool = args[toolIdx + 1];
268
+ if (args.includes("--today")) filter.date = new Date().toISOString().slice(0, 10);
269
+ const limitIdx = args.indexOf("--limit");
270
+ filter.limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1]) : 30;
271
+
272
+ const events = await audit.search(filter);
273
+
274
+ if (events.length === 0) {
275
+ console.log(dim("No audit events found."));
276
+ } else {
277
+ console.log(`\n${bold("📋 Audit Log")} ${dim(`(${events.length} events)`)}\n`);
278
+ for (const evt of events) {
279
+ console.log(formatEvent(evt));
280
+ }
281
+ console.log("");
282
+ }
283
+
284
+ if (!sub) {
285
+ console.log(dim(`
286
+ wispy audit — show recent events
287
+ wispy audit --session <id> — filter by session
288
+ wispy audit --today — today's events
289
+ wispy audit --tool <name> — filter by tool
290
+ wispy audit replay <sessionId> — step-by-step replay
291
+ wispy audit export --format md — export as markdown
292
+ wispy audit export --output file.md — save to file
293
+ `));
294
+ }
295
+
296
+ process.exit(0);
297
+ }
298
+
299
+ // ── server sub-command ────────────────────────────────────────────────────────
300
+ if (args[0] === "server" || args.includes("--server")) {
301
+ const { WispyEngine, WispyServer } = await import(
302
+ path.join(__dirname, "..", "core", "index.mjs")
303
+ );
304
+
305
+ const portIdx = args.indexOf("--port");
306
+ const hostIdx = args.indexOf("--host");
307
+ const port = portIdx !== -1 ? parseInt(args[portIdx + 1]) : undefined;
308
+ const host = hostIdx !== -1 ? args[hostIdx + 1] : undefined;
309
+
310
+ console.log("🌿 Starting Wispy API server...");
311
+
312
+ const engine = new WispyEngine();
313
+ const initResult = await engine.init();
314
+ if (!initResult) {
315
+ console.error("❌ No AI provider configured. Run `wispy` first to set up.");
316
+ process.exit(1);
317
+ }
318
+
319
+ const server = new WispyServer(engine, { port, host });
320
+ await server.start();
321
+
322
+ process.on("SIGINT", () => { server.stop(); process.exit(0); });
323
+ process.on("SIGTERM", () => { server.stop(); process.exit(0); });
324
+
325
+ // Keep alive
326
+ setInterval(() => {}, 60_000);
327
+ await new Promise(() => {});
328
+ }
329
+
330
+ // ── node sub-command ──────────────────────────────────────────────────────────
331
+ if (args[0] === "node") {
332
+ const { NodeManager, WISPY_DIR, CAPABILITIES } = await import(
333
+ path.join(__dirname, "..", "core", "index.mjs")
334
+ );
335
+ const { createInterface } = await import("node:readline");
336
+
337
+ const sub = args[1];
338
+ const nodes = new NodeManager(WISPY_DIR);
339
+
340
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
341
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
342
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
343
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
344
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
345
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
346
+
347
+ if (sub === "pair") {
348
+ const code = await nodes.generatePairCode();
349
+ console.log(`\n🔗 Pairing Code: ${bold(green(code))}\n`);
350
+ console.log(` This code expires in 1 hour.`);
351
+ console.log(`\n On the remote machine, run:`);
352
+ console.log(` ${cyan(`wispy node connect ${code} --url http://localhost:18790`)}\n`);
353
+ process.exit(0);
354
+ }
355
+
356
+ if (sub === "connect" && args[2]) {
357
+ const code = args[2];
358
+ const urlIdx = args.indexOf("--url");
359
+ const serverUrl = urlIdx !== -1 ? args[urlIdx + 1] : "http://localhost:18790";
360
+
361
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
362
+ const ask = (q) => new Promise(r => rl.question(q, r));
363
+
364
+ console.log(`\n🔗 Connecting to Wispy at ${serverUrl}\n`);
365
+ const name = (await ask(" Node name (e.g. my-laptop): ")).trim() || `node-${Date.now().toString(36)}`;
366
+
367
+ console.log(`\nAvailable capabilities:`);
368
+ const capList = Object.entries(CAPABILITIES);
369
+ capList.forEach(([k, v], i) => console.log(` ${i + 1}. ${k.padEnd(15)} — ${dim(v)}`));
370
+ const capInput = (await ask("\n Select capabilities (comma-separated numbers, e.g. 1,3): ")).trim();
371
+ rl.close();
372
+
373
+ const selectedCaps = capInput
374
+ ? capInput.split(",").map(n => capList[parseInt(n.trim()) - 1]?.[0]).filter(Boolean)
375
+ : [];
376
+
377
+ // Call server to confirm pair
378
+ try {
379
+ const tokenIdx = args.indexOf("--token");
380
+ const token = tokenIdx !== -1 ? args[tokenIdx + 1] : "";
381
+ const resp = await fetch(`${serverUrl}/api/nodes/pair`, {
382
+ method: "POST",
383
+ headers: { "Content-Type": "application/json", ...(token ? { "Authorization": `Bearer ${token}` } : {}) },
384
+ body: JSON.stringify({ code, name, capabilities: selectedCaps, host: "localhost", port: 18791 }),
385
+ });
386
+ if (!resp.ok) {
387
+ const err = await resp.json().catch(() => ({ error: resp.statusText }));
388
+ console.error(red(`❌ Pairing failed: ${err.error ?? resp.statusText}`));
389
+ process.exit(1);
390
+ }
391
+ const result = await resp.json();
392
+ console.log(green(`\n✅ Paired! Node ID: ${result.id}`));
393
+ } catch (err) {
394
+ console.error(red(`❌ Connection failed: ${err.message}`));
395
+ console.log(dim(" Make sure `wispy server` is running on the main machine."));
396
+ process.exit(1);
397
+ }
398
+ process.exit(0);
399
+ }
400
+
401
+ if (sub === "list") {
402
+ const nodeList = await nodes.list();
403
+ if (nodeList.length === 0) {
404
+ console.log(dim("No nodes registered. Use: wispy node pair"));
405
+ } else {
406
+ console.log(`\n${bold("🌐 Registered Nodes:")}\n`);
407
+ for (const n of nodeList) {
408
+ const caps = n.capabilities.join(", ") || dim("none");
409
+ const last = n.lastSeen ? dim(new Date(n.lastSeen).toLocaleString()) : dim("never");
410
+ console.log(` ${green(n.id.slice(-12))} ${bold(n.name.padEnd(20))} ${n.host}:${n.port}`);
411
+ console.log(` Caps: ${caps} Last seen: ${last}`);
412
+ console.log("");
413
+ }
414
+ }
415
+ process.exit(0);
416
+ }
417
+
418
+ if (sub === "status") {
419
+ const nodeList = await nodes.list();
420
+ if (nodeList.length === 0) {
421
+ console.log(dim("No nodes registered."));
422
+ process.exit(0);
423
+ }
424
+ console.log(`\n${bold("📡 Node Status:")}\n`);
425
+ const results = await nodes.status();
426
+ for (const r of results) {
427
+ const statusIcon = r.alive ? green("●") : red("●");
428
+ const latency = r.latency ? dim(` ${r.latency}ms`) : "";
429
+ const err = r.error ? red(` (${r.error})`) : "";
430
+ console.log(` ${statusIcon} ${r.name.padEnd(20)} ${r.host}:${r.port}${latency}${err}`);
431
+ }
432
+ console.log("");
433
+ process.exit(0);
434
+ }
435
+
436
+ if (sub === "remove" && args[2]) {
437
+ const id = args[2];
438
+ const nodeList = await nodes.list();
439
+ const match = nodeList.find(n => n.id === id || n.id.endsWith(id));
440
+ if (!match) {
441
+ console.error(red(`Node not found: ${id}`));
442
+ process.exit(1);
443
+ }
444
+ await nodes.remove(match.id);
445
+ console.log(green(`✅ Removed node: ${match.name} (${match.id})`));
446
+ process.exit(0);
447
+ }
448
+
449
+ // Help
450
+ console.log(`
451
+ 🌐 Wispy Node Commands:
452
+
453
+ wispy node pair — generate pairing code
454
+ wispy node connect <code> --url <url> — connect as a node
455
+ wispy node list — show registered nodes
456
+ wispy node status — ping all nodes
457
+ wispy node remove <id> — unregister a node
458
+ `);
459
+ process.exit(0);
460
+ }
461
+
169
462
  // ── channel sub-command ───────────────────────────────────────────────────────
170
463
  if (args[0] === "channel") {
171
464
  const { channelSetup, channelList, channelTest } = await import(
package/core/audit.mjs ADDED
@@ -0,0 +1,322 @@
1
+ /**
2
+ * core/audit.mjs — Audit Log & Replay for Wispy
3
+ *
4
+ * Storage: ~/.wispy/audit/YYYY-MM-DD.jsonl
5
+ * Auto-rotate: keep last 30 days
6
+ */
7
+
8
+ import path from "node:path";
9
+ import { readFile, writeFile, mkdir, readdir, unlink, appendFile, stat } from "node:fs/promises";
10
+
11
+ import { WISPY_DIR } from "./config.mjs";
12
+
13
+ const AUDIT_DIR = path.join(WISPY_DIR, "audit");
14
+ const MAX_DAYS = 30;
15
+
16
+ // Event types
17
+ export const EVENT_TYPES = {
18
+ TOOL_CALL: "tool_call",
19
+ TOOL_RESULT: "tool_result",
20
+ APPROVAL_REQUESTED: "approval_requested",
21
+ APPROVAL_GRANTED: "approval_granted",
22
+ APPROVAL_DENIED: "approval_denied",
23
+ MESSAGE_SENT: "message_sent",
24
+ MESSAGE_RECEIVED: "message_received",
25
+ ERROR: "error",
26
+ SUBAGENT_SPAWNED: "subagent_spawned",
27
+ SUBAGENT_COMPLETED: "subagent_completed",
28
+ CRON_EXECUTED: "cron_executed",
29
+ };
30
+
31
+ function todayString() {
32
+ return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
33
+ }
34
+
35
+ function auditFilePath(dateStr) {
36
+ return path.join(AUDIT_DIR, `${dateStr}.jsonl`);
37
+ }
38
+
39
+ export class AuditLog {
40
+ constructor(wispyDir = WISPY_DIR) {
41
+ this.auditDir = path.join(wispyDir, "audit");
42
+ this._initialized = false;
43
+ }
44
+
45
+ async _init() {
46
+ if (this._initialized) return;
47
+ await mkdir(this.auditDir, { recursive: true });
48
+ this._initialized = true;
49
+ // Rotate old files in the background
50
+ this._rotate().catch(() => {});
51
+ }
52
+
53
+ // ── Log ─────────────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Log an audit event.
57
+ * @param {object} event - Event object (type required)
58
+ */
59
+ async log(event) {
60
+ await this._init();
61
+ const entry = {
62
+ timestamp: new Date().toISOString(),
63
+ ...event,
64
+ };
65
+ const line = JSON.stringify(entry) + "\n";
66
+ const filePath = path.join(this.auditDir, `${todayString()}.jsonl`);
67
+ await appendFile(filePath, line, "utf8");
68
+ }
69
+
70
+ // ── Query ────────────────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Load all events from JSONL files matching filter.
74
+ * @param {object} filter - { date?, type?, sessionId?, tool?, limit? }
75
+ */
76
+ async search(filter = {}) {
77
+ await this._init();
78
+ const allFiles = await this._listFiles();
79
+ const events = [];
80
+
81
+ // Filter by date if provided
82
+ const targetFiles = filter.date
83
+ ? allFiles.filter(f => f.includes(filter.date))
84
+ : allFiles;
85
+
86
+ for (const file of targetFiles.slice(-30)) {
87
+ try {
88
+ const content = await readFile(path.join(this.auditDir, file), "utf8");
89
+ const lines = content.trim().split("\n").filter(Boolean);
90
+ for (const line of lines) {
91
+ try {
92
+ const evt = JSON.parse(line);
93
+ if (this._matchesFilter(evt, filter)) {
94
+ events.push(evt);
95
+ }
96
+ } catch {}
97
+ }
98
+ } catch {}
99
+ }
100
+
101
+ if (filter.limit) return events.slice(-filter.limit);
102
+ return events;
103
+ }
104
+
105
+ /**
106
+ * Get all events for a session.
107
+ */
108
+ async getSession(sessionId) {
109
+ return this.search({ sessionId });
110
+ }
111
+
112
+ /**
113
+ * Get recent events.
114
+ * @param {number} limit
115
+ */
116
+ async getRecent(limit = 20) {
117
+ return this.search({ limit });
118
+ }
119
+
120
+ /**
121
+ * Today's events.
122
+ */
123
+ async getToday() {
124
+ return this.search({ date: todayString() });
125
+ }
126
+
127
+ // ── Replay ───────────────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Get replay trace for a session — ordered steps.
131
+ * @param {string} sessionId
132
+ * @returns {Step[]}
133
+ */
134
+ async getReplayTrace(sessionId) {
135
+ const events = await this.getSession(sessionId);
136
+ if (events.length === 0) return [];
137
+
138
+ const steps = [];
139
+ let stepIdx = 0;
140
+
141
+ for (const evt of events) {
142
+ let step = null;
143
+ switch (evt.type) {
144
+ case EVENT_TYPES.MESSAGE_RECEIVED:
145
+ step = {
146
+ step: ++stepIdx,
147
+ type: "user_message",
148
+ timestamp: evt.timestamp,
149
+ content: evt.content ?? evt.message,
150
+ };
151
+ break;
152
+ case EVENT_TYPES.MESSAGE_SENT:
153
+ step = {
154
+ step: ++stepIdx,
155
+ type: "assistant_message",
156
+ timestamp: evt.timestamp,
157
+ content: evt.content ?? evt.response,
158
+ };
159
+ break;
160
+ case EVENT_TYPES.TOOL_CALL:
161
+ step = {
162
+ step: ++stepIdx,
163
+ type: "tool_call",
164
+ timestamp: evt.timestamp,
165
+ tool: evt.tool,
166
+ args: evt.args,
167
+ };
168
+ break;
169
+ case EVENT_TYPES.TOOL_RESULT:
170
+ step = {
171
+ step: ++stepIdx,
172
+ type: "tool_result",
173
+ timestamp: evt.timestamp,
174
+ tool: evt.tool,
175
+ result: evt.result,
176
+ duration: evt.duration,
177
+ };
178
+ break;
179
+ case EVENT_TYPES.APPROVAL_REQUESTED:
180
+ step = {
181
+ step: ++stepIdx,
182
+ type: "approval_requested",
183
+ timestamp: evt.timestamp,
184
+ tool: evt.tool,
185
+ args: evt.args,
186
+ };
187
+ break;
188
+ case EVENT_TYPES.APPROVAL_GRANTED:
189
+ case EVENT_TYPES.APPROVAL_DENIED:
190
+ step = {
191
+ step: ++stepIdx,
192
+ type: evt.type,
193
+ timestamp: evt.timestamp,
194
+ tool: evt.tool,
195
+ };
196
+ break;
197
+ case EVENT_TYPES.SUBAGENT_SPAWNED:
198
+ step = {
199
+ step: ++stepIdx,
200
+ type: "subagent_spawned",
201
+ timestamp: evt.timestamp,
202
+ agentId: evt.agentId,
203
+ label: evt.label,
204
+ };
205
+ break;
206
+ case EVENT_TYPES.SUBAGENT_COMPLETED:
207
+ step = {
208
+ step: ++stepIdx,
209
+ type: "subagent_completed",
210
+ timestamp: evt.timestamp,
211
+ agentId: evt.agentId,
212
+ status: evt.status,
213
+ };
214
+ break;
215
+ }
216
+ if (step) steps.push(step);
217
+ }
218
+
219
+ return steps;
220
+ }
221
+
222
+ // ── Export ───────────────────────────────────────────────────────────────────
223
+
224
+ async exportJson(filter = {}) {
225
+ const events = await this.search(filter);
226
+ return JSON.stringify(events, null, 2);
227
+ }
228
+
229
+ async exportMarkdown(filter = {}) {
230
+ const events = await this.search(filter);
231
+ if (events.length === 0) return "# Wispy Audit Report\n\nNo events found.\n";
232
+
233
+ const lines = [
234
+ `# Wispy Audit Report`,
235
+ `Generated: ${new Date().toISOString()}`,
236
+ `Total events: ${events.length}`,
237
+ "",
238
+ ];
239
+
240
+ const bySession = {};
241
+ for (const evt of events) {
242
+ const sid = evt.sessionId ?? "unknown";
243
+ if (!bySession[sid]) bySession[sid] = [];
244
+ bySession[sid].push(evt);
245
+ }
246
+
247
+ for (const [sid, evts] of Object.entries(bySession)) {
248
+ lines.push(`## Session: ${sid}`, "");
249
+ for (const evt of evts) {
250
+ const ts = new Date(evt.timestamp).toLocaleString();
251
+ switch (evt.type) {
252
+ case EVENT_TYPES.MESSAGE_RECEIVED:
253
+ lines.push(`**[${ts}] 👤 User:** ${(evt.content ?? evt.message ?? "").slice(0, 200)}`);
254
+ break;
255
+ case EVENT_TYPES.MESSAGE_SENT:
256
+ lines.push(`**[${ts}] 🌿 Wispy:** ${(evt.content ?? evt.response ?? "").slice(0, 200)}`);
257
+ break;
258
+ case EVENT_TYPES.TOOL_CALL:
259
+ lines.push(`**[${ts}] 🔧 Tool:** \`${evt.tool}\` — ${JSON.stringify(evt.args ?? {}).slice(0, 100)}`);
260
+ break;
261
+ case EVENT_TYPES.APPROVAL_GRANTED:
262
+ lines.push(`**[${ts}] ✅ Approved:** \`${evt.tool}\``);
263
+ break;
264
+ case EVENT_TYPES.APPROVAL_DENIED:
265
+ lines.push(`**[${ts}] ❌ Denied:** \`${evt.tool}\``);
266
+ break;
267
+ case EVENT_TYPES.ERROR:
268
+ lines.push(`**[${ts}] 🚨 Error:** ${evt.message ?? ""}`);
269
+ break;
270
+ default:
271
+ lines.push(`**[${ts}] ${evt.type}**`);
272
+ }
273
+ }
274
+ lines.push("");
275
+ }
276
+
277
+ return lines.join("\n");
278
+ }
279
+
280
+ // ── Rotation ─────────────────────────────────────────────────────────────────
281
+
282
+ async _rotate() {
283
+ try {
284
+ const files = await this._listFiles();
285
+ if (files.length <= MAX_DAYS) return;
286
+ const toDelete = files.slice(0, files.length - MAX_DAYS);
287
+ for (const f of toDelete) {
288
+ await unlink(path.join(this.auditDir, f)).catch(() => {});
289
+ }
290
+ } catch {}
291
+ }
292
+
293
+ async _listFiles() {
294
+ try {
295
+ const files = await readdir(this.auditDir);
296
+ return files.filter(f => f.endsWith(".jsonl")).sort();
297
+ } catch {
298
+ return [];
299
+ }
300
+ }
301
+
302
+ // ── Internal filter ───────────────────────────────────────────────────────────
303
+
304
+ _matchesFilter(evt, filter) {
305
+ if (filter.type && evt.type !== filter.type) return false;
306
+ if (filter.sessionId && evt.sessionId !== filter.sessionId) return false;
307
+ if (filter.tool && evt.tool !== filter.tool) return false;
308
+ if (filter.since) {
309
+ const ts = new Date(evt.timestamp);
310
+ const since = new Date(filter.since);
311
+ if (ts < since) return false;
312
+ }
313
+ return true;
314
+ }
315
+ }
316
+
317
+ // Singleton
318
+ let _defaultAudit = null;
319
+ export function getAuditLog() {
320
+ if (!_defaultAudit) _defaultAudit = new AuditLog();
321
+ return _defaultAudit;
322
+ }
package/core/cron.mjs CHANGED
@@ -119,6 +119,8 @@ export class CronManager {
119
119
  this._running = true;
120
120
  this._tick();
121
121
  this._timer = setInterval(() => this._tick(), TICK_INTERVAL_MS);
122
+ // unref() so the timer doesn't prevent the process from exiting
123
+ if (this._timer?.unref) this._timer.unref();
122
124
  console.error(`[wispy-cron] Scheduler started (checking every ${TICK_INTERVAL_MS / 1000}s)`);
123
125
  }
124
126
 
@@ -161,15 +163,19 @@ export class CronManager {
161
163
  async _tick() {
162
164
  const now = Date.now();
163
165
  for (const job of this._jobs) {
164
- if (!job.enabled) continue;
165
- if (!job.nextRun) continue;
166
-
167
- const nextRunMs = new Date(job.nextRun).getTime();
168
- if (now >= nextRunMs) {
169
- // Execute and update
170
- this._executeJob(job).catch(err => {
171
- console.error(`[wispy-cron] Job "${job.name}" error:`, err.message);
172
- });
166
+ try {
167
+ if (!job.enabled) continue;
168
+ if (!job.nextRun) continue;
169
+
170
+ const nextRunMs = new Date(job.nextRun).getTime();
171
+ if (now >= nextRunMs) {
172
+ // Execute and update — errors must not crash the scheduler
173
+ this._executeJob(job).catch(err => {
174
+ console.error(`[wispy-cron] Job "${job.name}" error: ${err.message}`);
175
+ });
176
+ }
177
+ } catch (err) {
178
+ console.error(`[wispy-cron] Tick error for job "${job?.name}": ${err.message}`);
173
179
  }
174
180
  }
175
181
  }
@@ -181,16 +187,24 @@ export class CronManager {
181
187
  let error = null;
182
188
 
183
189
  try {
184
- // Run via engine
185
- const response = await this.engine.processMessage(null, job.task, {
186
- noSave: true,
187
- systemPrompt: `You are Wispy 🌿, running a scheduled task. Task name: "${job.name}". Execute the task and provide a concise summary. Always end with 🌿.`,
188
- });
189
- output = response.content;
190
+ // Run via engine with a 5-minute timeout to prevent runaway jobs
191
+ const JOB_TIMEOUT_MS = 300_000;
192
+ const response = await Promise.race([
193
+ this.engine.processMessage(null, job.task, {
194
+ noSave: true,
195
+ systemPrompt: `You are Wispy 🌿, running a scheduled task. Task name: "${job.name}". Execute the task and provide a concise summary. Always end with 🌿.`,
196
+ }),
197
+ new Promise((_, reject) =>
198
+ setTimeout(() => reject(new Error(`Job timed out after ${JOB_TIMEOUT_MS / 1000}s`)), JOB_TIMEOUT_MS)
199
+ ),
200
+ ]);
201
+ output = response?.content ?? "(no output)";
190
202
 
191
203
  // Deliver to channel if configured
192
204
  if (job.channel) {
193
- await this._deliverToChannel(job.channel, job.name, output);
205
+ await this._deliverToChannel(job.channel, job.name, output).catch(err => {
206
+ console.error(`[wispy-cron] Channel delivery failed for job "${job.name}": ${err.message}`);
207
+ });
194
208
  }
195
209
  } catch (err) {
196
210
  status = "failed";