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.
- package/bin/wispy.mjs +293 -0
- package/core/audit.mjs +322 -0
- package/core/cron.mjs +28 -16
- package/core/engine.mjs +237 -12
- package/core/index.mjs +4 -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 +13 -3
- package/lib/wispy-repl.mjs +82 -0
- package/package.json +1 -1
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
|
@@ -161,15 +161,19 @@ export class CronManager {
|
|
|
161
161
|
async _tick() {
|
|
162
162
|
const now = Date.now();
|
|
163
163
|
for (const job of this._jobs) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
164
|
+
try {
|
|
165
|
+
if (!job.enabled) continue;
|
|
166
|
+
if (!job.nextRun) continue;
|
|
167
|
+
|
|
168
|
+
const nextRunMs = new Date(job.nextRun).getTime();
|
|
169
|
+
if (now >= nextRunMs) {
|
|
170
|
+
// Execute and update — errors must not crash the scheduler
|
|
171
|
+
this._executeJob(job).catch(err => {
|
|
172
|
+
console.error(`[wispy-cron] Job "${job.name}" error: ${err.message}`);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error(`[wispy-cron] Tick error for job "${job?.name}": ${err.message}`);
|
|
173
177
|
}
|
|
174
178
|
}
|
|
175
179
|
}
|
|
@@ -181,16 +185,24 @@ export class CronManager {
|
|
|
181
185
|
let error = null;
|
|
182
186
|
|
|
183
187
|
try {
|
|
184
|
-
// Run via engine
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
// Run via engine with a 5-minute timeout to prevent runaway jobs
|
|
189
|
+
const JOB_TIMEOUT_MS = 300_000;
|
|
190
|
+
const response = await Promise.race([
|
|
191
|
+
this.engine.processMessage(null, job.task, {
|
|
192
|
+
noSave: true,
|
|
193
|
+
systemPrompt: `You are Wispy 🌿, running a scheduled task. Task name: "${job.name}". Execute the task and provide a concise summary. Always end with 🌿.`,
|
|
194
|
+
}),
|
|
195
|
+
new Promise((_, reject) =>
|
|
196
|
+
setTimeout(() => reject(new Error(`Job timed out after ${JOB_TIMEOUT_MS / 1000}s`)), JOB_TIMEOUT_MS)
|
|
197
|
+
),
|
|
198
|
+
]);
|
|
199
|
+
output = response?.content ?? "(no output)";
|
|
190
200
|
|
|
191
201
|
// Deliver to channel if configured
|
|
192
202
|
if (job.channel) {
|
|
193
|
-
await this._deliverToChannel(job.channel, job.name, output)
|
|
203
|
+
await this._deliverToChannel(job.channel, job.name, output).catch(err => {
|
|
204
|
+
console.error(`[wispy-cron] Channel delivery failed for job "${job.name}": ${err.message}`);
|
|
205
|
+
});
|
|
194
206
|
}
|
|
195
207
|
} catch (err) {
|
|
196
208
|
status = "failed";
|