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.
@@ -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
- if (s.attachments.length > 0) {
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
- * Recursively list every .md skill file under `dir`. Returns absolute
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. Used so auto-generated
331
- * MCP skills under `skills/auto/<server>/<tool>.md` surface in search
332
- * while user-authored top-level skills win on score tiebreak.
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 relPath (no .md, slashes → dashes) so same-name skills in
405
- // different subdirs don't collide in the dedupe set.
406
- const name = relPath.replace(/\.md$/, '').replace(/[\\/]/g, '-');
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
- const slug = relPath.replace(/\.md$/, '').replace(/[\\/]/g, '-');
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: slug,
534
- title: parsed.data.title ?? slug,
535
- content: parsed.content.slice(0, 1500),
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
- const filePath = path.join(dir, `${skillName}.md`);
556
- if (!existsSync(filePath))
557
- continue;
558
- const raw = readFileSync(filePath, 'utf-8');
559
- const parsed = matter(raw);
560
- parsed.data.useCount = (parsed.data.useCount ?? 0) + 1;
561
- parsed.data.lastUsed = new Date().toISOString();
562
- writeFileSync(filePath, matter.stringify(parsed.content, parsed.data));
563
- return;
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 */ }
@@ -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 desc = describeCron(schedule || '');
24464
- return desc ? esc(desc) + ' <code>' + esc(schedule) + '</code>' : '<code style="color:var(--accent)">' + esc(schedule || '') + '</code>';
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
- const parts = expr.split(/\\s+/);
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
- const [min, hour, dom, month, dow] = parts;
29613
+ var min = parts[0], hour = parts[1], dom = parts[2], month = parts[3], dow = parts[4];
29584
29614
 
29585
- // Every N minutes
29586
- if (min.startsWith('*/')) return 'Every ' + min.slice(2) + ' minutes';
29587
- // Every N hours
29588
- if (hour.startsWith('*/')) return 'Every ' + hour.slice(2) + ' hours';
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
- const time = formatTime(+hour, +min);
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
- const monthStr = monthNames[+month] || 'Month ' + month;
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
- const suffix = +dom === 1 ? 'st' : +dom === 2 ? 'nd' : +dom === 3 ? 'rd' : 'th';
29601
- return dom + suffix + ' of every month at ' + time;
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 === '*' && !hour.includes(',') && !hour.includes('/')) return 'Every day at ' + time;
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+/);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.110",
3
+ "version": "1.18.112",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",