wispy-cli 0.7.0 → 0.9.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 CHANGED
@@ -22,6 +22,150 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
22
 
23
23
  const args = process.argv.slice(2);
24
24
 
25
+ // ── cron sub-command ──────────────────────────────────────────────────────────
26
+ if (args[0] === "cron") {
27
+ const { WispyEngine, CronManager, WISPY_DIR } = await import(
28
+ path.join(__dirname, "..", "core", "index.mjs")
29
+ );
30
+ const { createInterface } = await import("node:readline");
31
+
32
+ const sub = args[1];
33
+
34
+ // Init engine for cron commands that need it
35
+ const engine = new WispyEngine();
36
+ await engine.init({ skipMcp: true });
37
+ const cron = new CronManager(WISPY_DIR, engine);
38
+ await cron.init();
39
+
40
+ if (!sub || sub === "list") {
41
+ const jobs = cron.list();
42
+ if (jobs.length === 0) {
43
+ console.log("No cron jobs configured. Use: wispy cron add");
44
+ } else {
45
+ console.log(`\n🕐 Cron Jobs (${jobs.length}):\n`);
46
+ for (const j of jobs) {
47
+ const status = j.enabled ? "✅" : "⏸️ ";
48
+ const schedStr = j.schedule.kind === "cron" ? j.schedule.expr
49
+ : j.schedule.kind === "every" ? `every ${j.schedule.ms / 60000}min`
50
+ : `at ${j.schedule.time}`;
51
+ console.log(` ${status} ${j.id.slice(0, 8)} ${j.name.padEnd(20)} ${schedStr}`);
52
+ console.log(` Task: ${j.task.slice(0, 60)}${j.task.length > 60 ? "..." : ""}`);
53
+ if (j.channel) console.log(` Channel: ${j.channel}`);
54
+ if (j.nextRun) console.log(` Next run: ${new Date(j.nextRun).toLocaleString()}`);
55
+ console.log("");
56
+ }
57
+ }
58
+ process.exit(0);
59
+ }
60
+
61
+ if (sub === "add") {
62
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
63
+ const ask = (q) => new Promise(r => rl.question(q, r));
64
+
65
+ console.log("\n🕐 Add Cron Job\n");
66
+ const name = await ask(" Job name: ");
67
+ const task = await ask(" Task (what to do): ");
68
+ const schedKind = await ask(" Schedule type (cron/every/at) [cron]: ") || "cron";
69
+ let schedule = { kind: schedKind };
70
+
71
+ if (schedKind === "cron") {
72
+ const expr = await ask(" Cron expression (e.g. '0 9 * * *'): ");
73
+ const tz = await ask(" Timezone [Asia/Seoul]: ") || "Asia/Seoul";
74
+ schedule = { kind: "cron", expr: expr.trim(), tz: tz.trim() };
75
+ } else if (schedKind === "every") {
76
+ const mins = await ask(" Interval in minutes: ");
77
+ schedule = { kind: "every", ms: parseFloat(mins) * 60_000 };
78
+ } else if (schedKind === "at") {
79
+ const time = await ask(" Run at (ISO datetime, e.g. 2025-01-01T09:00:00): ");
80
+ schedule = { kind: "at", time: time.trim() };
81
+ }
82
+
83
+ const channel = await ask(" Channel (e.g. telegram:12345, or leave empty): ");
84
+ rl.close();
85
+
86
+ const job = await cron.add({
87
+ name: name.trim(),
88
+ task: task.trim(),
89
+ schedule,
90
+ channel: channel.trim() || null,
91
+ enabled: true,
92
+ });
93
+
94
+ console.log(`\n✅ Job created: ${job.id}`);
95
+ console.log(` Next run: ${job.nextRun ? new Date(job.nextRun).toLocaleString() : "N/A"}`);
96
+ process.exit(0);
97
+ }
98
+
99
+ if (sub === "remove" && args[2]) {
100
+ const id = args[2];
101
+ // Support partial ID match
102
+ const all = cron.list();
103
+ const match = all.find(j => j.id === id || j.id.startsWith(id));
104
+ if (!match) {
105
+ console.error(`Job not found: ${id}`);
106
+ process.exit(1);
107
+ }
108
+ await cron.remove(match.id);
109
+ console.log(`✅ Removed job: ${match.name} (${match.id})`);
110
+ process.exit(0);
111
+ }
112
+
113
+ if (sub === "run" && args[2]) {
114
+ const id = args[2];
115
+ const all = cron.list();
116
+ const match = all.find(j => j.id === id || j.id.startsWith(id));
117
+ if (!match) {
118
+ console.error(`Job not found: ${id}`);
119
+ process.exit(1);
120
+ }
121
+ console.log(`🌿 Running job: ${match.name}...`);
122
+ const result = await cron.runNow(match.id);
123
+ console.log(result.output ?? result.error);
124
+ process.exit(0);
125
+ }
126
+
127
+ if (sub === "history" && args[2]) {
128
+ const id = args[2];
129
+ const all = cron.list();
130
+ const match = all.find(j => j.id === id || j.id.startsWith(id));
131
+ if (!match) {
132
+ console.error(`Job not found: ${id}`);
133
+ process.exit(1);
134
+ }
135
+ const history = await cron.getHistory(match.id);
136
+ console.log(`\n📋 History for "${match.name}" (last ${history.length} runs):\n`);
137
+ for (const h of history) {
138
+ const icon = h.status === "success" ? "✅" : "❌";
139
+ console.log(` ${icon} ${new Date(h.startedAt).toLocaleString()}`);
140
+ console.log(` ${h.output?.slice(0, 100) ?? h.error ?? ""}`);
141
+ console.log("");
142
+ }
143
+ if (history.length === 0) console.log(" No runs yet.");
144
+ process.exit(0);
145
+ }
146
+
147
+ if (sub === "start") {
148
+ console.log("🌿 Starting cron scheduler... (Ctrl+C to stop)\n");
149
+ cron.start();
150
+ process.on("SIGINT", () => { cron.stop(); process.exit(0); });
151
+ process.on("SIGTERM", () => { cron.stop(); process.exit(0); });
152
+ setInterval(() => {}, 60_000);
153
+ await new Promise(() => {});
154
+ }
155
+
156
+ console.log(`
157
+ 🕐 Wispy Cron Commands:
158
+
159
+ wispy cron list — list all jobs
160
+ wispy cron add — interactive job creation
161
+ wispy cron remove <id> — delete a job
162
+ wispy cron run <id> — trigger immediately
163
+ wispy cron history <id> — show past runs
164
+ wispy cron start — start scheduler (foreground)
165
+ `);
166
+ process.exit(0);
167
+ }
168
+
25
169
  // ── channel sub-command ───────────────────────────────────────────────────────
26
170
  if (args[0] === "channel") {
27
171
  const { channelSetup, channelList, channelTest } = await import(
@@ -77,9 +221,35 @@ if (serveMode || telegramMode || discordMode || slackMode) {
77
221
 
78
222
  await manager.startAll(only);
79
223
 
224
+ // Start cron scheduler if in serve mode
225
+ let cronManager = null;
226
+ if (serveMode) {
227
+ try {
228
+ const { WispyEngine, CronManager, WISPY_DIR } = await import(
229
+ path.join(__dirname, "..", "core", "index.mjs")
230
+ );
231
+ const engine = new WispyEngine();
232
+ await engine.init({ skipMcp: true });
233
+ cronManager = new CronManager(WISPY_DIR, engine);
234
+ await cronManager.init();
235
+ cronManager.start();
236
+ console.error("[wispy] Cron scheduler started");
237
+ } catch (err) {
238
+ console.error("[wispy] Failed to start cron scheduler:", err.message);
239
+ }
240
+ }
241
+
80
242
  // Keep process alive and stop cleanly on Ctrl+C
81
- process.on("SIGINT", async () => { await manager.stopAll(); process.exit(0); });
82
- process.on("SIGTERM", async () => { await manager.stopAll(); process.exit(0); });
243
+ process.on("SIGINT", async () => {
244
+ if (cronManager) cronManager.stop();
245
+ await manager.stopAll();
246
+ process.exit(0);
247
+ });
248
+ process.on("SIGTERM", async () => {
249
+ if (cronManager) cronManager.stop();
250
+ await manager.stopAll();
251
+ process.exit(0);
252
+ });
83
253
 
84
254
  // Prevent Node from exiting
85
255
  setInterval(() => {}, 60_000);
package/core/cron.mjs ADDED
@@ -0,0 +1,346 @@
1
+ /**
2
+ * core/cron.mjs — Task scheduler for Wispy
3
+ *
4
+ * Manages scheduled jobs stored in ~/.wispy/cron/jobs.json
5
+ * History stored in ~/.wispy/cron/history/<jobId>.json
6
+ *
7
+ * Schedule types:
8
+ * - cron: standard 5-field cron expression (uses cron-parser)
9
+ * - every: interval in ms (e.g., 1800000 for 30 minutes)
10
+ * - at: one-shot at specific ISO datetime
11
+ */
12
+
13
+ import path from "node:path";
14
+ import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
15
+
16
+ const MAX_HISTORY_PER_JOB = 50;
17
+ const TICK_INTERVAL_MS = 30_000; // Check every 30 seconds
18
+
19
+ export class CronManager {
20
+ constructor(wispyDir, engine) {
21
+ this.cronDir = path.join(wispyDir, "cron");
22
+ this.jobsFile = path.join(this.cronDir, "jobs.json");
23
+ this.historyDir = path.join(this.cronDir, "history");
24
+ this.engine = engine;
25
+ this._jobs = [];
26
+ this._timer = null;
27
+ this._running = false;
28
+ this._cronParser = null; // lazy-loaded
29
+ }
30
+
31
+ /**
32
+ * Initialize: load jobs from disk
33
+ */
34
+ async init() {
35
+ await mkdir(this.cronDir, { recursive: true });
36
+ await mkdir(this.historyDir, { recursive: true });
37
+ await this._loadJobs();
38
+ return this;
39
+ }
40
+
41
+ /**
42
+ * Lazy-load cron-parser
43
+ */
44
+ async _getCronParser() {
45
+ if (!this._cronParser) {
46
+ try {
47
+ const mod = await import("cron-parser");
48
+ // v5 API: CronExpressionParser.parse(expr, { tz })
49
+ // v4 API: parseExpression(expr, { tz })
50
+ this._cronParser = mod.CronExpressionParser ?? mod.default ?? mod;
51
+ } catch {
52
+ this._cronParser = null;
53
+ }
54
+ }
55
+ return this._cronParser;
56
+ }
57
+
58
+ // ── Job Management ────────────────────────────────────────────────────────
59
+
60
+ async add(jobDef) {
61
+ const { randomUUID } = await import("node:crypto");
62
+ const job = {
63
+ id: jobDef.id ?? randomUUID(),
64
+ name: jobDef.name ?? "unnamed",
65
+ schedule: jobDef.schedule,
66
+ task: jobDef.task,
67
+ channel: jobDef.channel ?? null,
68
+ enabled: jobDef.enabled !== false,
69
+ createdAt: new Date().toISOString(),
70
+ lastRun: null,
71
+ nextRun: null,
72
+ };
73
+
74
+ // Compute next run time
75
+ job.nextRun = await this._computeNextRun(job);
76
+
77
+ this._jobs.push(job);
78
+ await this._saveJobs();
79
+ return job;
80
+ }
81
+
82
+ async remove(id) {
83
+ const before = this._jobs.length;
84
+ this._jobs = this._jobs.filter(j => j.id !== id);
85
+ if (this._jobs.length < before) {
86
+ await this._saveJobs();
87
+ return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ async update(id, patch) {
93
+ const idx = this._jobs.findIndex(j => j.id === id);
94
+ if (idx === -1) return null;
95
+ this._jobs[idx] = { ...this._jobs[idx], ...patch };
96
+ // Recompute next run if schedule changed
97
+ if (patch.schedule) {
98
+ this._jobs[idx].nextRun = await this._computeNextRun(this._jobs[idx]);
99
+ }
100
+ await this._saveJobs();
101
+ return this._jobs[idx];
102
+ }
103
+
104
+ list() {
105
+ return [...this._jobs];
106
+ }
107
+
108
+ get(id) {
109
+ return this._jobs.find(j => j.id === id) ?? null;
110
+ }
111
+
112
+ // ── Scheduler ─────────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Start the scheduler loop
116
+ */
117
+ start() {
118
+ if (this._running) return;
119
+ this._running = true;
120
+ this._tick();
121
+ this._timer = setInterval(() => this._tick(), TICK_INTERVAL_MS);
122
+ console.error(`[wispy-cron] Scheduler started (checking every ${TICK_INTERVAL_MS / 1000}s)`);
123
+ }
124
+
125
+ /**
126
+ * Stop the scheduler
127
+ */
128
+ stop() {
129
+ this._running = false;
130
+ if (this._timer) {
131
+ clearInterval(this._timer);
132
+ this._timer = null;
133
+ }
134
+ console.error("[wispy-cron] Scheduler stopped");
135
+ }
136
+
137
+ /**
138
+ * Manually trigger a job right now
139
+ */
140
+ async runNow(id) {
141
+ const job = this.get(id);
142
+ if (!job) return { success: false, error: "Job not found" };
143
+ return this._executeJob(job);
144
+ }
145
+
146
+ // ── History ───────────────────────────────────────────────────────────────
147
+
148
+ async getHistory(id, limit = 20) {
149
+ const histFile = path.join(this.historyDir, `${id}.json`);
150
+ try {
151
+ const raw = await readFile(histFile, "utf8");
152
+ const history = JSON.parse(raw);
153
+ return history.slice(-limit);
154
+ } catch {
155
+ return [];
156
+ }
157
+ }
158
+
159
+ // ── Internal ──────────────────────────────────────────────────────────────
160
+
161
+ async _tick() {
162
+ const now = Date.now();
163
+ 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
+ });
173
+ }
174
+ }
175
+ }
176
+
177
+ async _executeJob(job) {
178
+ const startedAt = new Date().toISOString();
179
+ let output = "";
180
+ let status = "success";
181
+ let error = null;
182
+
183
+ 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
+
191
+ // Deliver to channel if configured
192
+ if (job.channel) {
193
+ await this._deliverToChannel(job.channel, job.name, output);
194
+ }
195
+ } catch (err) {
196
+ status = "failed";
197
+ error = err.message;
198
+ output = `Error: ${err.message}`;
199
+ }
200
+
201
+ const runRecord = {
202
+ jobId: job.id,
203
+ startedAt,
204
+ completedAt: new Date().toISOString(),
205
+ status,
206
+ output: output.slice(0, 2000),
207
+ error,
208
+ };
209
+
210
+ // Save to history
211
+ await this._appendHistory(job.id, runRecord);
212
+
213
+ // Update job metadata
214
+ const idx = this._jobs.findIndex(j => j.id === job.id);
215
+ if (idx !== -1) {
216
+ this._jobs[idx].lastRun = startedAt;
217
+ this._jobs[idx].nextRun = await this._computeNextRun(this._jobs[idx]);
218
+ await this._saveJobs();
219
+ }
220
+
221
+ return { success: status === "success", output, error };
222
+ }
223
+
224
+ async _deliverToChannel(channel, jobName, output) {
225
+ const [type, id] = channel.split(":");
226
+ const message = `🌿 **${jobName}**\n\n${output}`;
227
+
228
+ try {
229
+ switch (type) {
230
+ case "telegram": {
231
+ const cfg = await this._loadChannelConfig("telegram");
232
+ if (!cfg?.token) return;
233
+ const url = `https://api.telegram.org/bot${cfg.token}/sendMessage`;
234
+ await fetch(url, {
235
+ method: "POST",
236
+ headers: { "Content-Type": "application/json" },
237
+ body: JSON.stringify({ chat_id: id, text: message.slice(0, 4096) }),
238
+ });
239
+ break;
240
+ }
241
+ case "discord": {
242
+ // Discord webhook format
243
+ const cfg = await this._loadChannelConfig("discord");
244
+ if (!cfg?.webhookUrl) return;
245
+ await fetch(cfg.webhookUrl, {
246
+ method: "POST",
247
+ headers: { "Content-Type": "application/json" },
248
+ body: JSON.stringify({ content: message.slice(0, 2000) }),
249
+ });
250
+ break;
251
+ }
252
+ default:
253
+ console.error(`[wispy-cron] Unknown channel type: ${type}`);
254
+ }
255
+ } catch (err) {
256
+ console.error(`[wispy-cron] Channel delivery failed: ${err.message}`);
257
+ }
258
+ }
259
+
260
+ async _loadChannelConfig(name) {
261
+ try {
262
+ const channelsPath = path.join(path.dirname(this.cronDir), "channels.json");
263
+ const raw = await readFile(channelsPath, "utf8");
264
+ const cfg = JSON.parse(raw);
265
+ return cfg[name] ?? null;
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
271
+ async _computeNextRun(job) {
272
+ const schedule = job.schedule;
273
+ if (!schedule) return null;
274
+
275
+ const now = new Date();
276
+
277
+ try {
278
+ if (schedule.kind === "at") {
279
+ const t = new Date(schedule.time);
280
+ // One-shot: if in the future, return it; if past, return null (disable)
281
+ return t > now ? t.toISOString() : null;
282
+ }
283
+
284
+ if (schedule.kind === "every") {
285
+ const intervalMs = schedule.ms ?? schedule.interval ?? 60_000;
286
+ // If lastRun exists, add interval to it; otherwise start from now
287
+ const base = job.lastRun ? new Date(job.lastRun).getTime() : Date.now();
288
+ return new Date(base + intervalMs).toISOString();
289
+ }
290
+
291
+ if (schedule.kind === "cron") {
292
+ const parser = await this._getCronParser();
293
+ if (!parser) {
294
+ console.error("[wispy-cron] cron-parser not available, skipping cron schedule");
295
+ return null;
296
+ }
297
+
298
+ const tz = schedule.tz ?? "UTC";
299
+ let interval;
300
+ // Support both v4 (parseExpression) and v5 (CronExpressionParser.parse) APIs
301
+ if (typeof parser.parse === "function") {
302
+ // v5: CronExpressionParser.parse(expr, options)
303
+ interval = parser.parse(schedule.expr, { currentDate: now, tz });
304
+ } else if (typeof parser.parseExpression === "function") {
305
+ // v4: module.parseExpression(expr, options)
306
+ interval = parser.parseExpression(schedule.expr, { currentDate: now, tz });
307
+ } else {
308
+ console.error("[wispy-cron] cron-parser API not recognized");
309
+ return null;
310
+ }
311
+ return interval.next().toISOString();
312
+ }
313
+ } catch (err) {
314
+ console.error(`[wispy-cron] Failed to compute next run: ${err.message}`);
315
+ }
316
+
317
+ return null;
318
+ }
319
+
320
+ async _appendHistory(jobId, record) {
321
+ const histFile = path.join(this.historyDir, `${jobId}.json`);
322
+ let history = [];
323
+ try {
324
+ history = JSON.parse(await readFile(histFile, "utf8"));
325
+ } catch {}
326
+ history.push(record);
327
+ if (history.length > MAX_HISTORY_PER_JOB) {
328
+ history = history.slice(-MAX_HISTORY_PER_JOB);
329
+ }
330
+ await writeFile(histFile, JSON.stringify(history, null, 2) + "\n", "utf8");
331
+ }
332
+
333
+ async _loadJobs() {
334
+ try {
335
+ const raw = await readFile(this.jobsFile, "utf8");
336
+ this._jobs = JSON.parse(raw);
337
+ } catch {
338
+ this._jobs = [];
339
+ }
340
+ }
341
+
342
+ async _saveJobs() {
343
+ await mkdir(this.cronDir, { recursive: true });
344
+ await writeFile(this.jobsFile, JSON.stringify(this._jobs, null, 2) + "\n", "utf8");
345
+ }
346
+ }