clementine-agent 1.18.155 → 1.18.156

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 (2) hide show
  1. package/dist/cli/dashboard.js +246 -25
  2. package/package.json +1 -1
@@ -5136,21 +5136,33 @@ export async function cmdDashboard(opts) {
5136
5136
  res.status(500).json({ ok: false, error: String(err) });
5137
5137
  }
5138
5138
  });
5139
- app.post('/api/cron/migrate-all-to-skills', async (_req, res) => {
5139
+ app.post('/api/cron/migrate-all-to-skills', async (req, res) => {
5140
5140
  try {
5141
- const { findMatchingSkill } = await import('../agent/cron-migrator.js');
5142
- const { listSkills } = await import('../agent/skill-store.js');
5141
+ const { findMatchingSkill, generateDescription } = await import('../agent/cron-migrator.js');
5142
+ const { listSkills, writeSkill } = await import('../agent/skill-store.js');
5143
5143
  const { setSchedule } = await import('../agent/schedule-registry.js');
5144
5144
  const { parseCronJobs, parseAgentCronJobs } = await import('../gateway/cron-scheduler.js');
5145
+ // 1.18.156 — `createMissing` defaults true so the bulk path actually
5146
+ // converts every legacy cron (auto-generates a folder-form skill from
5147
+ // the cron prompt when no match exists). The user can still pass
5148
+ // `{createMissing: false}` if they only want safe matches. .bak
5149
+ // backups still happen either way.
5150
+ const body = (req.body ?? {});
5151
+ const createMissing = body.createMissing !== false;
5145
5152
  const skills = listSkills();
5146
5153
  const allJobs = [...parseCronJobs(), ...parseAgentCronJobs(path.join(VAULT_DIR, '00-System', 'agents'))];
5147
5154
  const legacy = allJobs.filter(j => j.source !== 'scheduled-skill');
5148
5155
  const migrated = [];
5149
5156
  const skipped = [];
5157
+ // Track names we've just created so subsequent jobs in the loop don't
5158
+ // bail with "skill not in catalog" before listSkills() refreshes.
5159
+ const createdThisPass = new Set();
5160
+ const inCatalog = (n) => createdThisPass.has(n) || skills.some(s => s.frontmatter.name === n);
5150
5161
  // Group by source CRON.md so we open + parse + write each file once.
5151
5162
  const byFile = new Map();
5152
5163
  for (const job of legacy) {
5153
5164
  let skillName = null;
5165
+ let created = false;
5154
5166
  if (job.skills && job.skills.length > 0) {
5155
5167
  skillName = job.skills.find(s => skills.some(sk => sk.frontmatter.name === s)) ?? null;
5156
5168
  }
@@ -5160,10 +5172,49 @@ export async function cmdDashboard(opts) {
5160
5172
  skillName = m.frontmatter.name;
5161
5173
  }
5162
5174
  if (!skillName) {
5163
- skipped.push({ name: job.name, reason: 'no skill match' });
5164
- continue;
5175
+ if (!createMissing) {
5176
+ skipped.push({ name: job.name, reason: 'no skill match (set createMissing=true to auto-create)' });
5177
+ continue;
5178
+ }
5179
+ // Auto-create a skill from the cron's prompt. Mirrors the per-row
5180
+ // /migrate-with-skill-creation endpoint logic.
5181
+ const candidate = job.name
5182
+ .toLowerCase().replace(/[_\s]+/g, '-').replace(/[^a-z0-9-]/g, '')
5183
+ .replace(/-+/g, '-').replace(/^-+|-+$/g, '')
5184
+ .replace(/-(cron|task|job)$/, '').slice(0, 64);
5185
+ if (!candidate || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(candidate) || inCatalog(candidate)) {
5186
+ skipped.push({ name: job.name, reason: `cannot derive a unique kebab-case name (candidate: "${candidate}")` });
5187
+ continue;
5188
+ }
5189
+ const cronPrompt = String(job.prompt || '').trim();
5190
+ const desc = generateDescription(cronPrompt, null);
5191
+ const triggerPhrase = job.name.replace(/-/g, ' ');
5192
+ const description = `${desc} Use when this scheduled task fires (cron: ${job.schedule}) or when the user mentions "${triggerPhrase}".`.slice(0, 1024);
5193
+ try {
5194
+ writeSkill({
5195
+ name: candidate,
5196
+ description,
5197
+ body: [
5198
+ `# ${candidate.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`,
5199
+ '', '## Instructions', '',
5200
+ cronPrompt || '(prompt not captured — fill in)',
5201
+ '', '## When to use', '',
5202
+ `Auto-fired on schedule \`${job.schedule}\`. Also matches user prompts about "${triggerPhrase}".`,
5203
+ '',
5204
+ ].join('\n'),
5205
+ source: 'imported',
5206
+ ...(job.agentSlug ? { agentSlug: job.agentSlug } : {}),
5207
+ });
5208
+ createdThisPass.add(candidate);
5209
+ skillName = candidate;
5210
+ created = true;
5211
+ }
5212
+ catch (writeErr) {
5213
+ skipped.push({ name: job.name, reason: `skill auto-create failed: ${String(writeErr)}` });
5214
+ continue;
5215
+ }
5165
5216
  }
5166
- if (!skills.some(s => s.frontmatter.name === skillName)) {
5217
+ if (!inCatalog(skillName)) {
5167
5218
  skipped.push({ name: job.name, reason: `skill "${skillName}" not in catalog` });
5168
5219
  continue;
5169
5220
  }
@@ -5173,7 +5224,7 @@ export async function cmdDashboard(opts) {
5173
5224
  enabled: job.enabled !== false,
5174
5225
  agentSlug: job.agentSlug ?? null,
5175
5226
  });
5176
- migrated.push({ name: job.name, skillName });
5227
+ migrated.push({ name: job.name, skillName, ...(created ? { created: true } : {}) });
5177
5228
  const { cronFile, bareJobName } = resolveJobCronFile(job.name);
5178
5229
  if (!byFile.has(cronFile))
5179
5230
  byFile.set(cronFile, { jobsToRemove: new Set(), cronFile });
@@ -5217,6 +5268,136 @@ export async function cmdDashboard(opts) {
5217
5268
  res.status(500).json({ ok: false, error: String(err) });
5218
5269
  }
5219
5270
  });
5271
+ // 1.18.156 — Per-row "create skill from this cron prompt + schedule it"
5272
+ // path. The user's legacy crons usually have no matching skill (so the
5273
+ // existing /api/cron/:job/migrate-to-skill aborts with "Create a skill
5274
+ // first"). This endpoint closes that gap by writing a folder-form
5275
+ // SKILL.md from the cron's prompt, then performing the schedule wire-up
5276
+ // in one shot. Anthropic-canonical: name = kebab-case, description has
5277
+ // WHAT + WHEN + first-sentence trigger phrase, body is the cron prompt
5278
+ // verbatim. The user can immediately refine via the Builder afterward.
5279
+ app.post('/api/cron/:job/migrate-with-skill-creation', async (req, res) => {
5280
+ try {
5281
+ const jobName = req.params.job;
5282
+ if (!jobName) {
5283
+ res.status(400).json({ ok: false, error: 'job name required' });
5284
+ return;
5285
+ }
5286
+ const proposedName = String((req.body && req.body.skillName) || '').trim();
5287
+ const { generateDescription } = await import('../agent/cron-migrator.js');
5288
+ const { writeSkill, listSkills } = await import('../agent/skill-store.js');
5289
+ const { setSchedule } = await import('../agent/schedule-registry.js');
5290
+ const { parseCronJobs, parseAgentCronJobs } = await import('../gateway/cron-scheduler.js');
5291
+ const { cronFile, bareJobName } = resolveJobCronFile(jobName);
5292
+ const allJobs = [...parseCronJobs(), ...parseAgentCronJobs(path.join(VAULT_DIR, '00-System', 'agents'))];
5293
+ const target = allJobs.find(j => String(j.name).toLowerCase() === jobName.toLowerCase());
5294
+ if (!target)
5295
+ return res.status(404).json({ ok: false, error: `job "${jobName}" not found` });
5296
+ if (target.source === 'scheduled-skill') {
5297
+ return res.status(400).json({ ok: false, error: 'already a scheduled skill' });
5298
+ }
5299
+ // Slugify proposed name (or fall back to the job name) to Anthropic's
5300
+ // kebab-case convention. Strip "cron"/"task" suffixes that don't add
5301
+ // meaning; cap at 64 chars per the SKILL.md spec.
5302
+ const rawName = proposedName || jobName;
5303
+ const skillName = rawName
5304
+ .toLowerCase()
5305
+ .replace(/[_\s]+/g, '-')
5306
+ .replace(/[^a-z0-9-]/g, '')
5307
+ .replace(/-+/g, '-')
5308
+ .replace(/^-+|-+$/g, '')
5309
+ .replace(/-(cron|task|job)$/, '')
5310
+ .slice(0, 64);
5311
+ if (!skillName || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(skillName)) {
5312
+ return res.status(400).json({ ok: false, error: `cannot derive a valid kebab-case skill name from "${rawName}"` });
5313
+ }
5314
+ // Collision check.
5315
+ const existing = listSkills();
5316
+ if (existing.some(s => s.frontmatter.name === skillName)) {
5317
+ return res.status(409).json({
5318
+ ok: false,
5319
+ error: `Skill "${skillName}" already exists. Pass a different skillName or use /migrate-to-skill to bind to it.`,
5320
+ });
5321
+ }
5322
+ // Build an Anthropic-canonical SKILL.md. Description follows the
5323
+ // WHAT + WHEN pattern from the PDF (page 11): start with what the
5324
+ // cron does, add a trigger phrase derived from the cron name.
5325
+ const cronPrompt = String(target.prompt || '').trim();
5326
+ const cleanedDesc = generateDescription(cronPrompt, null);
5327
+ const triggerPhrase = jobName.replace(/-/g, ' ');
5328
+ const description = `${cleanedDesc} Use when this scheduled task fires (cron: ${target.schedule}) or when the user mentions "${triggerPhrase}".`.slice(0, 1024);
5329
+ const body = [
5330
+ `# ${skillName.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`,
5331
+ '',
5332
+ '## Instructions',
5333
+ '',
5334
+ cronPrompt || '(prompt not captured — fill in)',
5335
+ '',
5336
+ '## When to use',
5337
+ '',
5338
+ `Auto-fired on schedule \`${target.schedule}\`. Also matches user prompts about "${triggerPhrase}".`,
5339
+ '',
5340
+ ].join('\n');
5341
+ let writeResult;
5342
+ try {
5343
+ writeResult = writeSkill({
5344
+ name: skillName,
5345
+ description,
5346
+ body,
5347
+ source: 'imported',
5348
+ ...(target.agentSlug ? { agentSlug: target.agentSlug } : {}),
5349
+ });
5350
+ }
5351
+ catch (writeErr) {
5352
+ return res.status(500).json({ ok: false, error: `skill write failed: ${String(writeErr)}` });
5353
+ }
5354
+ // Now wire the schedule pointing at the new skill.
5355
+ const entry = setSchedule(skillName, {
5356
+ schedule: target.schedule,
5357
+ enabled: target.enabled !== false,
5358
+ agentSlug: target.agentSlug ?? null,
5359
+ });
5360
+ // Remove the legacy cron entry. Same .bak + write-back pattern as the
5361
+ // canonical /migrate-to-skill endpoint.
5362
+ const bakPath = cronFile + '.bak';
5363
+ try {
5364
+ writeFileSync(bakPath, readFileSync(cronFile, 'utf-8'));
5365
+ }
5366
+ catch { /* best-effort */ }
5367
+ const { parsed, jobs } = readCronFileAt(cronFile);
5368
+ const idx = jobs.findIndex(j => String(j.name).toLowerCase() === bareJobName.toLowerCase());
5369
+ if (idx >= 0) {
5370
+ jobs.splice(idx, 1);
5371
+ writeCronFileAt(cronFile, parsed, jobs);
5372
+ }
5373
+ try {
5374
+ const gw = await getGateway();
5375
+ const sched = gw.cronScheduler;
5376
+ if (sched && typeof sched.reloadJobs === 'function')
5377
+ sched.reloadJobs();
5378
+ }
5379
+ catch { /* best-effort */ }
5380
+ try {
5381
+ broadcastEvent({ type: 'skill_created', data: { skillName, fromCron: jobName } });
5382
+ }
5383
+ catch { /* non-fatal */ }
5384
+ try {
5385
+ broadcastEvent({ type: 'cron_deleted', data: { job: jobName, source: 'cron-md', migrated: true } });
5386
+ }
5387
+ catch { /* non-fatal */ }
5388
+ res.json({
5389
+ ok: true,
5390
+ skillName,
5391
+ scheduleEntry: entry,
5392
+ skillPath: writeResult.filePath,
5393
+ bakPath,
5394
+ message: `Created skill "${skillName}" and scheduled it on ${target.schedule}. Cron entry removed (backup: ${bakPath}).`,
5395
+ });
5396
+ }
5397
+ catch (err) {
5398
+ res.status(500).json({ ok: false, error: String(err) });
5399
+ }
5400
+ });
5220
5401
  app.post('/api/cron/migrate-all', async (_req, res) => {
5221
5402
  try {
5222
5403
  const { migrateAllEligibleJobs } = await import('../agent/cron-migrator.js');
@@ -25498,42 +25679,82 @@ function renderScheduledTaskCard(task) {
25498
25679
  }
25499
25680
 
25500
25681
  // 1.18.132 — Phase 3 migrator: convert one legacy cron → scheduled skill.
25501
- // Calls /api/cron/:job/migrate-to-skill which writes schedules.json and
25502
- // removes the entry from CRON.md (with a .bak backup).
25682
+ // 1.18.156 — End-to-end migration: try /migrate-to-skill first; if the
25683
+ // backend bails with "no skill match" (the common case for the user's
25684
+ // 15 legacy crons), surface a confirm to CREATE the skill from the cron's
25685
+ // prompt and schedule it in one shot via /migrate-with-skill-creation.
25686
+ // Replaces the silent-failure UX where the button did nothing visible.
25503
25687
  async function migrateCronToSkillFromCard(jobName) {
25504
25688
  if (!confirm('Convert "' + jobName + '" to a scheduled skill?\\n\\n' +
25505
- 'This writes a thin entry in ~/.clementine/schedules.json that points to a skill, ' +
25506
- 'and removes the legacy fat-cron entry from CRON.md. ' +
25507
- 'A .bak backup is left so you can restore if anything looks wrong.')) return;
25689
+ 'This writes a thin entry in ~/.clementine/schedules.json pointing to a skill, ' +
25690
+ 'and removes the legacy fat-cron entry from CRON.md. A .bak backup is left.')) return;
25508
25691
  try {
25509
25692
  var r = await apiFetch('/api/cron/' + encodeURIComponent(jobName) + '/migrate-to-skill', { method: 'POST' });
25510
25693
  var d = await r.json();
25511
- if (!r.ok) { toast(d.error || 'Migration failed', 'error'); return; }
25512
- toast('Migrated "' + jobName + '" → scheduled skill "' + d.skillName + '"', 'success');
25513
- if (typeof refreshCron === 'function') refreshCron();
25694
+ if (r.ok) {
25695
+ toast('Migrated "' + jobName + '" → scheduled skill "' + d.skillName + '"', 'success');
25696
+ if (typeof refreshCron === 'function') refreshCron();
25697
+ return;
25698
+ }
25699
+ // Common path: no matching skill exists. Offer to auto-create one.
25700
+ var msg = d.error || '';
25701
+ if (msg.indexOf('No skill match') >= 0 || msg.indexOf('not in catalog') >= 0) {
25702
+ var proceed = confirm(
25703
+ 'No matching skill found for "' + jobName + '".\\n\\n' +
25704
+ 'Want me to create a folder-form skill from this cron\\x27s prompt and schedule it?\\n\\n' +
25705
+ '• The skill is named after the cron (kebab-case)\\n' +
25706
+ '• Description follows Anthropic\\x27s WHAT + WHEN format\\n' +
25707
+ '• The cron prompt becomes the skill body — refine it later in the Builder\\n' +
25708
+ '• The legacy cron is removed (with .bak backup)'
25709
+ );
25710
+ if (!proceed) { toast('Migration cancelled — no skill created', 'info'); return; }
25711
+ var r2 = await apiFetch('/api/cron/' + encodeURIComponent(jobName) + '/migrate-with-skill-creation', { method: 'POST' });
25712
+ var d2 = await r2.json();
25713
+ if (!r2.ok) { toast(d2.error || 'Skill creation + migration failed', 'error'); return; }
25714
+ toast('Created skill "' + d2.skillName + '" + scheduled it. Refine in the Builder when ready.', 'success');
25715
+ if (typeof refreshCron === 'function') refreshCron();
25716
+ if (typeof refreshSkills === 'function') refreshSkills();
25717
+ return;
25718
+ }
25719
+ toast(msg || 'Migration failed', 'error');
25514
25720
  } catch (err) {
25515
25721
  toast('Failed: ' + err, 'error');
25516
25722
  }
25517
25723
  }
25518
25724
 
25519
- // Bulk migrator from the deprecation banner. Migrates every legacy cron
25520
- // that has a matching skill; reports skipped ones with reasons.
25725
+ // 1.18.156 — Bulk migrator. Default behavior auto-creates a folder-form
25726
+ // skill for any cron that has no matching skill (createMissing=true),
25727
+ // because the user's 15 legacy crons typically have no match — without
25728
+ // auto-create, the bulk button silently skips everything. .bak backups
25729
+ // preserve every original CRON.md so rollback is one cp away.
25521
25730
  async function migrateAllCronsToSkills() {
25522
- if (!confirm('Convert ALL eligible legacy crons to scheduled skills?\\n\\n' +
25523
- 'Each cron with a matched skill will become a thin schedules.json entry. ' +
25524
- 'CRON.md files get .bak backups. Crons without a skill match are skipped ' +
25525
- 'no data loss.')) return;
25731
+ if (!confirm('Convert ALL legacy crons to scheduled skills?\\n\\n' +
25732
+ 'For each cron:\\n' +
25733
+ ' If a matching skill exists bind to it\\n' +
25734
+ '• If no matching skill → auto-create one (kebab-case, Anthropic format) from the cron prompt\\n\\n' +
25735
+ 'CRON.md files get .bak backups. Refine generated skills in the Builder afterwards.')) return;
25526
25736
  try {
25527
- var r = await apiFetch('/api/cron/migrate-all-to-skills', { method: 'POST' });
25737
+ var r = await apiFetch('/api/cron/migrate-all-to-skills', {
25738
+ method: 'POST',
25739
+ headers: { 'Content-Type': 'application/json' },
25740
+ body: JSON.stringify({ createMissing: true }),
25741
+ });
25528
25742
  var d = await r.json();
25529
25743
  if (!r.ok) { toast(d.error || 'Bulk migration failed', 'error'); return; }
25530
- var migrated = (d.migrated || []).length;
25744
+ var migrated = d.migrated || [];
25745
+ var created = migrated.filter(function(m) { return m.created; }).length;
25746
+ var bound = migrated.length - created;
25531
25747
  var skipped = (d.skipped || []).length;
25532
- var msg = 'Migrated ' + migrated + ' cron' + (migrated === 1 ? '' : 's') + ' to scheduled skills';
25748
+ var parts = [];
25749
+ if (bound > 0) parts.push(bound + ' bound to existing skill' + (bound === 1 ? '' : 's'));
25750
+ if (created > 0) parts.push(created + ' new skill' + (created === 1 ? '' : 's') + ' created');
25751
+ var msg = 'Migrated ' + migrated.length + ' cron' + (migrated.length === 1 ? '' : 's')
25752
+ + (parts.length ? ' (' + parts.join(', ') + ')' : '');
25533
25753
  if (skipped > 0) msg += '. ' + skipped + ' skipped — see console for reasons.';
25534
- toast(msg, migrated > 0 ? 'success' : 'info');
25754
+ toast(msg, migrated.length > 0 ? 'success' : 'info');
25535
25755
  if (skipped > 0 && d.skipped) console.warn('[migrate-all-to-skills] skipped:', d.skipped);
25536
25756
  if (typeof refreshCron === 'function') refreshCron();
25757
+ if (typeof refreshSkills === 'function') refreshSkills();
25537
25758
  } catch (err) {
25538
25759
  toast('Failed: ' + err, 'error');
25539
25760
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.155",
3
+ "version": "1.18.156",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",