clementine-agent 1.18.131 → 1.18.133

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.
@@ -34,6 +34,14 @@ const slashCommands = [
34
34
  .addChoices({ name: 'List jobs', value: 'list' }, { name: 'Run a job', value: 'run' }, { name: 'Enable a job', value: 'enable' }, { name: 'Disable a job', value: 'disable' }))
35
35
  .addStringOption(o => o.setName('job').setDescription('Job name (for run/enable/disable)').setAutocomplete(true)),
36
36
  new SlashCommandBuilder().setName('heartbeat').setDescription('Run heartbeat check manually'),
37
+ // 1.18.132 — Phase 3: /skill is the on-demand sibling to scheduled
38
+ // skills. List the catalog or fire any skill once from anywhere
39
+ // Discord can reach. Uses cmdCronRun's catalog fallback (1.18.129)
40
+ // so unscheduled skills work too.
41
+ new SlashCommandBuilder().setName('skill').setDescription('List or run a skill on demand')
42
+ .addStringOption(o => o.setName('action').setDescription('Action').setRequired(true)
43
+ .addChoices({ name: 'List skills', value: 'list' }, { name: 'Run a skill', value: 'run' }))
44
+ .addStringOption(o => o.setName('name').setDescription('Skill name (for run)').setAutocomplete(true)),
37
45
  new SlashCommandBuilder().setName('tools').setDescription('List available MCP tools'),
38
46
  new SlashCommandBuilder().setName('toolset').setDescription('Set this chat tool mode')
39
47
  .addStringOption(o => o.setName('mode').setDescription('Tool mode').setRequired(true)
@@ -1190,6 +1198,23 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
1190
1198
  .slice(0, 25);
1191
1199
  await interaction.respond(filtered.map(name => ({ name, value: name })));
1192
1200
  }
1201
+ else if (interaction.commandName === 'skill') {
1202
+ // 1.18.132 — autocomplete from the live skill catalog (every
1203
+ // skill, scheduled or not). cmdCronRun handles either case.
1204
+ const focused = interaction.options.getFocused().toLowerCase();
1205
+ try {
1206
+ const { listSkills } = await import('../agent/skill-store.js');
1207
+ const skills = listSkills();
1208
+ const filtered = skills
1209
+ .map(s => s.frontmatter.name)
1210
+ .filter(name => name.toLowerCase().includes(focused))
1211
+ .slice(0, 25);
1212
+ await interaction.respond(filtered.map(name => ({ name, value: name })));
1213
+ }
1214
+ catch {
1215
+ await interaction.respond([]);
1216
+ }
1217
+ }
1193
1218
  return;
1194
1219
  }
1195
1220
  // ── Slash commands ───────────────────────────────────────────────
@@ -1373,6 +1398,76 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
1373
1398
  gateway.injectContext(sessionKey, `!cron run ${jobName}`, response);
1374
1399
  return;
1375
1400
  }
1401
+ // 1.18.132 — Phase 3: /skill list / /skill run <name>
1402
+ // List shows the full catalog (folder + flat) with descriptions.
1403
+ // Run fires the named skill via the same cronScheduler.runManual
1404
+ // path; cmdCronRun's catalog fallback (1.18.129) means unscheduled
1405
+ // skills still fire correctly. Output streams back to Discord.
1406
+ if (name === 'skill') {
1407
+ const action = cmd.options.getString('action', true);
1408
+ const skillName = cmd.options.getString('name') ?? '';
1409
+ if (action === 'list') {
1410
+ try {
1411
+ const { listSkills } = await import('../agent/skill-store.js');
1412
+ const skills = listSkills();
1413
+ if (skills.length === 0) {
1414
+ await cmd.reply('No skills found yet. Use the dashboard Skill Builder or `mcp__clementine-tools__create_skill` to author one.');
1415
+ return;
1416
+ }
1417
+ const lines = skills.map(s => {
1418
+ const fm = s.frontmatter;
1419
+ const ext = fm.clementine ?? {};
1420
+ const useCount = typeof ext.useCount === 'number' ? ext.useCount : 0;
1421
+ const desc = (fm.description || '').slice(0, 100) + ((fm.description || '').length > 100 ? '…' : '');
1422
+ return `• \`${fm.name}\` — ${desc}${useCount > 0 ? ` (${useCount}×)` : ''}`;
1423
+ });
1424
+ const header = `**${skills.length} skill${skills.length === 1 ? '' : 's'} available** — fire any with \`/skill run <name>\`:\n\n`;
1425
+ const chunks = chunkText(header + lines.join('\n'), 1900);
1426
+ await cmd.reply(chunks[0]);
1427
+ for (let i = 1; i < chunks.length; i++)
1428
+ await cmd.followUp(chunks[i]);
1429
+ }
1430
+ catch (err) {
1431
+ await cmd.reply(`Failed to list skills: ${err}`);
1432
+ }
1433
+ return;
1434
+ }
1435
+ if (action === 'run') {
1436
+ if (!skillName) {
1437
+ await cmd.reply('Specify a skill name. Try `/skill list` to see options.');
1438
+ return;
1439
+ }
1440
+ try {
1441
+ const { getSkill } = await import('../agent/skill-store.js');
1442
+ const skill = getSkill(skillName);
1443
+ if (!skill) {
1444
+ await cmd.reply(`Skill '${skillName}' not found. Try \`/skill list\`.`);
1445
+ return;
1446
+ }
1447
+ // Re-uses the cron run pipeline. cmdCronRun (1.18.129) falls
1448
+ // back to the skill catalog when the name isn't a registered
1449
+ // cron job, so this works for both scheduled + unscheduled
1450
+ // skills.
1451
+ if (cronScheduler.isJobRunning(skillName)) {
1452
+ await cmd.reply(`Skill '${skillName}' is already running.`);
1453
+ return;
1454
+ }
1455
+ await cmd.deferReply();
1456
+ const response = await cronScheduler.runManual(skillName);
1457
+ const chunks = chunkText(response || `*(skill '${skillName}' completed — no output)*`, 1900);
1458
+ await cmd.editReply(chunks[0]);
1459
+ for (let i = 1; i < chunks.length; i++)
1460
+ await cmd.followUp(chunks[i]);
1461
+ gateway.injectContext(sessionKey, `!skill run ${skillName}`, response);
1462
+ }
1463
+ catch (err) {
1464
+ await cmd.followUp({ content: `Skill run failed: ${err}` }).catch(() => { });
1465
+ }
1466
+ return;
1467
+ }
1468
+ await cmd.reply('Unknown action. Try `/skill list` or `/skill run <name>`.');
1469
+ return;
1470
+ }
1376
1471
  // Workflow command
1377
1472
  if (name === 'workflow') {
1378
1473
  const action = cmd.options.getString('action', true);
@@ -5066,6 +5066,189 @@ export async function cmdDashboard(opts) {
5066
5066
  res.status(500).json({ ok: false, error: String(err) });
5067
5067
  }
5068
5068
  });
5069
+ // 1.18.132 — Phase 3: convert a legacy cron to a scheduled-skill
5070
+ // registry entry. Distinct from /api/cron/:job/migrate (which keeps
5071
+ // the cron in CRON.md but cleans it up). This endpoint REMOVES the
5072
+ // entry from CRON.md and writes it into ~/.clementine/schedules.json
5073
+ // — the Anthropic-pure target shape.
5074
+ //
5075
+ // Eligibility: the cron must already pin a skill OR have a matchable
5076
+ // skill name. We refuse to migrate a cron with no skill to fall back
5077
+ // on, because the schedules.json shape only references {skillName →
5078
+ // schedule}; it has no place for the cron's own prompt body. Users
5079
+ // with custom-prompt crons can either (a) create a skill from the
5080
+ // prompt first via the Builder, then re-run this, or (b) keep the
5081
+ // legacy cron format.
5082
+ app.post('/api/cron/:job/migrate-to-skill', async (req, res) => {
5083
+ try {
5084
+ const jobName = req.params.job;
5085
+ if (!jobName) {
5086
+ res.status(400).json({ ok: false, error: 'job name required' });
5087
+ return;
5088
+ }
5089
+ const { findMatchingSkill } = await import('../agent/cron-migrator.js');
5090
+ const { listSkills } = await import('../agent/skill-store.js');
5091
+ const { setSchedule } = await import('../agent/schedule-registry.js');
5092
+ const { parseCronJobs, parseAgentCronJobs } = await import('../gateway/cron-scheduler.js');
5093
+ const { cronFile, bareJobName } = resolveJobCronFile(jobName);
5094
+ const allJobs = [...parseCronJobs(), ...parseAgentCronJobs(path.join(VAULT_DIR, '00-System', 'agents'))];
5095
+ const target = allJobs.find(j => String(j.name).toLowerCase() === jobName.toLowerCase());
5096
+ if (!target)
5097
+ return res.status(404).json({ ok: false, error: `job "${jobName}" not found` });
5098
+ // Only migrate true legacy CRON.md jobs — scheduled skills aren't migration targets.
5099
+ if (target.source === 'scheduled-skill') {
5100
+ return res.status(400).json({ ok: false, error: 'already a scheduled skill' });
5101
+ }
5102
+ // Find the skill to bind. Prefer an explicit pin; fall back to
5103
+ // findMatchingSkill which uses prompt + name keyword overlap.
5104
+ let skillName = null;
5105
+ const skills = listSkills();
5106
+ if (target.skills && target.skills.length > 0) {
5107
+ // Use the first pinned skill that actually exists in the catalog.
5108
+ skillName = target.skills.find(s => skills.some(sk => sk.frontmatter.name === s)) ?? target.skills[0];
5109
+ }
5110
+ else {
5111
+ const m = findMatchingSkill(target, skills);
5112
+ if (m)
5113
+ skillName = m.frontmatter.name;
5114
+ }
5115
+ if (!skillName) {
5116
+ return res.status(400).json({
5117
+ ok: false,
5118
+ error: 'No skill match. Create a skill from this prompt in the Builder first, then retry.',
5119
+ });
5120
+ }
5121
+ // Sanity: a skill of this name must exist in the catalog (otherwise
5122
+ // the runtime will treat it as a missing pin).
5123
+ if (!skills.some(s => s.frontmatter.name === skillName)) {
5124
+ return res.status(400).json({ ok: false, error: `Pinned skill "${skillName}" not in catalog. Create it first.` });
5125
+ }
5126
+ // Write to schedules.json. Carry over agentSlug + enabled + schedule.
5127
+ const entry = setSchedule(skillName, {
5128
+ schedule: target.schedule,
5129
+ enabled: target.enabled !== false,
5130
+ agentSlug: target.agentSlug ?? null,
5131
+ });
5132
+ // Remove from CRON.md by bare name.
5133
+ const bakPath = cronFile + '.bak';
5134
+ try {
5135
+ writeFileSync(bakPath, readFileSync(cronFile, 'utf-8'));
5136
+ }
5137
+ catch { /* best-effort */ }
5138
+ const { parsed, jobs } = readCronFileAt(cronFile);
5139
+ const idx = jobs.findIndex(j => String(j.name).toLowerCase() === bareJobName.toLowerCase());
5140
+ if (idx < 0) {
5141
+ return res.status(404).json({ ok: false, error: `job not in CRON.md (looked for "${bareJobName}")` });
5142
+ }
5143
+ const removed = jobs.splice(idx, 1)[0];
5144
+ writeCronFileAt(cronFile, parsed, jobs);
5145
+ // Hot-reload + broadcast so the Tasks page updates without manual refresh.
5146
+ try {
5147
+ const gw = await getGateway();
5148
+ const sched = gw.cronScheduler;
5149
+ if (sched && typeof sched.reloadJobs === 'function')
5150
+ sched.reloadJobs();
5151
+ }
5152
+ catch { /* best-effort */ }
5153
+ try {
5154
+ broadcastEvent({ type: 'cron_deleted', data: { job: jobName, source: 'cron-md', migrated: true } });
5155
+ }
5156
+ catch { /* non-fatal */ }
5157
+ res.json({
5158
+ ok: true,
5159
+ skillName,
5160
+ scheduleEntry: entry,
5161
+ removedFrom: cronFile,
5162
+ bakPath,
5163
+ removedBareName: removed?.name,
5164
+ message: `Converted "${jobName}" → scheduled skill "${skillName}". CRON.md backup at ${bakPath}.`,
5165
+ });
5166
+ }
5167
+ catch (err) {
5168
+ res.status(500).json({ ok: false, error: String(err) });
5169
+ }
5170
+ });
5171
+ app.post('/api/cron/migrate-all-to-skills', async (_req, res) => {
5172
+ try {
5173
+ const { findMatchingSkill } = await import('../agent/cron-migrator.js');
5174
+ const { listSkills } = await import('../agent/skill-store.js');
5175
+ const { setSchedule } = await import('../agent/schedule-registry.js');
5176
+ const { parseCronJobs, parseAgentCronJobs } = await import('../gateway/cron-scheduler.js');
5177
+ const skills = listSkills();
5178
+ const allJobs = [...parseCronJobs(), ...parseAgentCronJobs(path.join(VAULT_DIR, '00-System', 'agents'))];
5179
+ const legacy = allJobs.filter(j => j.source !== 'scheduled-skill');
5180
+ const migrated = [];
5181
+ const skipped = [];
5182
+ // Group by source CRON.md so we open + parse + write each file once.
5183
+ const byFile = new Map();
5184
+ for (const job of legacy) {
5185
+ let skillName = null;
5186
+ if (job.skills && job.skills.length > 0) {
5187
+ skillName = job.skills.find(s => skills.some(sk => sk.frontmatter.name === s)) ?? null;
5188
+ }
5189
+ if (!skillName) {
5190
+ const m = findMatchingSkill(job, skills);
5191
+ if (m)
5192
+ skillName = m.frontmatter.name;
5193
+ }
5194
+ if (!skillName) {
5195
+ skipped.push({ name: job.name, reason: 'no skill match' });
5196
+ continue;
5197
+ }
5198
+ if (!skills.some(s => s.frontmatter.name === skillName)) {
5199
+ skipped.push({ name: job.name, reason: `skill "${skillName}" not in catalog` });
5200
+ continue;
5201
+ }
5202
+ try {
5203
+ setSchedule(skillName, {
5204
+ schedule: job.schedule,
5205
+ enabled: job.enabled !== false,
5206
+ agentSlug: job.agentSlug ?? null,
5207
+ });
5208
+ migrated.push({ name: job.name, skillName });
5209
+ const { cronFile, bareJobName } = resolveJobCronFile(job.name);
5210
+ if (!byFile.has(cronFile))
5211
+ byFile.set(cronFile, { jobsToRemove: new Set(), cronFile });
5212
+ byFile.get(cronFile).jobsToRemove.add(bareJobName);
5213
+ }
5214
+ catch (err) {
5215
+ skipped.push({ name: job.name, reason: String(err) });
5216
+ }
5217
+ }
5218
+ const touchedFiles = [];
5219
+ for (const { cronFile, jobsToRemove } of byFile.values()) {
5220
+ try {
5221
+ const bakPath = cronFile + '.bak';
5222
+ try {
5223
+ writeFileSync(bakPath, readFileSync(cronFile, 'utf-8'));
5224
+ }
5225
+ catch { /* best-effort */ }
5226
+ const { parsed, jobs } = readCronFileAt(cronFile);
5227
+ const filtered = jobs.filter(j => !jobsToRemove.has(String(j.name)));
5228
+ writeCronFileAt(cronFile, parsed, filtered);
5229
+ touchedFiles.push(cronFile);
5230
+ }
5231
+ catch (err) {
5232
+ skipped.push({ name: '(file write)', reason: `${cronFile}: ${err}` });
5233
+ }
5234
+ }
5235
+ try {
5236
+ const gw = await getGateway();
5237
+ const sched = gw.cronScheduler;
5238
+ if (sched && typeof sched.reloadJobs === 'function')
5239
+ sched.reloadJobs();
5240
+ }
5241
+ catch { /* best-effort */ }
5242
+ try {
5243
+ broadcastEvent({ type: 'cron_migrated_to_skills', data: { migratedCount: migrated.length } });
5244
+ }
5245
+ catch { /* non-fatal */ }
5246
+ res.json({ ok: true, migrated, skipped, touchedFiles });
5247
+ }
5248
+ catch (err) {
5249
+ res.status(500).json({ ok: false, error: String(err) });
5250
+ }
5251
+ });
5069
5252
  app.post('/api/cron/migrate-all', async (_req, res) => {
5070
5253
  try {
5071
5254
  const { migrateAllEligibleJobs } = await import('../agent/cron-migrator.js');
@@ -6864,6 +7047,57 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
6864
7047
  res.status(500).json({ error: String(err) });
6865
7048
  }
6866
7049
  });
7050
+ // 1.18.132 — list top-level files in a registered project. Used by
7051
+ // the Skill Builder's Files sidebar tab to surface clickable file
7052
+ // paths that the user can insert into their skill body.
7053
+ // Hardened: the path query param MUST match a registered project's
7054
+ // path exactly (no path traversal, no scanning arbitrary directories).
7055
+ app.get('/api/projects/files', (req, res) => {
7056
+ try {
7057
+ const projPath = String(req.query.path ?? '');
7058
+ if (!projPath)
7059
+ return res.status(400).json({ ok: false, error: 'path query param required' });
7060
+ const projects = loadProjectsMeta();
7061
+ const found = projects.find((p) => p.path === projPath);
7062
+ if (!found)
7063
+ return res.status(404).json({ ok: false, error: 'path is not a registered project' });
7064
+ if (!existsSync(projPath))
7065
+ return res.json({ ok: true, files: [], note: 'project path does not exist on disk' });
7066
+ let entries;
7067
+ try {
7068
+ entries = readdirSync(projPath);
7069
+ }
7070
+ catch (err) {
7071
+ return res.status(500).json({ ok: false, error: 'failed to list directory: ' + String(err) });
7072
+ }
7073
+ // Skip hidden + node_modules + standard noise. Up to 50 entries
7074
+ // (the sidebar caps at 50 anyway). One level deep — the panel is
7075
+ // an entry point; users can dig further from their editor.
7076
+ const NOISE = new Set(['node_modules', '.git', '.DS_Store', 'dist', 'build', '.next', '.cache']);
7077
+ const out = [];
7078
+ for (const entry of entries.sort()) {
7079
+ if (entry.startsWith('.'))
7080
+ continue;
7081
+ if (NOISE.has(entry))
7082
+ continue;
7083
+ const abs = path.join(projPath, entry);
7084
+ let st;
7085
+ try {
7086
+ st = statSync(abs);
7087
+ }
7088
+ catch {
7089
+ continue;
7090
+ }
7091
+ out.push({ relPath: entry, isDir: st.isDirectory(), sizeBytes: st.isFile() ? st.size : 0 });
7092
+ if (out.length >= 50)
7093
+ break;
7094
+ }
7095
+ res.json({ ok: true, files: out });
7096
+ }
7097
+ catch (err) {
7098
+ res.status(500).json({ ok: false, error: String(err) });
7099
+ }
7100
+ });
6867
7101
  // ── Available Tools ──────────────────────────────────────────
6868
7102
  app.get('/api/available-tools', async (_req, res) => {
6869
7103
  try {
@@ -25276,10 +25510,53 @@ function renderScheduledTaskCard(task) {
25276
25510
  + '<button class="btn-sm secondary" data-trace-job="' + esc(task.name) + '" title="View execution trace">Trace</button>'
25277
25511
  + (defObj.source === 'scheduled-skill'
25278
25512
  ? '<button class="btn-sm secondary btn-danger" onclick="unscheduleSkillFromCard(\\x27' + safeName + '\\x27)" title="Remove the schedule (skill stays)">Unschedule</button>'
25279
- : '<button class="btn-sm secondary btn-danger" onclick="confirmDeleteCron(\\x27' + safeName + '\\x27)" title="Delete task">Del</button>')
25513
+ : '<button class="btn-sm secondary" onclick="migrateCronToSkillFromCard(\\x27' + safeName + '\\x27)" title="Convert this legacy cron to a scheduled skill — Anthropic-pure format">→ Skill</button>'
25514
+ + '<button class="btn-sm secondary btn-danger" onclick="confirmDeleteCron(\\x27' + safeName + '\\x27)" title="Delete task">Del</button>')
25280
25515
  + '</div></div>';
25281
25516
  }
25282
25517
 
25518
+ // 1.18.132 — Phase 3 migrator: convert one legacy cron → scheduled skill.
25519
+ // Calls /api/cron/:job/migrate-to-skill which writes schedules.json and
25520
+ // removes the entry from CRON.md (with a .bak backup).
25521
+ async function migrateCronToSkillFromCard(jobName) {
25522
+ if (!confirm('Convert "' + jobName + '" to a scheduled skill?\\n\\n' +
25523
+ 'This writes a thin entry in ~/.clementine/schedules.json that points to a skill, ' +
25524
+ 'and removes the legacy fat-cron entry from CRON.md. ' +
25525
+ 'A .bak backup is left so you can restore if anything looks wrong.')) return;
25526
+ try {
25527
+ var r = await apiFetch('/api/cron/' + encodeURIComponent(jobName) + '/migrate-to-skill', { method: 'POST' });
25528
+ var d = await r.json();
25529
+ if (!r.ok) { toast(d.error || 'Migration failed', 'error'); return; }
25530
+ toast('Migrated "' + jobName + '" → scheduled skill "' + d.skillName + '"', 'success');
25531
+ if (typeof refreshCron === 'function') refreshCron();
25532
+ } catch (err) {
25533
+ toast('Failed: ' + err, 'error');
25534
+ }
25535
+ }
25536
+
25537
+ // Bulk migrator from the deprecation banner. Migrates every legacy cron
25538
+ // that has a matching skill; reports skipped ones with reasons.
25539
+ async function migrateAllCronsToSkills() {
25540
+ if (!confirm('Convert ALL eligible legacy crons to scheduled skills?\\n\\n' +
25541
+ 'Each cron with a matched skill will become a thin schedules.json entry. ' +
25542
+ 'CRON.md files get .bak backups. Crons without a skill match are skipped — ' +
25543
+ 'no data loss.')) return;
25544
+ try {
25545
+ var r = await apiFetch('/api/cron/migrate-all-to-skills', { method: 'POST' });
25546
+ var d = await r.json();
25547
+ if (!r.ok) { toast(d.error || 'Bulk migration failed', 'error'); return; }
25548
+ var migrated = (d.migrated || []).length;
25549
+ var skipped = (d.skipped || []).length;
25550
+ var msg = 'Migrated ' + migrated + ' cron' + (migrated === 1 ? '' : 's') + ' to scheduled skills';
25551
+ if (skipped > 0) msg += '. ' + skipped + ' skipped — see console for reasons.';
25552
+ toast(msg, migrated > 0 ? 'success' : 'info');
25553
+ if (skipped > 0 && d.skipped) console.warn('[migrate-all-to-skills] skipped:', d.skipped);
25554
+ if (typeof refreshCron === 'function') refreshCron();
25555
+ } catch (err) {
25556
+ toast('Failed: ' + err, 'error');
25557
+ }
25558
+ }
25559
+
25283
25560
  // 1.18.129 — replace the "+ New Task" tile with a small dropdown that
25284
25561
  // nudges users toward the new "schedule a skill" path. Legacy cron
25285
25562
  // option stays for backward compat / power users with hand-rolled
@@ -26817,7 +27094,30 @@ async function refreshCron() {
26817
27094
  // placeholder; refreshCronCleanBanner() fetches /api/cron/migrate-preview
26818
27095
  // async and fills this in only when there are eligible legacy jobs.
26819
27096
  // The banner stays empty (zero visual noise) when the vault is clean.
26820
- var html = '<div id="cron-migrate-banner-host"></div>';
27097
+ // 1.18.132 Phase 3: soft-deprecate banner. Counts legacy crons
27098
+ // (definition.source !== 'scheduled-skill') and surfaces a one-click
27099
+ // bulk migrator. Dismissable; persists in localStorage so it doesn't
27100
+ // nag on every refresh.
27101
+ var legacyCount = 0;
27102
+ try {
27103
+ legacyCount = (visibleTasks || []).filter(function(t) {
27104
+ return !(t.definition && t.definition.source === 'scheduled-skill');
27105
+ }).length;
27106
+ } catch (_) { /* defensive */ }
27107
+ var bannerHtml = '';
27108
+ var dismissed = localStorage.getItem('clem-skill-migrate-banner-dismissed') === '1';
27109
+ if (legacyCount > 0 && !dismissed) {
27110
+ bannerHtml = '<div style="background:rgba(124,58,237,0.08);border:1px solid var(--purple);border-radius:8px;padding:12px 14px;margin-bottom:14px;display:flex;align-items:center;gap:12px;flex-wrap:wrap">'
27111
+ + '<span style="font-size:18px">⚡</span>'
27112
+ + '<div style="flex:1;min-width:200px">'
27113
+ + '<div style="font-size:13px;font-weight:500;color:var(--text-primary)">' + legacyCount + ' legacy cron task' + (legacyCount === 1 ? '' : 's') + ' can become scheduled skills</div>'
27114
+ + '<div style="font-size:11px;color:var(--text-muted);margin-top:2px">Skills are the Anthropic-pure unit of work. Scheduled-skill format is thinner, easier to maintain, and tasks can be reused on demand.</div>'
27115
+ + '</div>'
27116
+ + '<button class="btn-sm btn-primary" onclick="migrateAllCronsToSkills()" style="font-size:12px;padding:6px 12px">Migrate all eligible →</button>'
27117
+ + '<button class="btn-sm" onclick="localStorage.setItem(\\x27clem-skill-migrate-banner-dismissed\\x27,\\x271\\x27);refreshCron()" title="Hide this banner" style="font-size:12px;padding:6px 10px">Dismiss</button>'
27118
+ + '</div>';
27119
+ }
27120
+ var html = bannerHtml + '<div id="cron-migrate-banner-host"></div>';
26821
27121
  html += '<div id="health-strip" class="health-strip"></div>';
26822
27122
  // 1.18.115 — collapse the cost/latency/reliability/activity mini-cards
26823
27123
  // into a <details> block. The Health Strip already covers what most
@@ -27929,6 +28229,7 @@ async function openSkillBuilder(skillName) {
27929
28229
  + '<span id="sb-save-status" style="font-size:11px;color:var(--text-muted)"></span>'
27930
28230
  + '</div>'
27931
28231
  + '<div style="display:flex;align-items:center;gap:6px">'
28232
+ + '<button onclick="sbRunSkillTest()" id="sb-test-btn" style="font-size:12px;padding:7px 12px;border:1px solid var(--green);border-radius:6px;background:transparent;color:var(--green);cursor:pointer;font-weight:500" title="Fire this skill once and stream the result inline (toast on completion)">▶ Test run</button>'
27932
28233
  + '<button onclick="sbSaveCurrent()" id="sb-save-btn" class="btn-primary" style="font-size:12px;padding:7px 14px;border:none;border-radius:6px;background:var(--accent);color:#fff;font-weight:500;cursor:pointer" disabled>Save (⌘S)</button>'
27933
28234
  + '<button onclick="closeSkillBuilder()" style="font-size:12px;padding:7px 12px;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--text-primary);cursor:pointer">Close</button>'
27934
28235
  + '</div>'
@@ -27954,6 +28255,8 @@ async function openSkillBuilder(skillName) {
27954
28255
  + '<div style="border-left:1px solid var(--border);background:var(--bg-secondary);display:flex;flex-direction:column;min-height:0">'
27955
28256
  + '<div style="padding:8px 6px 0;display:flex;gap:2px;border-bottom:1px solid var(--border)">'
27956
28257
  + '<button onclick="sbSwitchTab(\\x27tools\\x27)" id="sb-tab-tools" class="sb-tab" style="flex:1;font-size:11px;padding:8px 4px;border:none;background:transparent;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;font-weight:500">Tools</button>'
28258
+ + '<button onclick="sbSwitchTab(\\x27files\\x27)" id="sb-tab-files" class="sb-tab" style="flex:1;font-size:11px;padding:8px 4px;border:none;background:transparent;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;font-weight:500">Files</button>'
28259
+ + '<button onclick="sbSwitchTab(\\x27memory\\x27)" id="sb-tab-memory" class="sb-tab" style="flex:1;font-size:11px;padding:8px 4px;border:none;background:transparent;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;font-weight:500">Memory</button>'
27957
28260
  + '<button onclick="sbSwitchTab(\\x27skills\\x27)" id="sb-tab-skills" class="sb-tab" style="flex:1;font-size:11px;padding:8px 4px;border:none;background:transparent;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;font-weight:500">Skills</button>'
27958
28261
  + '</div>'
27959
28262
  + '<div style="padding:6px 10px;border-bottom:1px solid var(--border)">'
@@ -28140,10 +28443,10 @@ async function sbDeleteFile(relPath) {
28140
28443
  }
28141
28444
  }
28142
28445
 
28143
- // ── Sidebar tabs (Tools / Skills) ────────────────────────────────────
28446
+ // ── Sidebar tabs (Tools / Files / Memory / Skills) ──────────────────
28144
28447
  function sbSwitchTab(tab) {
28145
28448
  window._sbActiveTab = tab;
28146
- ['tools', 'skills'].forEach(function(t) {
28449
+ ['tools', 'files', 'memory', 'skills'].forEach(function(t) {
28147
28450
  var el = document.getElementById('sb-tab-' + t);
28148
28451
  if (el) {
28149
28452
  el.style.color = (t === tab) ? 'var(--accent)' : 'var(--text-muted)';
@@ -28159,6 +28462,10 @@ async function sbRenderSidebar() {
28159
28462
  var q = (document.getElementById('sb-sidebar-search')?.value || '').toLowerCase().trim();
28160
28463
  if (window._sbActiveTab === 'tools') {
28161
28464
  await sbRenderToolsTab(listEl, q);
28465
+ } else if (window._sbActiveTab === 'files') {
28466
+ await sbRenderFilesTab(listEl, q);
28467
+ } else if (window._sbActiveTab === 'memory') {
28468
+ await sbRenderMemoryTab(listEl, q);
28162
28469
  } else if (window._sbActiveTab === 'skills') {
28163
28470
  await sbRenderSkillsTab(listEl, q);
28164
28471
  }
@@ -28256,6 +28563,163 @@ async function sbRenderSkillsTab(listEl, q) {
28256
28563
  listEl.innerHTML = html;
28257
28564
  }
28258
28565
 
28566
+ // 1.18.132 — Files tab (Phase 2.5). Browses the user's projects.
28567
+ // Click a project to expand its file tree; click a file to insert
28568
+ // its absolute path at the cursor (so the skill body can reference
28569
+ // it via Read or Bash). Scoped to projectsData (already populated by
28570
+ // /api/projects on page load); no separate filesystem walk endpoint
28571
+ // needed in this iteration.
28572
+ async function sbRenderFilesTab(listEl, q) {
28573
+ var projects = (typeof projectsData !== 'undefined' && Array.isArray(projectsData)) ? projectsData : [];
28574
+ if (projects.length === 0) {
28575
+ try {
28576
+ var pr = await window.apiFetch('/api/projects');
28577
+ var pd = await pr.json();
28578
+ if (pd && Array.isArray(pd.projects)) projects = pd.projects;
28579
+ } catch (_) { /* fall through to empty */ }
28580
+ }
28581
+ if (q) {
28582
+ projects = projects.filter(function(p) {
28583
+ return ((p.name || '') + ' ' + (p.path || '')).toLowerCase().indexOf(q) > -1;
28584
+ });
28585
+ }
28586
+ if (projects.length === 0) {
28587
+ listEl.innerHTML = '<div style="padding:18px;color:var(--text-muted);font-size:11px;text-align:center">' + (q ? 'No matches.' : 'No projects yet. Add one in Settings → Projects.') + '</div>';
28588
+ return;
28589
+ }
28590
+ var html = '<div style="padding:6px 12px;font-size:10px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;background:var(--bg-tertiary)">Projects (' + projects.length + ')</div>';
28591
+ for (var i = 0; i < projects.length; i++) {
28592
+ var p = projects[i];
28593
+ // Click on row → insert the project path. Click on icon to load
28594
+ // a child file list inline (lazy expand).
28595
+ html += '<div style="border-bottom:1px solid var(--border-light)">'
28596
+ + '<div onclick="sbInsertAtCursor(\\x27' + jsStr(p.path) + '\\x27)" style="padding:7px 12px;cursor:pointer;font-size:12px;display:flex;align-items:center;gap:6px" onmouseover="this.style.background=\\x27var(--bg-tertiary)\\x27" onmouseout="this.style.background=\\x27transparent\\x27" title="Insert this absolute path at the cursor">'
28597
+ + '<span>📁</span>'
28598
+ + '<span style="font-weight:500;color:var(--text-primary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(p.name || p.path.split(\'/\').pop()) + '</span>'
28599
+ + '<button onclick="event.stopPropagation();sbToggleProjectFiles(\\x27' + jsStr(p.path) + '\\x27, this)" title="Browse files inside this project" style="background:none;border:1px solid var(--border);color:var(--text-muted);font-size:10px;padding:1px 6px;border-radius:3px;cursor:pointer">▸</button>'
28600
+ + '</div>'
28601
+ + '<div id="sb-files-children-' + i + '" data-project-path="' + esc(p.path) + '" style="display:none;font-size:11px;background:var(--bg-secondary);padding:4px 0"></div>'
28602
+ + '</div>';
28603
+ }
28604
+ listEl.innerHTML = html;
28605
+ }
28606
+
28607
+ // Lazy-load the child file list for a project. Hits a tiny endpoint
28608
+ // that returns the top-level files (max 50, no recursion) so we don't
28609
+ // blow the panel on huge projects.
28610
+ async function sbToggleProjectFiles(projectPath, btn) {
28611
+ // Find the children container next to this button
28612
+ var children = btn.parentElement.parentElement.querySelector('[data-project-path]');
28613
+ if (!children) return;
28614
+ if (children.style.display !== 'none') {
28615
+ children.style.display = 'none';
28616
+ btn.textContent = '▸';
28617
+ return;
28618
+ }
28619
+ children.style.display = '';
28620
+ btn.textContent = '▾';
28621
+ if (!children.dataset.loaded) {
28622
+ children.innerHTML = '<div style="padding:8px 24px;color:var(--text-muted);font-size:10px">Loading…</div>';
28623
+ try {
28624
+ var r = await window.apiFetch('/api/projects/files?path=' + encodeURIComponent(projectPath));
28625
+ var d = await r.json();
28626
+ if (!r.ok || !Array.isArray(d.files)) {
28627
+ children.innerHTML = '<div style="padding:8px 24px;color:var(--text-muted);font-size:10px">No files surfaced (or endpoint unavailable).</div>';
28628
+ return;
28629
+ }
28630
+ var html = '';
28631
+ for (var i = 0; i < Math.min(d.files.length, 50); i++) {
28632
+ var f = d.files[i];
28633
+ var icon = f.isDir ? '📁' : '📄';
28634
+ var fullPath = projectPath + '/' + f.relPath;
28635
+ html += '<div onclick="sbInsertAtCursor(\\x27' + jsStr(fullPath) + '\\x27)" style="padding:4px 24px;cursor:pointer;display:flex;align-items:center;gap:6px" onmouseover="this.style.background=\\x27var(--bg-tertiary)\\x27" onmouseout="this.style.background=\\x27transparent\\x27">'
28636
+ + '<span>' + icon + '</span>'
28637
+ + '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary)">' + esc(f.relPath) + '</span>'
28638
+ + '</div>';
28639
+ }
28640
+ if (d.files.length > 50) {
28641
+ html += '<div style="padding:4px 24px;color:var(--text-muted);font-size:10px;font-style:italic">+ ' + (d.files.length - 50) + ' more (open the project in your editor for the rest)</div>';
28642
+ }
28643
+ children.innerHTML = html;
28644
+ children.dataset.loaded = '1';
28645
+ } catch (err) {
28646
+ children.innerHTML = '<div style="padding:8px 24px;color:var(--red);font-size:10px">' + esc(String(err)) + '</div>';
28647
+ }
28648
+ }
28649
+ }
28650
+
28651
+ // 1.18.132 — Memory tab (Phase 2.5). Surfaces three things the agent
28652
+ // can reach when the skill runs:
28653
+ // 1. Recent extractions (last facts the auto-extractor saved)
28654
+ // 2. Top-level MEMORY.md sections (h2 headers)
28655
+ // 3. A button to insert a memory_search call template
28656
+ // Click any item → inserts a contextual reference at the cursor so
28657
+ // the skill body can document or trigger a recall.
28658
+ async function sbRenderMemoryTab(listEl, q) {
28659
+ var html = '';
28660
+ // 1. Recent extractions
28661
+ try {
28662
+ var r = await window.apiFetch('/api/memory/writes/recent?limit=12');
28663
+ var d = await r.json();
28664
+ var writes = (d && Array.isArray(d.writes)) ? d.writes : (d && Array.isArray(d.entries) ? d.entries : []);
28665
+ if (q) {
28666
+ writes = writes.filter(function(w) {
28667
+ return JSON.stringify(w).toLowerCase().indexOf(q) > -1;
28668
+ });
28669
+ }
28670
+ if (writes.length > 0) {
28671
+ html += '<div style="padding:6px 12px;font-size:10px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;background:var(--bg-tertiary)">Recent extractions</div>';
28672
+ for (var i = 0; i < Math.min(writes.length, 8); i++) {
28673
+ var w = writes[i];
28674
+ var preview = (w.content_preview || w.content || w.preview || w.text || '').slice(0, 100);
28675
+ if (!preview) continue;
28676
+ var insertion = 'Reference recent fact: "' + preview.replace(/"/g, '\\\\\\\\"').slice(0, 80) + '"';
28677
+ html += '<div onclick="sbInsertAtCursor(\\x27' + jsStr(insertion) + '\\x27)" style="padding:7px 12px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border-light)" onmouseover="this.style.background=\\x27var(--bg-tertiary)\\x27" onmouseout="this.style.background=\\x27transparent\\x27">'
28678
+ + '<div style="color:var(--text-secondary);line-height:1.4;font-size:11px">' + esc(preview) + (preview.length >= 100 ? '…' : '') + '</div>'
28679
+ + '</div>';
28680
+ }
28681
+ }
28682
+ } catch (_) { /* memory writes endpoint may not be live in early daemons */ }
28683
+ // 2. MEMORY.md slot headers
28684
+ try {
28685
+ var r2 = await window.apiFetch('/api/memory/md');
28686
+ var d2 = await r2.json();
28687
+ var content = (d2 && d2.content) || '';
28688
+ var lines = content.split('\\n');
28689
+ var sections = [];
28690
+ for (var li = 0; li < lines.length; li++) {
28691
+ var match = lines[li].match(/^##\\s+(.+)$/);
28692
+ if (match) sections.push(match[1].trim());
28693
+ }
28694
+ if (q) {
28695
+ sections = sections.filter(function(s) { return s.toLowerCase().indexOf(q) > -1; });
28696
+ }
28697
+ if (sections.length > 0) {
28698
+ html += '<div style="padding:6px 12px;font-size:10px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;background:var(--bg-tertiary);margin-top:4px">MEMORY.md sections</div>';
28699
+ for (var si = 0; si < sections.length; si++) {
28700
+ var s = sections[si];
28701
+ var ins = 'Refer to MEMORY.md > "' + s + '" section.';
28702
+ html += '<div onclick="sbInsertAtCursor(\\x27' + jsStr(ins) + '\\x27)" style="padding:6px 12px;cursor:pointer;font-size:12px;display:flex;align-items:center;gap:6px;border-bottom:1px solid var(--border-light)" onmouseover="this.style.background=\\x27var(--bg-tertiary)\\x27" onmouseout="this.style.background=\\x27transparent\\x27">'
28703
+ + '<span>📄</span>'
28704
+ + '<span style="color:var(--text-primary)">' + esc(s) + '</span>'
28705
+ + '</div>';
28706
+ }
28707
+ }
28708
+ } catch (_) { /* defensive */ }
28709
+ // 3. Always-available memory_search insert
28710
+ html += '<div style="padding:6px 12px;font-size:10px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;background:var(--bg-tertiary);margin-top:4px">Patterns</div>';
28711
+ html += '<div onclick="sbInsertAtCursor(\\x27Use memory_search with query: \\\\\\\"YOUR_QUERY\\\\\\\".\\x27)" style="padding:7px 12px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border-light)" onmouseover="this.style.background=\\x27var(--bg-tertiary)\\x27" onmouseout="this.style.background=\\x27transparent\\x27">'
28712
+ + '<span style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-primary)">memory_search</span>'
28713
+ + '<span style="color:var(--text-muted);font-size:10px;margin-left:8px">Recall facts/transcripts at runtime</span>'
28714
+ + '</div>';
28715
+ html += '<div onclick="sbInsertAtCursor(\\x27Use memory_write to save this fact: \\\\\\\"FACT\\\\\\\" with reason \\\\\\\"WHY_IT_MATTERS\\\\\\\".\\x27)" style="padding:7px 12px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border-light)" onmouseover="this.style.background=\\x27var(--bg-tertiary)\\x27" onmouseout="this.style.background=\\x27transparent\\x27">'
28716
+ + '<span style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-primary)">memory_write</span>'
28717
+ + '<span style="color:var(--text-muted);font-size:10px;margin-left:8px">Persist a new fact</span>'
28718
+ + '</div>';
28719
+ if (!html.trim()) html = '<div style="padding:18px;color:var(--text-muted);font-size:11px;text-align:center">No memory items surfaced.</div>';
28720
+ listEl.innerHTML = html;
28721
+ }
28722
+
28259
28723
  function sbInsertAtCursor(text) {
28260
28724
  var ed = document.getElementById('sb-editor');
28261
28725
  if (!ed) return;
@@ -28269,6 +28733,38 @@ function sbInsertAtCursor(text) {
28269
28733
  sbOnEdit();
28270
28734
  }
28271
28735
 
28736
+ // 1.18.132 — Test runner. Fires the open skill once via the same
28737
+ // /api/cron/run/:name path that the Skills detail Run-now button
28738
+ // uses (which itself works for unscheduled skills via cmdCronRun's
28739
+ // catalog fallback). Refuses to run if the file has unsaved changes
28740
+ // (otherwise the user would be testing the saved version, not what
28741
+ // they see in the editor — confusing).
28742
+ async function sbRunSkillTest() {
28743
+ if (window._sbState.dirty) {
28744
+ if (!confirm('Save current changes before testing? Unsaved edits won\\x27t be in the run.')) return;
28745
+ await sbSaveCurrent();
28746
+ if (window._sbState.dirty) return; // save failed
28747
+ }
28748
+ var name = window._sbState.skillName;
28749
+ if (!name) return;
28750
+ var btn = document.getElementById('sb-test-btn');
28751
+ if (btn) { btn.disabled = true; btn.textContent = '⏳ Running…'; btn.style.color = 'var(--text-muted)'; btn.style.borderColor = 'var(--border)'; }
28752
+ try {
28753
+ var r = await window.apiFetch('/api/cron/run/' + encodeURIComponent(name), { method: 'POST' });
28754
+ if (r.status === 409) {
28755
+ toast('Already running. Wait for the in-flight run to finish.', 'warn');
28756
+ return;
28757
+ }
28758
+ var d = await r.json();
28759
+ if (!r.ok) { toast(d.error || 'Run failed', 'error'); return; }
28760
+ toast('Started "' + name + '" — output streams to chat. Close the builder to see it.', 'success');
28761
+ } catch (err) {
28762
+ toast('Failed: ' + err, 'error');
28763
+ } finally {
28764
+ if (btn) { btn.disabled = false; btn.textContent = '▶ Test run'; btn.style.color = 'var(--green)'; btn.style.borderColor = 'var(--green)'; }
28765
+ }
28766
+ }
28767
+
28272
28768
  async function _openSkillModal(opts) {
28273
28769
  opts = opts || {};
28274
28770
  var existing = null;
@@ -1032,12 +1032,31 @@ export class HeartbeatScheduler {
1032
1032
  let response = null;
1033
1033
  try {
1034
1034
  const cronCall = buildInsightCheckCronCall(prompt);
1035
+ // 1.18.132 — fix the "Prompt is too long" loop. Insight-check is
1036
+ // an internal Haiku classifier ("should we proactively message
1037
+ // Nathan?") with a tightly-built prompt assembled by
1038
+ // gatherInsightSignals + buildInsightPrompt. Without predictable
1039
+ // mode the runtime ALSO injected MEMORY.md (8k cap) + team
1040
+ // comms + delegation queue + auto-matched skills (up to 4 skill
1041
+ // bodies). The user's MEMORY.md alone was ~80% of the budget;
1042
+ // adding even one auto-matched skill body tipped it past the
1043
+ // model's input window.
1044
+ //
1045
+ // Pass predictable: true + an explicit empty allowedTools list so
1046
+ // the SDK call is just: built-in claude_code system prompt +
1047
+ // our compact insight prompt. This is a classifier, not a working
1048
+ // agent; nothing in MEMORY.md or skill bodies is relevant to
1049
+ // urgency rating.
1035
1050
  response = await this.gateway.handleCronJob(cronCall.jobName, cronCall.jobPrompt, cronCall.tier, cronCall.maxTurns, cronCall.model, undefined, // workDir
1036
1051
  'standard', // mode (display only)
1037
1052
  undefined, // maxHours
1038
1053
  undefined, // timeoutMs
1039
1054
  undefined, // successCriteria
1040
- undefined);
1055
+ undefined, // agentSlug
1056
+ undefined, // pinnedSkills
1057
+ [], // allowedTools — empty = no MCP injection
1058
+ [], // allowedMcpServers — empty = no MCP servers wired
1059
+ true);
1041
1060
  this.runLog.append({
1042
1061
  jobName: 'insight-check',
1043
1062
  startedAt: icStartedAt.toISOString(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.131",
3
+ "version": "1.18.133",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",