clementine-agent 1.18.110 → 1.18.112
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/dist/agent/run-agent-cron.js +63 -1
- package/dist/agent/skill-extractor.d.ts +4 -1
- package/dist/agent/skill-extractor.js +125 -23
- package/dist/cli/dashboard.js +124 -21
- package/package.json +1 -1
|
@@ -279,6 +279,13 @@ export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSki
|
|
|
279
279
|
}
|
|
280
280
|
if (prepared.length === 0)
|
|
281
281
|
return { text: '', applied, missing };
|
|
282
|
+
// Folder-form bundled-file budget. Anthropic skill spec says the body
|
|
283
|
+
// should be ≤500 lines; bundled files (templates/, reference docs)
|
|
284
|
+
// load on top. We cap aggregate inlined bundle bytes so a skill with a
|
|
285
|
+
// huge templates/ tree doesn't blow the context window — anything over
|
|
286
|
+
// the cap is left on disk and the LLM can Read it via the cron's cwd.
|
|
287
|
+
const BUNDLE_FILE_CAP = 5;
|
|
288
|
+
const BUNDLE_BYTES_CAP = 12000;
|
|
282
289
|
const skillLines = prepared.map(s => {
|
|
283
290
|
recordSkillUse(s.name);
|
|
284
291
|
memoryStore?.logSkillUse?.({
|
|
@@ -292,7 +299,62 @@ export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSki
|
|
|
292
299
|
let block = `### ${s.title}${s.source === 'pinned' ? ' _(pinned)_' : ''}\n${s.content}`;
|
|
293
300
|
if (s.toolsUsed.length > 0)
|
|
294
301
|
block += `\n**Tools:** ${s.toolsUsed.join(', ')}`;
|
|
295
|
-
|
|
302
|
+
// Folder-form skills (post-migration default): inline sibling .md
|
|
303
|
+
// files (templates/intro.md, reference.md, etc.) so the cron prompt
|
|
304
|
+
// actually sees them. SKILL.md itself is the body above. scripts/
|
|
305
|
+
// and other non-.md assets stay on disk — the cron's cwd has Bash
|
|
306
|
+
// access to them via the runtime working directory.
|
|
307
|
+
const folderPath = path.join(s.skillDir, s.name);
|
|
308
|
+
const skillEntry = path.join(folderPath, 'SKILL.md');
|
|
309
|
+
if (fs.existsSync(skillEntry)) {
|
|
310
|
+
let bytesUsed = 0;
|
|
311
|
+
let filesUsed = 0;
|
|
312
|
+
const collectMd = (subDir, label) => {
|
|
313
|
+
if (filesUsed >= BUNDLE_FILE_CAP || bytesUsed >= BUNDLE_BYTES_CAP)
|
|
314
|
+
return;
|
|
315
|
+
let entries;
|
|
316
|
+
try {
|
|
317
|
+
entries = fs.readdirSync(subDir).sort();
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
for (const entry of entries) {
|
|
323
|
+
if (filesUsed >= BUNDLE_FILE_CAP || bytesUsed >= BUNDLE_BYTES_CAP)
|
|
324
|
+
break;
|
|
325
|
+
if (entry === 'SKILL.md')
|
|
326
|
+
continue;
|
|
327
|
+
if (!entry.endsWith('.md'))
|
|
328
|
+
continue;
|
|
329
|
+
const full = path.join(subDir, entry);
|
|
330
|
+
try {
|
|
331
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
332
|
+
const remaining = BUNDLE_BYTES_CAP - bytesUsed;
|
|
333
|
+
const slice = content.slice(0, remaining);
|
|
334
|
+
const labeled = label ? `${label}/${entry}` : entry;
|
|
335
|
+
block += `\n\n#### ${labeled}\n${slice}`;
|
|
336
|
+
bytesUsed += slice.length;
|
|
337
|
+
filesUsed++;
|
|
338
|
+
}
|
|
339
|
+
catch { /* skip unreadable */ }
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
// Top-level bundled .md files (reference.md, etc.)
|
|
343
|
+
collectMd(folderPath, '');
|
|
344
|
+
// Common sub-dirs: templates/, references/. One level deep only —
|
|
345
|
+
// we don't recurse to keep the budget predictable.
|
|
346
|
+
for (const sub of ['templates', 'references']) {
|
|
347
|
+
if (filesUsed >= BUNDLE_FILE_CAP || bytesUsed >= BUNDLE_BYTES_CAP)
|
|
348
|
+
break;
|
|
349
|
+
const subPath = path.join(folderPath, sub);
|
|
350
|
+
if (fs.existsSync(subPath) && fs.statSync(subPath).isDirectory()) {
|
|
351
|
+
collectMd(subPath, sub);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else if (s.attachments.length > 0) {
|
|
356
|
+
// Legacy flat form: attachments live under <skill>.files/ alongside
|
|
357
|
+
// the skill .md. Kept for backward compat with un-migrated skills.
|
|
296
358
|
const attDir = path.join(s.skillDir, s.name + '.files');
|
|
297
359
|
for (const attName of s.attachments.slice(0, 3)) {
|
|
298
360
|
const attPath = path.join(attDir, attName);
|
|
@@ -74,7 +74,10 @@ export declare function searchSkills(query: string, limit?: number, agentSlug?:
|
|
|
74
74
|
export declare function loadSkillByName(name: string, agentSlug?: string, opts?: {
|
|
75
75
|
suppressedNames?: Set<string>;
|
|
76
76
|
}): SkillMatch | null;
|
|
77
|
-
/** Record that a skill was used (bump use count).
|
|
77
|
+
/** Record that a skill was used (bump use count). Handles both flat and
|
|
78
|
+
* folder-form skills. For folder form the counter lives under
|
|
79
|
+
* `clementine.useCount` (Anthropic-canonical frontmatter keeps top-level
|
|
80
|
+
* reserved for `name`/`description`). */
|
|
78
81
|
export declare function recordSkillUse(skillName: string, agentSlug?: string): void;
|
|
79
82
|
/** List all active skills (global + all agent-scoped). */
|
|
80
83
|
export declare function listSkills(agentSlug?: string): Array<{
|
|
@@ -325,11 +325,39 @@ async function mergeSkill(assistant, existing, incoming) {
|
|
|
325
325
|
*/
|
|
326
326
|
const skillEmbeddingCache = new Map();
|
|
327
327
|
/**
|
|
328
|
-
*
|
|
328
|
+
* Compute the canonical skill slug from a relative path.
|
|
329
|
+
*
|
|
330
|
+
* Two layouts are supported:
|
|
331
|
+
* - Folder form (Anthropic spec): `<name>/SKILL.md` → `<name>`
|
|
332
|
+
* (sibling .md files in that folder are bundled docs, not standalone skills)
|
|
333
|
+
* - Flat form (legacy): `<name>.md` → `<name>`
|
|
334
|
+
* (auto-generated MCP skills like `auto/discord/send.md` → `auto-discord-send`)
|
|
335
|
+
*
|
|
336
|
+
* NOTE: this is the single source of truth for "what slug should this file be
|
|
337
|
+
* loaded under." Both walkSkillFiles and loadSkillByName route through it so
|
|
338
|
+
* the dedupe/naming stays consistent.
|
|
339
|
+
*/
|
|
340
|
+
function slugFromRel(relPath) {
|
|
341
|
+
// Folder form: <a>/<b>/SKILL.md → <a>-<b>
|
|
342
|
+
if (/(?:^|[\\/])SKILL\.md$/.test(relPath)) {
|
|
343
|
+
const parts = relPath.replace(/[\\/]SKILL\.md$/, '').split(/[\\/]/);
|
|
344
|
+
return parts.join('-');
|
|
345
|
+
}
|
|
346
|
+
// Flat form: any other .md file
|
|
347
|
+
return relPath.replace(/\.md$/, '').replace(/[\\/]/g, '-');
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Recursively list every skill file under `dir`. Returns absolute
|
|
329
351
|
* paths, relative paths (for dedupe/naming), and an `isAuto` flag set
|
|
330
|
-
* when the file lives under an `auto/` subtree.
|
|
331
|
-
*
|
|
332
|
-
*
|
|
352
|
+
* when the file lives under an `auto/` subtree.
|
|
353
|
+
*
|
|
354
|
+
* Folder-form skills (`<name>/SKILL.md`) emit ONLY the SKILL.md and do
|
|
355
|
+
* not recurse — sibling .md files (templates/, reference docs) and
|
|
356
|
+
* subdirectories (scripts/) are bundled assets, not standalone skills.
|
|
357
|
+
*
|
|
358
|
+
* Flat-form skills (`<name>.md`, including auto-generated MCP skills
|
|
359
|
+
* under `auto/<server>/<tool>.md`) keep the legacy behavior so existing
|
|
360
|
+
* unmigrated installs don't regress.
|
|
333
361
|
*/
|
|
334
362
|
function walkSkillFiles(root) {
|
|
335
363
|
const out = [];
|
|
@@ -341,6 +369,20 @@ function walkSkillFiles(root) {
|
|
|
341
369
|
catch {
|
|
342
370
|
return;
|
|
343
371
|
}
|
|
372
|
+
// Folder-form short-circuit: a sub-directory with a SKILL.md is a bundled
|
|
373
|
+
// skill folder. Emit just the SKILL.md and skip everything else inside —
|
|
374
|
+
// bundled docs/scripts are not separate skills.
|
|
375
|
+
if (rel) {
|
|
376
|
+
const skillEntry = entries.find(e => e.isFile() && e.name === 'SKILL.md');
|
|
377
|
+
if (skillEntry) {
|
|
378
|
+
out.push({
|
|
379
|
+
filePath: path.join(dir, skillEntry.name),
|
|
380
|
+
relPath: path.join(rel, skillEntry.name),
|
|
381
|
+
isAuto: rel.split(path.sep)[0] === 'auto',
|
|
382
|
+
});
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
344
386
|
for (const ent of entries) {
|
|
345
387
|
const name = ent.name;
|
|
346
388
|
// Skip backup files and hidden files
|
|
@@ -401,9 +443,10 @@ export function searchSkills(query, limit = 3, agentSlug, opts) {
|
|
|
401
443
|
// tiebreak even when both match the query.
|
|
402
444
|
const files = walkSkillFiles(dir);
|
|
403
445
|
for (const { filePath, relPath, isAuto } of files) {
|
|
404
|
-
// Use
|
|
405
|
-
//
|
|
406
|
-
|
|
446
|
+
// Use slugFromRel so folder-form skills (`<name>/SKILL.md`) get
|
|
447
|
+
// their canonical name `<name>` and not `<name>-SKILL`. Same dedupe
|
|
448
|
+
// contract: distinct files cannot share a name.
|
|
449
|
+
const name = slugFromRel(relPath);
|
|
407
450
|
if (seen.has(name))
|
|
408
451
|
continue;
|
|
409
452
|
seen.add(name);
|
|
@@ -521,18 +564,61 @@ export function loadSkillByName(name, agentSlug, opts) {
|
|
|
521
564
|
if (opts?.suppressedNames?.has(name))
|
|
522
565
|
return null;
|
|
523
566
|
for (const dir of dirs) {
|
|
567
|
+
// Fast path: folder form. `<dir>/<name>/SKILL.md` is the canonical
|
|
568
|
+
// Anthropic layout post-migration. Try it first so a slug like
|
|
569
|
+
// `morning-briefing` resolves to `morning-briefing/SKILL.md`.
|
|
570
|
+
const folderEntry = path.join(dir, name, 'SKILL.md');
|
|
571
|
+
if (existsSync(folderEntry)) {
|
|
572
|
+
try {
|
|
573
|
+
const raw = readFileSync(folderEntry, 'utf-8');
|
|
574
|
+
const parsed = matter(raw);
|
|
575
|
+
const fmTools = parsed.data?.clementine?.tools?.allow ?? parsed.data?.toolsUsed ?? [];
|
|
576
|
+
return {
|
|
577
|
+
name,
|
|
578
|
+
title: parsed.data.title ?? parsed.data.name ?? name,
|
|
579
|
+
content: parsed.content, // full body — the runtime cap is gone
|
|
580
|
+
score: 0,
|
|
581
|
+
toolsUsed: Array.isArray(fmTools) ? fmTools : [],
|
|
582
|
+
attachments: parsed.data.attachments ?? [],
|
|
583
|
+
skillDir: dir,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
// Fast path: flat form. `<dir>/<name>.md` (legacy + auto-discovery).
|
|
591
|
+
const flatEntry = path.join(dir, `${name}.md`);
|
|
592
|
+
if (existsSync(flatEntry)) {
|
|
593
|
+
try {
|
|
594
|
+
const raw = readFileSync(flatEntry, 'utf-8');
|
|
595
|
+
const parsed = matter(raw);
|
|
596
|
+
return {
|
|
597
|
+
name,
|
|
598
|
+
title: parsed.data.title ?? name,
|
|
599
|
+
content: parsed.content, // full body — the runtime cap is gone
|
|
600
|
+
score: 0,
|
|
601
|
+
toolsUsed: parsed.data.toolsUsed ?? [],
|
|
602
|
+
attachments: parsed.data.attachments ?? [],
|
|
603
|
+
skillDir: dir,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// Fallback: walk for nested forms (e.g. `auto/discord/send` slug).
|
|
524
611
|
const files = walkSkillFiles(dir);
|
|
525
612
|
for (const { filePath, relPath } of files) {
|
|
526
|
-
|
|
527
|
-
if (slug !== name)
|
|
613
|
+
if (slugFromRel(relPath) !== name)
|
|
528
614
|
continue;
|
|
529
615
|
try {
|
|
530
616
|
const raw = readFileSync(filePath, 'utf-8');
|
|
531
617
|
const parsed = matter(raw);
|
|
532
618
|
return {
|
|
533
|
-
name
|
|
534
|
-
title: parsed.data.title ??
|
|
535
|
-
content: parsed.content
|
|
619
|
+
name,
|
|
620
|
+
title: parsed.data.title ?? name,
|
|
621
|
+
content: parsed.content, // full body — the runtime cap is gone
|
|
536
622
|
score: 0,
|
|
537
623
|
toolsUsed: parsed.data.toolsUsed ?? [],
|
|
538
624
|
attachments: parsed.data.attachments ?? [],
|
|
@@ -546,21 +632,37 @@ export function loadSkillByName(name, agentSlug, opts) {
|
|
|
546
632
|
}
|
|
547
633
|
return null;
|
|
548
634
|
}
|
|
549
|
-
/** Record that a skill was used (bump use count).
|
|
635
|
+
/** Record that a skill was used (bump use count). Handles both flat and
|
|
636
|
+
* folder-form skills. For folder form the counter lives under
|
|
637
|
+
* `clementine.useCount` (Anthropic-canonical frontmatter keeps top-level
|
|
638
|
+
* reserved for `name`/`description`). */
|
|
550
639
|
export function recordSkillUse(skillName, agentSlug) {
|
|
551
640
|
try {
|
|
552
|
-
// Check agent dir first, then global
|
|
553
641
|
const dirs = agentSlug ? [agentSkillsDir(agentSlug), GLOBAL_SKILLS_DIR] : [GLOBAL_SKILLS_DIR];
|
|
554
642
|
for (const dir of dirs) {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
643
|
+
// Folder form: <dir>/<skillName>/SKILL.md
|
|
644
|
+
const folderEntry = path.join(dir, skillName, 'SKILL.md');
|
|
645
|
+
if (existsSync(folderEntry)) {
|
|
646
|
+
const raw = readFileSync(folderEntry, 'utf-8');
|
|
647
|
+
const parsed = matter(raw);
|
|
648
|
+
parsed.data.clementine = (parsed.data.clementine && typeof parsed.data.clementine === 'object')
|
|
649
|
+
? parsed.data.clementine
|
|
650
|
+
: {};
|
|
651
|
+
parsed.data.clementine.useCount = (parsed.data.clementine.useCount ?? 0) + 1;
|
|
652
|
+
parsed.data.clementine.lastUsed = new Date().toISOString();
|
|
653
|
+
writeFileSync(folderEntry, matter.stringify(parsed.content, parsed.data));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
// Flat form: <dir>/<skillName>.md
|
|
657
|
+
const flatEntry = path.join(dir, `${skillName}.md`);
|
|
658
|
+
if (existsSync(flatEntry)) {
|
|
659
|
+
const raw = readFileSync(flatEntry, 'utf-8');
|
|
660
|
+
const parsed = matter(raw);
|
|
661
|
+
parsed.data.useCount = (parsed.data.useCount ?? 0) + 1;
|
|
662
|
+
parsed.data.lastUsed = new Date().toISOString();
|
|
663
|
+
writeFileSync(flatEntry, matter.stringify(parsed.content, parsed.data));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
564
666
|
}
|
|
565
667
|
}
|
|
566
668
|
catch { /* non-fatal */ }
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -24459,9 +24459,17 @@ function operationUsageBadge(usage) {
|
|
|
24459
24459
|
return '<span class="badge badge-blue" title="' + esc(formatTokens(usage.totalInput || 0)) + ' input, ' + esc(formatTokens(usage.totalOutput || 0)) + ' output">' + esc(formatTokens(usage.totalTokens || 0)) + ' tok 7d</span>';
|
|
24460
24460
|
}
|
|
24461
24461
|
|
|
24462
|
+
// PRD §pretty-cron / 1.18.111: pretty primary, raw cron in a hover
|
|
24463
|
+
// tooltip. Power users still get the literal expression on hover; casual
|
|
24464
|
+
// users never have to read "0 8-18 * * 1-5". Falls back to raw inline
|
|
24465
|
+
// (with a help-cursor hint) when describeCron can't summarize.
|
|
24462
24466
|
function operationScheduleHtml(schedule) {
|
|
24463
|
-
var
|
|
24464
|
-
|
|
24467
|
+
var raw = schedule || '';
|
|
24468
|
+
var desc = describeCron(raw);
|
|
24469
|
+
if (desc) {
|
|
24470
|
+
return '<span title="' + esc(raw) + '" style="cursor:help">' + esc(desc) + '</span>';
|
|
24471
|
+
}
|
|
24472
|
+
return '<code title="' + esc(raw) + '" style="color:var(--accent);cursor:help">' + esc(raw) + '</code>';
|
|
24465
24473
|
}
|
|
24466
24474
|
|
|
24467
24475
|
function operationSectionHeader(title, subtitle, badgeClass, badgeText, marginTop) {
|
|
@@ -29577,44 +29585,139 @@ function updateScheduleHint() {
|
|
|
29577
29585
|
|
|
29578
29586
|
const monthNames = ['','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
29579
29587
|
|
|
29588
|
+
// PRD §pretty-cron / 1.18.111 — describe a cron expression in casual
|
|
29589
|
+
// human-readable form. Output style:
|
|
29590
|
+
// - "Mondays at 8 AM" not "Every Monday at 8:00 AM"
|
|
29591
|
+
// - "5 PM" not "5:00 PM" when minutes=0
|
|
29592
|
+
// - "8 AM–6 PM" (en-dash range) for hour ranges
|
|
29593
|
+
// - "Every minute" / "Every 2 minutes" / "Every 4 hours"
|
|
29594
|
+
// - "Hourly weekdays 8 AM–6 PM" for the business-hours pattern
|
|
29595
|
+
// - Falls back to '' when the expression is too exotic to summarize;
|
|
29596
|
+
// the renderer then shows the raw cron only as a last resort.
|
|
29580
29597
|
function describeCron(expr) {
|
|
29581
|
-
|
|
29598
|
+
if (!expr || typeof expr !== 'string') return '';
|
|
29599
|
+
// @aliases first — common shortcuts.
|
|
29600
|
+
var aliases = {
|
|
29601
|
+
'@yearly': 'Once a year (Jan 1, midnight)',
|
|
29602
|
+
'@annually': 'Once a year (Jan 1, midnight)',
|
|
29603
|
+
'@monthly': 'First of every month, midnight',
|
|
29604
|
+
'@weekly': 'Sundays at midnight',
|
|
29605
|
+
'@daily': 'Every day at midnight',
|
|
29606
|
+
'@hourly': 'Every hour',
|
|
29607
|
+
'@reboot': 'On daemon start',
|
|
29608
|
+
};
|
|
29609
|
+
if (aliases[expr]) return aliases[expr];
|
|
29610
|
+
|
|
29611
|
+
var parts = expr.trim().split(/\\s+/);
|
|
29582
29612
|
if (parts.length !== 5) return '';
|
|
29583
|
-
|
|
29613
|
+
var min = parts[0], hour = parts[1], dom = parts[2], month = parts[3], dow = parts[4];
|
|
29584
29614
|
|
|
29585
|
-
//
|
|
29586
|
-
if (min
|
|
29587
|
-
|
|
29588
|
-
|
|
29615
|
+
// ── Sub-hour cadence ────────────────────────────────────────────────
|
|
29616
|
+
if (min === '*' && hour === '*' && dom === '*' && month === '*' && dow === '*') return 'Every minute';
|
|
29617
|
+
if (min.startsWith('*/')) {
|
|
29618
|
+
var n = parseInt(min.slice(2), 10);
|
|
29619
|
+
if (Number.isFinite(n)) return n === 1 ? 'Every minute' : 'Every ' + n + ' minutes';
|
|
29620
|
+
}
|
|
29621
|
+
// Every N hours (e.g. "0 */2 * * *")
|
|
29622
|
+
if (hour.startsWith('*/') && (min === '0' || min === '*')) {
|
|
29623
|
+
var nh = parseInt(hour.slice(2), 10);
|
|
29624
|
+
if (Number.isFinite(nh)) return nh === 1 ? 'Every hour' : 'Every ' + nh + ' hours';
|
|
29625
|
+
}
|
|
29589
29626
|
|
|
29590
|
-
|
|
29627
|
+
// ── Hour ranges (e.g. "0 8-18 * * 1-5" — hourly during business hours) ──
|
|
29628
|
+
var rangeMatch = /^(\d{1,2})-(\d{1,2})$/.exec(hour);
|
|
29629
|
+
if (rangeMatch && (min === '0' || min === '*')) {
|
|
29630
|
+
var startH = parseInt(rangeMatch[1], 10);
|
|
29631
|
+
var endH = parseInt(rangeMatch[2], 10);
|
|
29632
|
+
if (Number.isFinite(startH) && Number.isFinite(endH)) {
|
|
29633
|
+
var span = formatHour(startH) + '–' + formatHour(endH);
|
|
29634
|
+
if (dow === '1-5') return 'Hourly weekdays ' + span;
|
|
29635
|
+
if (dow === '*' && dom === '*' && month === '*') return 'Hourly ' + span;
|
|
29636
|
+
if (/^[0-6]$/.test(dow)) return 'Hourly ' + plural(dayNames[+dow]) + ' ' + span;
|
|
29637
|
+
}
|
|
29638
|
+
}
|
|
29639
|
+
|
|
29640
|
+
// ── Comma-list of hours at fixed minute (e.g. "0 8,12,16 * * *") ────
|
|
29641
|
+
// Check this BEFORE parseInt — parseInt is lenient and would parse
|
|
29642
|
+
// "8,12,16" as 8, missing the multi-hour case entirely.
|
|
29643
|
+
if (hour.indexOf(',') !== -1 && min !== '*' && !min.startsWith('*/')) {
|
|
29644
|
+
var minN = parseInt(min, 10);
|
|
29645
|
+
if (Number.isFinite(minN)) {
|
|
29646
|
+
return 'Daily at ' + hour.split(',').map(function(h) { return formatTimePretty(+h, minN); }).join(', ');
|
|
29647
|
+
}
|
|
29648
|
+
}
|
|
29649
|
+
|
|
29650
|
+
// ── Single-time-of-day patterns ─────────────────────────────────────
|
|
29651
|
+
var hourNum = parseInt(hour, 10);
|
|
29652
|
+
var minNum = parseInt(min, 10);
|
|
29653
|
+
if (!Number.isFinite(hourNum) || !Number.isFinite(minNum)) return '';
|
|
29654
|
+
// Reject parseInt's lenient mode: a hour like "8,12" would parse to 8.
|
|
29655
|
+
if (String(hourNum) !== hour || String(minNum) !== min) return '';
|
|
29656
|
+
var time = formatTimePretty(hourNum, minNum);
|
|
29591
29657
|
|
|
29592
29658
|
// Specific date: day + month set (e.g. "10 16 1 3 *" = Mar 1 at 4:10 PM)
|
|
29593
29659
|
if (dom !== '*' && month !== '*') {
|
|
29594
|
-
|
|
29660
|
+
var monthStr = monthNames[+month] || ('Month ' + month);
|
|
29595
29661
|
return monthStr + ' ' + dom + ' at ' + time;
|
|
29596
29662
|
}
|
|
29597
29663
|
|
|
29598
29664
|
// Day of month only (e.g. "0 9 15 * *" = 15th of every month)
|
|
29599
29665
|
if (dom !== '*' && month === '*' && dow === '*') {
|
|
29600
|
-
|
|
29601
|
-
|
|
29666
|
+
return ordinal(+dom) + ' of every month at ' + time;
|
|
29667
|
+
}
|
|
29668
|
+
|
|
29669
|
+
// Weekdays (Mon-Fri)
|
|
29670
|
+
if (dow === '1-5') return 'Weekdays at ' + time;
|
|
29671
|
+
|
|
29672
|
+
// Specific weekday → pluralize ("Mondays at 8 AM" not "Every Monday at 8 AM")
|
|
29673
|
+
if (/^[0-6]$/.test(dow)) return plural(dayNames[+dow]) + ' at ' + time;
|
|
29674
|
+
|
|
29675
|
+
// Multiple specific weekdays (e.g. "0 9 * * 1,3,5")
|
|
29676
|
+
if (/^[0-6](,[0-6])+$/.test(dow)) {
|
|
29677
|
+
return dow.split(',').map(function(d) { return shortDay(+d); }).join(', ') + ' at ' + time;
|
|
29602
29678
|
}
|
|
29603
29679
|
|
|
29604
|
-
// Weekdays
|
|
29605
|
-
if (dow === '1-5' && !hour.includes(',')) return 'Weekdays at ' + time;
|
|
29606
29680
|
// Every day
|
|
29607
|
-
if (dow === '*' && dom === '*' && month === '*'
|
|
29608
|
-
// Specific weekday
|
|
29609
|
-
if (/^[0-6]$/.test(dow) && !hour.includes(',')) return 'Every ' + dayNames[+dow] + ' at ' + time;
|
|
29610
|
-
// Multiple weekdays (e.g. "0 9 * * 1,3,5")
|
|
29611
|
-
if (/^[0-6](,[0-6])+$/.test(dow)) return dow.split(',').map(d => dayNames[+d]).join(', ') + ' at ' + time;
|
|
29612
|
-
// Multiple hours
|
|
29613
|
-
if (hour.includes(',')) return 'Daily at ' + hour.split(',').map(h => formatTime(+h, +min)).join(', ');
|
|
29681
|
+
if (dow === '*' && dom === '*' && month === '*') return 'Every day at ' + time;
|
|
29614
29682
|
|
|
29615
29683
|
return '';
|
|
29616
29684
|
}
|
|
29617
29685
|
|
|
29686
|
+
function plural(day) {
|
|
29687
|
+
// "Monday" → "Mondays". Days end in y already so just append 's'.
|
|
29688
|
+
return day + 's';
|
|
29689
|
+
}
|
|
29690
|
+
|
|
29691
|
+
function shortDay(d) {
|
|
29692
|
+
// Compact form for multi-day lists: "Mon, Wed, Fri".
|
|
29693
|
+
return ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d] || ('day ' + d);
|
|
29694
|
+
}
|
|
29695
|
+
|
|
29696
|
+
function ordinal(n) {
|
|
29697
|
+
// 1st / 2nd / 3rd / 4th… for the day-of-month phrasing.
|
|
29698
|
+
if (n >= 11 && n <= 13) return n + 'th';
|
|
29699
|
+
var lastDigit = n % 10;
|
|
29700
|
+
if (lastDigit === 1) return n + 'st';
|
|
29701
|
+
if (lastDigit === 2) return n + 'nd';
|
|
29702
|
+
if (lastDigit === 3) return n + 'rd';
|
|
29703
|
+
return n + 'th';
|
|
29704
|
+
}
|
|
29705
|
+
|
|
29706
|
+
function formatHour(h) {
|
|
29707
|
+
// Compact hour-only format used in ranges: "8 AM" / "12 PM" / "6 PM".
|
|
29708
|
+
var ampm = h >= 12 ? 'PM' : 'AM';
|
|
29709
|
+
var hr = h === 0 ? 12 : (h > 12 ? h - 12 : h);
|
|
29710
|
+
return hr + ' ' + ampm;
|
|
29711
|
+
}
|
|
29712
|
+
|
|
29713
|
+
function formatTimePretty(h, m) {
|
|
29714
|
+
// "8 AM" when minutes=0, "8:30 AM" otherwise. Casual, no leading zeros.
|
|
29715
|
+
if (m === 0) return formatHour(h);
|
|
29716
|
+
var ampm = h >= 12 ? 'PM' : 'AM';
|
|
29717
|
+
var hr = h === 0 ? 12 : (h > 12 ? h - 12 : h);
|
|
29718
|
+
return hr + ':' + String(m).padStart(2, '0') + ' ' + ampm;
|
|
29719
|
+
}
|
|
29720
|
+
|
|
29618
29721
|
function setScheduleFromCron(expr) {
|
|
29619
29722
|
// Try to reverse-map a cron expression back to the builder
|
|
29620
29723
|
const parts = expr.split(/\\s+/);
|