alvin-bot 4.4.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.
Files changed (136) hide show
  1. package/.env.example +43 -0
  2. package/BACKLOG.md +223 -0
  3. package/CHANGELOG.md +63 -0
  4. package/CLAUDE.example.md +152 -0
  5. package/CODE_OF_CONDUCT.md +52 -0
  6. package/CONTRIBUTING.md +72 -0
  7. package/LICENSE +21 -0
  8. package/README.md +529 -0
  9. package/SECURITY.md +38 -0
  10. package/SOUL.example.md +60 -0
  11. package/TOOLS.example.md +42 -0
  12. package/alvin-bot.config.example.json +24 -0
  13. package/bin/cli.js +1088 -0
  14. package/dist/.metadata_never_index +0 -0
  15. package/dist/claude.js +102 -0
  16. package/dist/config.js +65 -0
  17. package/dist/engine.js +90 -0
  18. package/dist/find-claude-binary.js +98 -0
  19. package/dist/handlers/commands.js +1489 -0
  20. package/dist/handlers/document.js +187 -0
  21. package/dist/handlers/message.js +200 -0
  22. package/dist/handlers/photo.js +154 -0
  23. package/dist/handlers/platform-message.js +275 -0
  24. package/dist/handlers/video.js +237 -0
  25. package/dist/handlers/voice.js +148 -0
  26. package/dist/i18n.js +299 -0
  27. package/dist/index.js +442 -0
  28. package/dist/init-data-dir.js +81 -0
  29. package/dist/middleware/auth.js +215 -0
  30. package/dist/migrate.js +139 -0
  31. package/dist/paths.js +87 -0
  32. package/dist/platforms/discord.js +161 -0
  33. package/dist/platforms/index.js +130 -0
  34. package/dist/platforms/signal.js +205 -0
  35. package/dist/platforms/slack.js +318 -0
  36. package/dist/platforms/telegram.js +111 -0
  37. package/dist/platforms/types.js +8 -0
  38. package/dist/platforms/whatsapp.js +648 -0
  39. package/dist/providers/claude-sdk-provider.js +173 -0
  40. package/dist/providers/codex-cli-provider.js +121 -0
  41. package/dist/providers/index.js +7 -0
  42. package/dist/providers/openai-compatible.js +388 -0
  43. package/dist/providers/registry.js +209 -0
  44. package/dist/providers/tool-executor.js +450 -0
  45. package/dist/providers/types.js +205 -0
  46. package/dist/services/access.js +144 -0
  47. package/dist/services/asset-index.js +230 -0
  48. package/dist/services/browser-manager.js +161 -0
  49. package/dist/services/browser.js +121 -0
  50. package/dist/services/compaction.js +129 -0
  51. package/dist/services/cron.js +462 -0
  52. package/dist/services/custom-tools.js +317 -0
  53. package/dist/services/delivery-queue.js +154 -0
  54. package/dist/services/elevenlabs.js +58 -0
  55. package/dist/services/embeddings.js +386 -0
  56. package/dist/services/exec-guard.js +46 -0
  57. package/dist/services/fallback-order.js +151 -0
  58. package/dist/services/heartbeat.js +192 -0
  59. package/dist/services/hooks.js +44 -0
  60. package/dist/services/imagegen.js +72 -0
  61. package/dist/services/language-detect.js +144 -0
  62. package/dist/services/markdown.js +63 -0
  63. package/dist/services/mcp.js +252 -0
  64. package/dist/services/memory.js +133 -0
  65. package/dist/services/personality.js +227 -0
  66. package/dist/services/plugins.js +171 -0
  67. package/dist/services/reminders.js +97 -0
  68. package/dist/services/restart.js +48 -0
  69. package/dist/services/security-audit.js +66 -0
  70. package/dist/services/self-search.js +129 -0
  71. package/dist/services/session.js +93 -0
  72. package/dist/services/skills.js +287 -0
  73. package/dist/services/standing-orders.js +29 -0
  74. package/dist/services/subagents.js +142 -0
  75. package/dist/services/sudo.js +243 -0
  76. package/dist/services/telegram.js +113 -0
  77. package/dist/services/tool-discovery.js +214 -0
  78. package/dist/services/usage-tracker.js +137 -0
  79. package/dist/services/users.js +199 -0
  80. package/dist/services/voice.js +95 -0
  81. package/dist/tui/index.js +507 -0
  82. package/dist/web/canvas.js +30 -0
  83. package/dist/web/doctor-api.js +606 -0
  84. package/dist/web/openai-compat.js +252 -0
  85. package/dist/web/server.js +1351 -0
  86. package/dist/web/setup-api.js +1078 -0
  87. package/docs/mcp.example.json +16 -0
  88. package/docs/screenshots/00-Login.png +0 -0
  89. package/docs/screenshots/01-Chat-Dark-Conversation.png +0 -0
  90. package/docs/screenshots/02-Chat.png +0 -0
  91. package/docs/screenshots/03-Dashboard-Overview.png +0 -0
  92. package/docs/screenshots/04-AI-Models-and-Providers.png +0 -0
  93. package/docs/screenshots/05-Personality-Editor.png +0 -0
  94. package/docs/screenshots/06-Memory-Manager.png +0 -0
  95. package/docs/screenshots/07-Active-Sessions.png +0 -0
  96. package/docs/screenshots/08-File-Browser.png +0 -0
  97. package/docs/screenshots/09-Scheduled-Jobs.png +0 -0
  98. package/docs/screenshots/10-Custom-Tools.png +0 -0
  99. package/docs/screenshots/11-Plugins-and-MCP.png +0 -0
  100. package/docs/screenshots/12-Messaging-Platforms.png +0 -0
  101. package/docs/screenshots/12.1-Messaging-Platforms-WhatsApp-Groups-List.png +0 -0
  102. package/docs/screenshots/12.2-Messaging-Platforms-WA-Group-Details.png +0 -0
  103. package/docs/screenshots/13-User-Management.png +0 -0
  104. package/docs/screenshots/14-Web-Terminal.png +0 -0
  105. package/docs/screenshots/15-Maintenance-and-Health.png +0 -0
  106. package/docs/screenshots/16-Settings-and-Env.png +0 -0
  107. package/docs/screenshots/TG-commands.png +0 -0
  108. package/docs/screenshots/TG.png +0 -0
  109. package/docs/screenshots/_Mac-Installer.png +0 -0
  110. package/docs/tools.example.json +33 -0
  111. package/install.sh +165 -0
  112. package/package.json +190 -0
  113. package/plugins/calendar/index.js +270 -0
  114. package/plugins/email/index.js +231 -0
  115. package/plugins/finance/index.js +254 -0
  116. package/plugins/notes/index.js +227 -0
  117. package/plugins/smarthome/index.js +230 -0
  118. package/plugins/weather/index.js +122 -0
  119. package/skills/apple-notes/SKILL.md +31 -0
  120. package/skills/browse/SKILL.md +136 -0
  121. package/skills/code-project/SKILL.md +43 -0
  122. package/skills/data-analysis/SKILL.md +39 -0
  123. package/skills/document-creation/SKILL.md +48 -0
  124. package/skills/email-summary/SKILL.md +46 -0
  125. package/skills/github/SKILL.md +42 -0
  126. package/skills/summarize/SKILL.md +28 -0
  127. package/skills/system-admin/SKILL.md +39 -0
  128. package/skills/weather/SKILL.md +34 -0
  129. package/skills/web-research/SKILL.md +35 -0
  130. package/web/public/canvas.html +52 -0
  131. package/web/public/css/style.css +555 -0
  132. package/web/public/index.html +189 -0
  133. package/web/public/js/app.js +3102 -0
  134. package/web/public/js/i18n.js +1048 -0
  135. package/web/public/js/icons.js +104 -0
  136. package/web/public/login.html +48 -0
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Cron Service — Persistent scheduled tasks.
3
+ *
4
+ * Supports:
5
+ * - Interval-based jobs (every 5m, 1h, etc.)
6
+ * - Cron expressions (0 9 * * 1 = every Monday 9am)
7
+ * - One-shot scheduled tasks (run once at a specific time)
8
+ * - Job types: reminder, shell, ai-query, http
9
+ * - Management via /cron command + Web UI
10
+ * - Persisted to docs/cron-jobs.json (survives restarts)
11
+ */
12
+ import fs from "fs";
13
+ import { execSync } from "child_process";
14
+ import { dirname } from "path";
15
+ import { getRegistry } from "../engine.js";
16
+ import { CRON_FILE, BOT_ROOT } from "../paths.js";
17
+ // ── Storage ─────────────────────────────────────────────
18
+ function loadJobs() {
19
+ try {
20
+ return JSON.parse(fs.readFileSync(CRON_FILE, "utf-8"));
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
26
+ function saveJobs(jobs) {
27
+ const dir = dirname(CRON_FILE);
28
+ if (!fs.existsSync(dir))
29
+ fs.mkdirSync(dir, { recursive: true });
30
+ fs.writeFileSync(CRON_FILE, JSON.stringify(jobs, null, 2));
31
+ }
32
+ // ── Cron Parsing ────────────────────────────────────────
33
+ /**
34
+ * Parse an interval string (5m, 1h, 30s, 2d) to milliseconds.
35
+ */
36
+ function parseInterval(input) {
37
+ const match = input.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)s?$/i);
38
+ if (!match)
39
+ return null;
40
+ const value = parseFloat(match[1]);
41
+ const unit = match[2].toLowerCase();
42
+ const mult = { s: 1000, sec: 1000, m: 60_000, min: 60_000, h: 3_600_000, hr: 3_600_000, d: 86_400_000, day: 86_400_000 };
43
+ return value * (mult[unit] || 60_000);
44
+ }
45
+ /**
46
+ * Parse a cron expression and find the next run time.
47
+ * Supports: minute hour day month weekday
48
+ * Simple implementation — covers common cases.
49
+ */
50
+ function nextCronRun(expression, after = new Date()) {
51
+ const parts = expression.trim().split(/\s+/);
52
+ if (parts.length !== 5)
53
+ return null;
54
+ const [minExpr, hourExpr, dayExpr, monthExpr, weekdayExpr] = parts;
55
+ function parseField(expr, min, max) {
56
+ if (expr === "*")
57
+ return Array.from({ length: max - min + 1 }, (_, i) => i + min);
58
+ if (expr.includes("/")) {
59
+ const [, step] = expr.split("/");
60
+ const s = parseInt(step);
61
+ return Array.from({ length: max - min + 1 }, (_, i) => i + min).filter(v => v % s === 0);
62
+ }
63
+ if (expr.includes(","))
64
+ return expr.split(",").map(Number);
65
+ if (expr.includes("-")) {
66
+ const [a, b] = expr.split("-").map(Number);
67
+ return Array.from({ length: b - a + 1 }, (_, i) => i + a);
68
+ }
69
+ return [parseInt(expr)];
70
+ }
71
+ const minutes = parseField(minExpr, 0, 59);
72
+ const hours = parseField(hourExpr, 0, 23);
73
+ const days = parseField(dayExpr, 1, 31);
74
+ const months = parseField(monthExpr, 1, 12);
75
+ const weekdays = parseField(weekdayExpr, 0, 6); // 0=Sun
76
+ // Search forward up to 366 days
77
+ const candidate = new Date(after);
78
+ candidate.setSeconds(0, 0);
79
+ candidate.setMinutes(candidate.getMinutes() + 1);
80
+ for (let i = 0; i < 366 * 24 * 60; i++) {
81
+ const m = candidate.getMinutes();
82
+ const h = candidate.getHours();
83
+ const d = candidate.getDate();
84
+ const mo = candidate.getMonth() + 1;
85
+ const wd = candidate.getDay();
86
+ if (minutes.includes(m) && hours.includes(h) && days.includes(d) && months.includes(mo) && weekdays.includes(wd)) {
87
+ return candidate;
88
+ }
89
+ candidate.setMinutes(candidate.getMinutes() + 1);
90
+ }
91
+ return null;
92
+ }
93
+ /**
94
+ * Calculate next run time for a job.
95
+ */
96
+ function calculateNextRun(job) {
97
+ if (!job.enabled)
98
+ return null;
99
+ // Interval-based
100
+ const intervalMs = parseInterval(job.schedule);
101
+ if (intervalMs) {
102
+ const base = job.lastRunAt || job.createdAt;
103
+ return base + intervalMs;
104
+ }
105
+ // Cron expression
106
+ const next = nextCronRun(job.schedule);
107
+ return next ? next.getTime() : null;
108
+ }
109
+ let notifyCallback = null;
110
+ export function setNotifyCallback(fn) {
111
+ notifyCallback = fn;
112
+ }
113
+ async function executeJob(job) {
114
+ try {
115
+ switch (job.type) {
116
+ case "reminder":
117
+ case "message": {
118
+ const text = job.payload.text || "(no message)";
119
+ if (notifyCallback) {
120
+ await notifyCallback(job.target, `⏰ ${job.name}\n\n${text}`);
121
+ }
122
+ return { output: `Sent: ${text.slice(0, 100)}` };
123
+ }
124
+ case "shell": {
125
+ const cmd = job.payload.command || "echo 'no command'";
126
+ const output = execSync(cmd, {
127
+ timeout: 60_000,
128
+ stdio: "pipe",
129
+ env: { ...process.env, PATH: process.env.PATH + ":/opt/homebrew/bin:/usr/local/bin" },
130
+ }).toString().trim();
131
+ // Notify with output
132
+ if (notifyCallback && output) {
133
+ await notifyCallback(job.target, `🔧 ${job.name}\n\`\`\`\n${output.slice(0, 3000)}\n\`\`\``);
134
+ }
135
+ return { output: output.slice(0, 5000) };
136
+ }
137
+ case "http": {
138
+ const url = job.payload.url || "";
139
+ const method = job.payload.method || "GET";
140
+ const headers = job.payload.headers || {};
141
+ const fetchOpts = { method, headers };
142
+ if (job.payload.body && method !== "GET") {
143
+ fetchOpts.body = job.payload.body;
144
+ }
145
+ const res = await fetch(url, fetchOpts);
146
+ const text = await res.text();
147
+ const output = `HTTP ${res.status}: ${text.slice(0, 2000)}`;
148
+ if (notifyCallback) {
149
+ await notifyCallback(job.target, `🌐 ${job.name}\n${output.slice(0, 500)}`);
150
+ }
151
+ return { output };
152
+ }
153
+ case "ai-query": {
154
+ // AI queries run through the actual AI engine (Claude SDK)
155
+ const prompt = job.payload.prompt || "";
156
+ try {
157
+ const registry = getRegistry();
158
+ const queryOpts = {
159
+ prompt,
160
+ systemPrompt: `You are Alvin Bot, an autonomous AI assistant. You are currently executing a scheduled cron job ("${job.name}"). Reply concisely. Use Telegram-compatible Markdown. You have access to tools (Bash, files, etc.) — use them if needed.`,
161
+ effort: "high",
162
+ workingDir: BOT_ROOT,
163
+ };
164
+ let fullResponse = "";
165
+ for await (const chunk of registry.queryWithFallback(queryOpts)) {
166
+ if (chunk.type === "text" && chunk.text) {
167
+ fullResponse = chunk.text;
168
+ }
169
+ if (chunk.type === "error") {
170
+ throw new Error(chunk.error || "AI query failed");
171
+ }
172
+ if (chunk.type === "done") {
173
+ break;
174
+ }
175
+ }
176
+ // Send AI response to target
177
+ if (notifyCallback && fullResponse.trim()) {
178
+ // Split long responses into chunks (Telegram limit ~4096 chars)
179
+ const maxLen = 3900;
180
+ if (fullResponse.length <= maxLen) {
181
+ await notifyCallback(job.target, fullResponse);
182
+ }
183
+ else {
184
+ const parts = [];
185
+ for (let i = 0; i < fullResponse.length; i += maxLen) {
186
+ parts.push(fullResponse.slice(i, i + maxLen));
187
+ }
188
+ for (const part of parts) {
189
+ await notifyCallback(job.target, part);
190
+ }
191
+ }
192
+ }
193
+ return { output: fullResponse.slice(0, 500) };
194
+ }
195
+ catch (err) {
196
+ const error = err instanceof Error ? err.message : String(err);
197
+ if (notifyCallback) {
198
+ await notifyCallback(job.target, `❌ AI-Query Error (${job.name}): ${error}`);
199
+ }
200
+ throw err;
201
+ }
202
+ }
203
+ default:
204
+ return { output: "", error: `Unknown job type: ${job.type}` };
205
+ }
206
+ }
207
+ catch (err) {
208
+ const error = err instanceof Error ? err.message : String(err);
209
+ if (notifyCallback) {
210
+ await notifyCallback(job.target, `❌ Cron Error (${job.name}): ${error}`);
211
+ }
212
+ return { output: "", error };
213
+ }
214
+ }
215
+ // ── Scheduler Loop ──────────────────────────────────────
216
+ let schedulerTimer = null;
217
+ const runningJobs = new Set(); // Guard against overlapping executions
218
+ export function startScheduler() {
219
+ if (schedulerTimer)
220
+ return;
221
+ // Check every 30 seconds for due jobs
222
+ schedulerTimer = setInterval(async () => {
223
+ const jobs = loadJobs();
224
+ const now = Date.now();
225
+ let changed = false;
226
+ for (const job of jobs) {
227
+ if (!job.enabled)
228
+ continue;
229
+ // Skip if this job is already running
230
+ if (runningJobs.has(job.id))
231
+ continue;
232
+ // Calculate next run if not set
233
+ if (!job.nextRunAt) {
234
+ job.nextRunAt = calculateNextRun(job);
235
+ changed = true;
236
+ }
237
+ if (job.nextRunAt && now >= job.nextRunAt) {
238
+ console.log(`Cron: Running job "${job.name}" (${job.id})`);
239
+ // Mark as running + clear nextRunAt BEFORE async execution to prevent re-trigger
240
+ runningJobs.add(job.id);
241
+ job.nextRunAt = null;
242
+ saveJobs(jobs);
243
+ try {
244
+ const result = await executeJob(job);
245
+ // Re-load jobs in case they were modified during execution
246
+ const freshJobs = loadJobs();
247
+ const freshJob = freshJobs.find(j => j.id === job.id);
248
+ if (freshJob) {
249
+ freshJob.lastRunAt = now;
250
+ freshJob.lastResult = result.output.slice(0, 500);
251
+ freshJob.lastError = result.error || null;
252
+ freshJob.runCount++;
253
+ if (freshJob.oneShot) {
254
+ freshJob.enabled = false;
255
+ freshJob.nextRunAt = null;
256
+ }
257
+ else {
258
+ freshJob.nextRunAt = calculateNextRun(freshJob);
259
+ }
260
+ saveJobs(freshJobs);
261
+ }
262
+ }
263
+ finally {
264
+ runningJobs.delete(job.id);
265
+ }
266
+ continue; // Skip the outer changed/save since we save inside
267
+ }
268
+ }
269
+ if (changed)
270
+ saveJobs(jobs);
271
+ }, 30_000);
272
+ console.log("⏰ Cron scheduler started (30s interval)");
273
+ }
274
+ export function stopScheduler() {
275
+ if (schedulerTimer) {
276
+ clearInterval(schedulerTimer);
277
+ schedulerTimer = null;
278
+ }
279
+ }
280
+ // ── Public CRUD API ─────────────────────────────────────
281
+ function generateId() {
282
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
283
+ }
284
+ export function createJob(input) {
285
+ const job = {
286
+ id: generateId(),
287
+ name: input.name,
288
+ type: input.type,
289
+ schedule: input.schedule,
290
+ oneShot: input.oneShot ?? false,
291
+ payload: input.payload,
292
+ target: input.target,
293
+ enabled: input.enabled ?? true,
294
+ createdAt: Date.now(),
295
+ lastRunAt: null,
296
+ lastResult: null,
297
+ lastError: null,
298
+ nextRunAt: null,
299
+ runCount: 0,
300
+ createdBy: input.createdBy || "unknown",
301
+ };
302
+ // Calculate first run
303
+ job.nextRunAt = calculateNextRun(job);
304
+ const jobs = loadJobs();
305
+ jobs.push(job);
306
+ saveJobs(jobs);
307
+ return job;
308
+ }
309
+ export function listJobs() {
310
+ return loadJobs();
311
+ }
312
+ export function getJob(id) {
313
+ return loadJobs().find(j => j.id === id);
314
+ }
315
+ export function updateJob(id, updates) {
316
+ const jobs = loadJobs();
317
+ const idx = jobs.findIndex(j => j.id === id);
318
+ if (idx < 0)
319
+ return null;
320
+ Object.assign(jobs[idx], updates);
321
+ if (updates.schedule || updates.enabled !== undefined) {
322
+ jobs[idx].nextRunAt = calculateNextRun(jobs[idx]);
323
+ }
324
+ saveJobs(jobs);
325
+ return jobs[idx];
326
+ }
327
+ export function deleteJob(id) {
328
+ const jobs = loadJobs();
329
+ const filtered = jobs.filter(j => j.id !== id);
330
+ if (filtered.length === jobs.length)
331
+ return false;
332
+ saveJobs(filtered);
333
+ return true;
334
+ }
335
+ export function toggleJob(id) {
336
+ const jobs = loadJobs();
337
+ const job = jobs.find(j => j.id === id);
338
+ if (!job)
339
+ return null;
340
+ job.enabled = !job.enabled;
341
+ job.nextRunAt = calculateNextRun(job);
342
+ saveJobs(jobs);
343
+ return job;
344
+ }
345
+ export function runJobNow(id) {
346
+ const job = getJob(id);
347
+ if (!job)
348
+ return null;
349
+ return executeJob(job);
350
+ }
351
+ /**
352
+ * Convert a cron expression or interval string to a human-readable German description.
353
+ */
354
+ export function humanReadableSchedule(schedule) {
355
+ // Interval strings (5m, 1h, 30s, 2d)
356
+ const intervalMatch = schedule.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)s?$/i);
357
+ if (intervalMatch) {
358
+ const value = parseFloat(intervalMatch[1]);
359
+ const unit = intervalMatch[2].toLowerCase();
360
+ const labels = {
361
+ s: ["second", "seconds"], sec: ["second", "seconds"],
362
+ m: ["minute", "minutes"], min: ["minute", "minutes"],
363
+ h: ["hour", "hours"], hr: ["hour", "hours"],
364
+ d: ["day", "days"], day: ["day", "days"],
365
+ };
366
+ const [sing, plur] = labels[unit] || ["?", "?"];
367
+ return `Every ${value} ${value === 1 ? sing : plur}`;
368
+ }
369
+ // Cron expression: MIN HOUR DAY MONTH WEEKDAY
370
+ const parts = schedule.trim().split(/\s+/);
371
+ if (parts.length !== 5)
372
+ return schedule;
373
+ const [minExpr, hourExpr, dayExpr, monthExpr, weekdayExpr] = parts;
374
+ const weekdayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
375
+ const monthNames = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
376
+ // Helper: format time from hour + minute expressions
377
+ function formatTime(h, m) {
378
+ if (h === "*" && m === "*")
379
+ return "";
380
+ const hh = h === "*" ? "*" : h.padStart(2, "0");
381
+ const mm = m === "*" ? "00" : m.padStart(2, "0");
382
+ return `${hh}:${mm}`;
383
+ }
384
+ // Helper: expand comma/range fields to readable list
385
+ function expandField(expr, names) {
386
+ if (expr === "*")
387
+ return "";
388
+ const vals = expr.split(",").map(v => {
389
+ if (v.includes("-")) {
390
+ const [a, b] = v.split("-");
391
+ if (names)
392
+ return `${names[+a]}–${names[+b]}`;
393
+ return `${a}–${b}`;
394
+ }
395
+ return names ? (names[+v] || v) : v;
396
+ });
397
+ return vals.join(", ");
398
+ }
399
+ const time = formatTime(hourExpr, minExpr);
400
+ const hasStep = [minExpr, hourExpr].some(e => e.includes("/"));
401
+ // Every X minutes/hours
402
+ if (minExpr.includes("/") && hourExpr === "*" && dayExpr === "*" && monthExpr === "*" && weekdayExpr === "*") {
403
+ const step = minExpr.split("/")[1];
404
+ return `Every ${step} min`;
405
+ }
406
+ if (hourExpr.includes("/") && dayExpr === "*" && monthExpr === "*" && weekdayExpr === "*") {
407
+ const step = hourExpr.split("/")[1];
408
+ return `Every ${step}h`;
409
+ }
410
+ // Build description
411
+ const descParts = [];
412
+ // Weekday specific
413
+ if (weekdayExpr !== "*") {
414
+ const days = expandField(weekdayExpr, weekdayNames);
415
+ if (weekdayExpr === "1-5")
416
+ descParts.push("Weekdays");
417
+ else if (weekdayExpr === "0,6" || weekdayExpr === "6,0")
418
+ descParts.push("Weekends");
419
+ else
420
+ descParts.push(`Every ${days}`);
421
+ }
422
+ // Day of month specific
423
+ else if (dayExpr !== "*") {
424
+ const dayList = expandField(dayExpr);
425
+ if (monthExpr !== "*") {
426
+ const monthList = expandField(monthExpr, monthNames);
427
+ descParts.push(`On the ${dayList}. of ${monthList}`);
428
+ }
429
+ else {
430
+ descParts.push(`On the ${dayList}. of every month`);
431
+ }
432
+ }
433
+ // Month specific only
434
+ else if (monthExpr !== "*") {
435
+ const monthList = expandField(monthExpr, monthNames);
436
+ descParts.push(`In ${monthList}`);
437
+ }
438
+ // Daily (all wildcards except time)
439
+ else if (!hasStep) {
440
+ descParts.push("Daily");
441
+ }
442
+ if (time && !hasStep)
443
+ descParts.push(time);
444
+ return descParts.join(", ") || schedule;
445
+ }
446
+ /**
447
+ * Format next run time as human-readable.
448
+ */
449
+ export function formatNextRun(nextRunAt) {
450
+ if (!nextRunAt)
451
+ return "—";
452
+ const diff = nextRunAt - Date.now();
453
+ if (diff < 0)
454
+ return "overdue";
455
+ if (diff < 60_000)
456
+ return `in ${Math.round(diff / 1000)}s`;
457
+ if (diff < 3_600_000)
458
+ return `in ${Math.round(diff / 60_000)} Min`;
459
+ if (diff < 86_400_000)
460
+ return `in ${(diff / 3_600_000).toFixed(1)}h`;
461
+ return `in ${(diff / 86_400_000).toFixed(1)} days`;
462
+ }