clementine-agent 1.18.114 → 1.18.117

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.
@@ -128,6 +128,28 @@ function buildRunAgentEnv() {
128
128
  return env;
129
129
  }
130
130
  const logger = pino({ name: 'clementine.run-agent' });
131
+ /**
132
+ * Map a sessionKey to the CLEMENTINE_INTERACTION_SOURCE value the MCP
133
+ * subprocess will read. Mirrors `inferInteractionSource` in assistant.ts
134
+ * (kept inline here to avoid a circular import — assistant.ts already
135
+ * imports from this module). Owner-DM-only admin tools like
136
+ * refresh_tool_inventory / allow_tool / env_set check for exactly
137
+ * 'owner-dm', so the previous hardcoded 'interactive' value broke them
138
+ * for every chat session routed through runAgent.
139
+ */
140
+ function interactionSourceForSession(sessionKey, source) {
141
+ if (source === 'cron' || source === 'heartbeat')
142
+ return 'autonomous';
143
+ if (!sessionKey)
144
+ return 'autonomous';
145
+ if (sessionKey.startsWith('discord:member'))
146
+ return 'member-channel';
147
+ if (sessionKey.startsWith('discord:channel:'))
148
+ return 'owner-channel';
149
+ if (sessionKey.includes(':'))
150
+ return 'owner-dm';
151
+ return 'autonomous';
152
+ }
131
153
  // Last-resort fallbacks for callers that pass NO maxBudgetUsd. The
132
154
  // production callers (`runAgent` from gateway/router, runAgentCron,
133
155
  // runAgentHeartbeat) read `BUDGET.*` from src/config.ts — which is
@@ -229,7 +251,7 @@ export async function runAgent(prompt, opts) {
229
251
  ...subprocessEnv,
230
252
  CLEMENTINE_HOME: BASE_DIR,
231
253
  ...(opts.profile?.slug ? { CLEMENTINE_TEAM_AGENT: opts.profile.slug } : {}),
232
- CLEMENTINE_INTERACTION_SOURCE: source === 'cron' || source === 'heartbeat' ? 'autonomous' : 'interactive',
254
+ CLEMENTINE_INTERACTION_SOURCE: interactionSourceForSession(opts.sessionKey, source),
233
255
  },
234
256
  },
235
257
  ...(opts.extraMcpServers ?? {}),
@@ -10118,43 +10118,156 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
10118
10118
  res.status(500).json({ error: String(err) });
10119
10119
  }
10120
10120
  });
10121
+ // POST /api/skills — create a new folder-form skill (1.18.115).
10122
+ // Anthropic-compatible: top-level `name` + `description` required; the
10123
+ // optional `tools` array goes under `clementine.tools.allow` so the
10124
+ // frontmatter parses cleanly in vanilla Claude Agent SDK while still
10125
+ // letting the cron runtime enforce the allowlist.
10121
10126
  app.post('/api/skills', (req, res) => {
10122
10127
  try {
10123
- const { title, description, triggers, steps } = req.body;
10124
- if (!title || !steps) {
10125
- res.status(400).json({ error: 'title and steps are required' });
10128
+ const { name, title, description, body, tools } = req.body ?? {};
10129
+ if (!name || typeof name !== 'string') {
10130
+ res.status(400).json({ error: 'name is required' });
10131
+ return;
10132
+ }
10133
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
10134
+ res.status(400).json({ error: 'name must match ^[a-z0-9][a-z0-9-]{0,63}$ (Anthropic spec)' });
10135
+ return;
10136
+ }
10137
+ if (!description || typeof description !== 'string') {
10138
+ res.status(400).json({ error: 'description is required' });
10139
+ return;
10140
+ }
10141
+ if (description.length > 1024) {
10142
+ res.status(400).json({ error: 'description must be ≤ 1024 chars (Anthropic spec)' });
10143
+ return;
10144
+ }
10145
+ if (!body || typeof body !== 'string' || !body.trim()) {
10146
+ res.status(400).json({ error: 'body is required' });
10126
10147
  return;
10127
10148
  }
10128
10149
  const skillsDir = path.join(VAULT_DIR, '00-System', 'skills');
10129
10150
  if (!existsSync(skillsDir))
10130
10151
  mkdirSync(skillsDir, { recursive: true });
10131
- const name = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
10152
+ const folderPath = path.join(skillsDir, name);
10153
+ const entryPath = path.join(folderPath, 'SKILL.md');
10154
+ if (existsSync(entryPath)) {
10155
+ res.status(409).json({ error: 'Skill "' + name + '" already exists. Use PUT to update.' });
10156
+ return;
10157
+ }
10158
+ mkdirSync(folderPath, { recursive: true });
10132
10159
  const now = new Date().toISOString();
10133
- const triggerList = (triggers || '').split(',').map((t) => t.trim()).filter(Boolean);
10160
+ const fm = { name, description };
10161
+ if (title && typeof title === 'string' && title.trim())
10162
+ fm.title = title.trim();
10163
+ const allowed = Array.isArray(tools) ? tools.map(String).map(s => s.trim()).filter(Boolean) : [];
10164
+ const clementineExt = {
10165
+ source: 'manual',
10166
+ useCount: 0,
10167
+ createdAt: now,
10168
+ updatedAt: now,
10169
+ version: 1,
10170
+ };
10171
+ if (allowed.length > 0)
10172
+ clementineExt.tools = { allow: allowed };
10173
+ fm.clementine = clementineExt;
10134
10174
  const matterMod = require('gray-matter');
10135
- const content = matterMod.stringify(`\n# ${title}\n\n${description || ''}\n\n## Procedure\n\n${steps}\n`, { title, description: description || '', triggers: triggerList, source: 'manual', toolsUsed: [], useCount: 0, createdAt: now, updatedAt: now });
10136
- writeFileSync(path.join(skillsDir, `${name}.md`), content);
10137
- res.json({ ok: true, name });
10175
+ const content = matterMod.stringify(body.endsWith('\n') ? body : body + '\n', fm);
10176
+ writeFileSync(entryPath, content);
10177
+ res.json({ ok: true, name, layout: 'folder', filePath: entryPath });
10178
+ }
10179
+ catch (err) {
10180
+ res.status(500).json({ error: String(err) });
10181
+ }
10182
+ });
10183
+ // PUT /api/skills/:name — update an existing skill (1.18.115).
10184
+ // Folder-aware: writes back to <name>/SKILL.md when the skill is in
10185
+ // folder layout, otherwise updates the flat <name>.md (preserves the
10186
+ // existing layout — we don't auto-migrate on edit; users go through the
10187
+ // Migrate flow when they're ready).
10188
+ app.put('/api/skills/:name', (req, res) => {
10189
+ try {
10190
+ const skillName = req.params.name;
10191
+ const { title, description, body, tools } = req.body ?? {};
10192
+ if (!description || typeof description !== 'string') {
10193
+ res.status(400).json({ error: 'description is required' });
10194
+ return;
10195
+ }
10196
+ if (description.length > 1024) {
10197
+ res.status(400).json({ error: 'description must be ≤ 1024 chars (Anthropic spec)' });
10198
+ return;
10199
+ }
10200
+ if (!body || typeof body !== 'string' || !body.trim()) {
10201
+ res.status(400).json({ error: 'body is required' });
10202
+ return;
10203
+ }
10204
+ const skillsDir = path.join(VAULT_DIR, '00-System', 'skills');
10205
+ const folderEntry = path.join(skillsDir, skillName, 'SKILL.md');
10206
+ const flatEntry = path.join(skillsDir, skillName + '.md');
10207
+ const targetPath = existsSync(folderEntry) ? folderEntry : (existsSync(flatEntry) ? flatEntry : null);
10208
+ if (!targetPath) {
10209
+ res.status(404).json({ error: 'Skill "' + skillName + '" not found' });
10210
+ return;
10211
+ }
10212
+ // Preserve existing frontmatter (esp. clementine namespace fields like
10213
+ // useCount, createdAt, migration provenance) and only merge in updates.
10214
+ const matterMod = require('gray-matter');
10215
+ const existingRaw = readFileSync(targetPath, 'utf-8');
10216
+ const parsed = matterMod(existingRaw);
10217
+ const fm = { ...parsed.data };
10218
+ fm.name = skillName;
10219
+ fm.description = description;
10220
+ if (title && typeof title === 'string' && title.trim())
10221
+ fm.title = title.trim();
10222
+ else
10223
+ delete fm.title;
10224
+ const ext = (fm.clementine && typeof fm.clementine === 'object') ? fm.clementine : {};
10225
+ ext.updatedAt = new Date().toISOString();
10226
+ const allowed = Array.isArray(tools) ? tools.map(String).map(s => s.trim()).filter(Boolean) : [];
10227
+ if (allowed.length > 0)
10228
+ ext.tools = { ...(ext.tools || {}), allow: allowed };
10229
+ else if (ext.tools && typeof ext.tools === 'object')
10230
+ delete ext.tools.allow;
10231
+ fm.clementine = ext;
10232
+ const content = matterMod.stringify(body.endsWith('\n') ? body : body + '\n', fm);
10233
+ writeFileSync(targetPath, content);
10234
+ res.json({ ok: true, name: skillName, layout: targetPath === folderEntry ? 'folder' : 'flat' });
10138
10235
  }
10139
10236
  catch (err) {
10140
10237
  res.status(500).json({ error: String(err) });
10141
10238
  }
10142
10239
  });
10240
+ // DELETE /api/skills/:name — remove a skill (folder OR flat). 1.18.115:
10241
+ // updated to handle folder form. The .md.bak (if present) stays as a
10242
+ // rollback artefact unless the caller passes `?bak=clean`.
10143
10243
  app.delete('/api/skills/:name', (req, res) => {
10144
10244
  try {
10245
+ const skillName = req.params.name;
10145
10246
  const skillsDir = path.join(VAULT_DIR, '00-System', 'skills');
10146
- const filePath = path.join(skillsDir, `${req.params.name}.md`);
10147
- if (!existsSync(filePath)) {
10148
- res.status(404).json({ error: 'Skill not found' });
10247
+ const folderPath = path.join(skillsDir, skillName);
10248
+ const folderEntry = path.join(folderPath, 'SKILL.md');
10249
+ const flatEntry = path.join(skillsDir, skillName + '.md');
10250
+ const cleanBak = req.query.bak === 'clean';
10251
+ let removed = false;
10252
+ if (existsSync(folderEntry)) {
10253
+ // Folder form — remove the whole skill folder.
10254
+ rmSync(folderPath, { recursive: true, force: true });
10255
+ removed = true;
10256
+ }
10257
+ else if (existsSync(flatEntry)) {
10258
+ // Legacy flat form — remove the .md plus optional .files dir.
10259
+ unlinkSync(flatEntry);
10260
+ const filesDir = path.join(skillsDir, skillName + '.files');
10261
+ if (existsSync(filesDir))
10262
+ rmSync(filesDir, { recursive: true, force: true });
10263
+ removed = true;
10264
+ }
10265
+ if (!removed) {
10266
+ res.status(404).json({ error: 'Skill "' + skillName + '" not found' });
10149
10267
  return;
10150
10268
  }
10151
- unlinkSync(filePath);
10152
- // Clean up attachments directory and backup
10153
- const filesDir = path.join(skillsDir, `${req.params.name}.files`);
10154
- if (existsSync(filesDir))
10155
- rmSync(filesDir, { recursive: true, force: true });
10156
- const bakPath = filePath.replace(/\.md$/, '.md.bak');
10157
- if (existsSync(bakPath))
10269
+ const bakPath = path.join(skillsDir, skillName + '.md.bak');
10270
+ if (cleanBak && existsSync(bakPath))
10158
10271
  unlinkSync(bakPath);
10159
10272
  res.json({ ok: true });
10160
10273
  }
@@ -16107,6 +16220,13 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16107
16220
  color: var(--green); font-size: 12px; font-weight: 700;
16108
16221
  margin: 0 0 4px 0; text-transform: uppercase; letter-spacing: 0.4px;
16109
16222
  }
16223
+ /* Soft info variant — used by the Strict-mode-available tip on legacy
16224
+ tasks. Quieter than .warn so it reads as a suggestion, not a defect. */
16225
+ .cron-banner.info {
16226
+ background: var(--bg-secondary);
16227
+ border: 1px solid var(--border);
16228
+ color: var(--text-primary);
16229
+ }
16110
16230
  .cron-banner .banner-actions { margin-top: 10px; display: flex; gap: 8px; }
16111
16231
  .cron-banner .banner-actions button {
16112
16232
  font-size: 12px; padding: 6px 12px; border-radius: 6px; cursor: pointer;
@@ -17063,7 +17183,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
17063
17183
  Tasks (default) + Tools & MCP catalog. Workflows still reachable
17064
17184
  via deep-link ?tab=workflows for power users with existing
17065
17185
  multi-step workflows. -->
17066
- <div id="build-tabs" style="display:flex;gap:4px;padding:8px 18px 0;background:var(--bg-secondary);border-bottom:1px solid var(--border);flex-shrink:0">
17186
+ <div id="build-tabs" style="display:flex;gap:4px;padding:8px 18px 0;background:var(--bg-secondary);border-bottom:1px solid var(--border);flex-shrink:0;align-items:flex-end">
17067
17187
  <button class="build-tab-btn active" data-build-tab="crons" onclick="switchBuildTab('crons')" style="padding:8px 14px;border-radius:6px 6px 0 0;border:none;background:transparent;color:var(--text-primary);font-size:13px;font-weight:500;cursor:pointer;border-bottom:2px solid transparent">
17068
17188
  <span style="margin-right:6px">📅</span>Tasks <span id="build-tab-cron-count" style="display:none;margin-left:4px;font-size:10px;background:var(--bg-tertiary);padding:1px 6px;border-radius:999px;color:var(--text-muted)">0</span>
17069
17189
  </button>
@@ -17076,6 +17196,11 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
17076
17196
  <button class="build-tab-btn" data-build-tab="workflows" onclick="switchBuildTab('workflows')" style="display:none;padding:8px 14px;border-radius:6px 6px 0 0;border:none;background:transparent;color:var(--text-secondary);font-size:13px;font-weight:500;cursor:pointer;border-bottom:2px solid transparent">
17077
17197
  <span style="margin-right:6px">🔧</span>Workflows <span id="build-tab-workflows-count" style="display:none;margin-left:4px;font-size:10px;background:var(--bg-tertiary);padding:1px 6px;border-radius:999px;color:var(--text-muted)">0</span>
17078
17198
  </button>
17199
+ <!-- Spacer + primary "create" CTA. The Tasks/Runs/Tools tabs are above; this sits flush with them on the right so creation is one click from anywhere on the Tasks domain. -->
17200
+ <div style="flex:1"></div>
17201
+ <button class="btn-primary" onclick="openCreateCronModal()" style="margin-bottom:6px;font-size:13px;padding:7px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px">
17202
+ <span style="font-size:14px;line-height:1">+</span> New task
17203
+ </button>
17079
17204
  </div>
17080
17205
  <style>
17081
17206
  .build-tab-btn.active { color:var(--accent) !important; border-bottom-color:var(--accent) !important; background:var(--bg-primary) !important; }
@@ -20173,11 +20298,18 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
20173
20298
  invocation lands in Phase C. The page is intentionally minimal —
20174
20299
  we want users to see what's there, not be overwhelmed by 7 tiles. -->
20175
20300
  <div class="page" id="page-skills">
20176
- <div class="page-head">
20177
- <div class="icon icon-slot" data-icon="brain"></div>
20178
- <div class="title-block">
20179
- <h1>Skills</h1>
20180
- <p class="desc">Reusable procedures Clementine can run. Each skill declares its tools, data sources, and state.</p>
20301
+ <div class="page-head" style="display:flex;align-items:flex-start;justify-content:space-between;gap:18px">
20302
+ <div style="display:flex;align-items:flex-start;gap:14px;flex:1;min-width:0">
20303
+ <div class="icon icon-slot" data-icon="brain"></div>
20304
+ <div class="title-block">
20305
+ <h1>Skills</h1>
20306
+ <p class="desc">Reusable procedures. A <strong>skill</strong> is a recipe (Markdown body + tool allowlist + bundled docs). Tasks pin skills; chats can run them. One skill, many tasks.</p>
20307
+ </div>
20308
+ </div>
20309
+ <div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
20310
+ <button class="btn-primary" onclick="openCreateSkillModal()" style="font-size:13px;padding:8px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px">
20311
+ <span style="font-size:14px;line-height:1">+</span> New skill
20312
+ </button>
20181
20313
  </div>
20182
20314
  </div>
20183
20315
  <div style="display:grid;grid-template-columns:380px 1fr;gap:18px;height:calc(100vh - 180px);min-height:500px">
@@ -24351,11 +24483,30 @@ function renderScheduledTaskCard(task) {
24351
24483
  var runOrCancelBtn = isRunning
24352
24484
  ? '<button class="btn-sm secondary btn-danger" onclick="cancelCronRun(\\x27' + safeName + '\\x27)" title="Stop this in-flight run (SIGTERM)">Cancel</button>'
24353
24485
  : '<button class="btn-sm secondary btn-success" onclick="apiPost(\\x27/api/cron/run/' + encodeURIComponent(task.name) + '\\x27)" title="Run this task once now">Run Now</button>';
24486
+ // 1.18.115 — preview line. Prefer task.description (a future-proof
24487
+ // dedicated field), then strip leading TOOL RESTRICTIONS / FORBIDDEN
24488
+ // boilerplate (purely a runtime allowlist, not what the task does), then
24489
+ // grab the first real sentence so the card reads as "what this task is
24490
+ // for" rather than "wall of LLM scaffolding."
24491
+ function _taskPreview(t) {
24492
+ if (t.description && String(t.description).trim()) return String(t.description).trim();
24493
+ var p = String(t.prompt || '').trim();
24494
+ // Strip the canonical tool-restriction preamble (matches the user's
24495
+ // TOOL RESTRICTIONS — MANDATORY... block up to the first paragraph
24496
+ // that doesn't start with a numbered restriction line).
24497
+ var stripped = p.replace(/^TOOL RESTRICTIONS[\\s\\S]*?(?=\\n\\n[A-Z][^.]*\\.|\\n\\n\\w)/i, '').trim();
24498
+ if (!stripped) stripped = p;
24499
+ // First sentence or first line, whichever is shorter. Falls back to
24500
+ // the first 200 chars if nothing punctuates.
24501
+ var firstLine = stripped.split('\\n').map(function(s){ return s.trim(); }).find(function(s){ return s.length > 0; }) || '';
24502
+ var firstSentence = (firstLine.match(/^[^.!?]+[.!?]/) || [firstLine])[0];
24503
+ return (firstSentence || stripped).slice(0, 200);
24504
+ }
24354
24505
  return '<div class="' + cardCls + '" style="' + style + '">'
24355
24506
  + '<div class="task-card-header"><strong>' + esc(task.displayName || task.name) + '</strong>'
24356
24507
  + '<label class="toggle-switch"><input type="checkbox"' + (enabled ? ' checked' : '') + ' onchange="toggleCronJob(\\x27' + safeName + '\\x27)"><span class="toggle-slider"></span></label></div>'
24357
24508
  + '<div class="task-card-schedule">' + operationScheduleHtml(task.schedule) + '</div>'
24358
- + '<div class="task-card-prompt">' + esc(task.prompt || '') + '</div>'
24509
+ + '<div class="task-card-prompt">' + esc(_taskPreview(task)) + '</div>'
24359
24510
  + renderTrickCapabilityStrip(task)
24360
24511
  + '<div class="task-card-status">' + lastRunHtml + '</div>'
24361
24512
  + renderTrickTagChips(task)
@@ -25809,15 +25960,24 @@ async function refreshCron() {
25809
25960
  var visibleRunning = ownerScoped ? (ops.runningNow || []).filter(function(i) { return buildOpsOwnerMatches(i.owner || ''); }) : (ops.runningNow || []);
25810
25961
  var ownerFilter = getBuildOwnerFilter();
25811
25962
 
25812
- // PRD §12 / 1.18.88: Health Strip placeholder. The runs payload from
25813
- // /api/cron/runs (already fetched alongside ops) feeds the metrics.
25814
- // Render an empty shell first; refreshHealthStrip fills it in.
25963
+ // PRD §12 / 1.18.88: Health Strip kept always-visible at the top.
25964
+ // 7 KPI tiles (24h runs, success rate, cost, p50/p95 latency, running,
25965
+ // top failure). The runs payload from /api/cron/runs (already fetched
25966
+ // alongside ops) feeds the metrics. Render an empty shell first;
25967
+ // refreshHealthStrip fills it in.
25815
25968
  var html = '<div id="health-strip" class="health-strip"></div>';
25816
- // PRD §12 / 1.18.93: three mini-dashboards below the Health Strip —
25817
- // Cost (7d sparkline), Latency split (model / tool / overhead),
25818
- // Reliability (failures stacked by category). Filled in by
25819
- // refreshMiniDashboards from the same /api/cron/runs payload.
25820
- html += '<div id="mini-dashboards" class="mini-dashboards"></div>';
25969
+ // 1.18.115 collapse the cost/latency/reliability/activity mini-cards
25970
+ // into a <details> block. The Health Strip already covers what most
25971
+ // users want at a glance; the 4 mini-dashboards are for deeper
25972
+ // observability and don't need to be the second thing on the page.
25973
+ // Closed by default; users who want them flip it open once and the
25974
+ // browser remembers via the [open] attribute persistence pattern.
25975
+ html += '<details class="mini-dashboards-toggle" style="margin:14px 0 4px">'
25976
+ + '<summary style="font-size:12px;color:var(--text-muted);cursor:pointer;padding:6px 0;user-select:none;display:inline-flex;align-items:center;gap:6px">'
25977
+ + '<span style="font-size:11px">▸</span> Show cost / latency / reliability mini-dashboards'
25978
+ + '</summary>'
25979
+ + '<div id="mini-dashboards" class="mini-dashboards" style="margin-top:10px"></div>'
25980
+ + '</details>';
25821
25981
 
25822
25982
  // ── Zone 1 — Running now (promoted to top, primary "what's live" view) ──
25823
25983
  if (visibleRunning.length > 0) {
@@ -26748,6 +26908,250 @@ async function migrateAllLegacySkills() {
26748
26908
  } catch (err) { toast('Bulk migration failed: ' + err, 'error'); }
26749
26909
  }
26750
26910
 
26911
+ // 1.18.115 — tiny inline Markdown renderer for the Skills detail pane.
26912
+ // Handles the subset SKILL.md bodies use in practice: headers, bold,
26913
+ // italic, inline-code, fenced code blocks, ordered + unordered lists,
26914
+ // paragraphs, line breaks. Pulling in marked or markdown-it would balloon
26915
+ // the served bundle for ~80 lines of regex. Output is always escaped
26916
+ // first, then re-styled.
26917
+ //
26918
+ // NOTE: regex patterns containing backtick characters are constructed via
26919
+ // new RegExp(string) instead of regex literals — raw backticks would
26920
+ // otherwise close the surrounding TypeScript template literal that
26921
+ // builds the served HTML.
26922
+ function renderMarkdown(src) {
26923
+ if (!src) return '';
26924
+ // Escape every char first; we never inject raw HTML from the body.
26925
+ var s = String(src)
26926
+ .replace(/&/g, '&amp;')
26927
+ .replace(/</g, '&lt;')
26928
+ .replace(/>/g, '&gt;')
26929
+ .replace(/"/g, '&quot;');
26930
+ // Fenced code blocks (preserve interior verbatim — no inline markup
26931
+ // applied inside). Use a placeholder map so subsequent regex passes
26932
+ // don't munge the contents. Fence delimiter is three backticks.
26933
+ var BACKTICK = String.fromCharCode(96);
26934
+ var fences = [];
26935
+ var fenceRe = new RegExp(BACKTICK + BACKTICK + BACKTICK + '([a-z0-9_-]*)\\n?([\\s\\S]*?)' + BACKTICK + BACKTICK + BACKTICK, 'gi');
26936
+ s = s.replace(fenceRe, function(_, lang, code) {
26937
+ fences.push('<pre style="background:var(--bg-tertiary);padding:12px 14px;border-radius:6px;overflow:auto;font-size:12px;line-height:1.5;margin:8px 0;border:1px solid var(--border)"><code>' + code.replace(/\\n+$/, '') + '</code></pre>');
26938
+ return '\\u0000FENCE_' + (fences.length - 1) + '\\u0000';
26939
+ });
26940
+ // Inline code — single backticks
26941
+ var inlineRe = new RegExp(BACKTICK + '([^' + BACKTICK + '\\n]+)' + BACKTICK, 'g');
26942
+ s = s.replace(inlineRe, '<code style="background:var(--bg-tertiary);padding:1px 6px;border-radius:3px;font-size:0.92em">$1</code>');
26943
+ // Bold first (greedier double-star) so it doesn't get eaten by single-star italic.
26944
+ s = s.replace(/\\*\\*([^*\\n][^*]*?)\\*\\*/g, '<strong>$1</strong>');
26945
+ s = s.replace(/(^|[^*])\\*([^*\\n][^*]*?)\\*/g, '$1<em>$2</em>');
26946
+ // Headers — process line-by-line so we don't accidentally match across
26947
+ // paragraphs. Also collect lists into <ul>/<ol> blocks.
26948
+ var lines = s.split('\\n');
26949
+ var out = [];
26950
+ var listKind = null; // 'ul' | 'ol' | null
26951
+ var listItems = [];
26952
+ function flushList() {
26953
+ if (!listKind) return;
26954
+ out.push('<' + listKind + ' style="margin:6px 0 10px;padding-left:22px">' + listItems.map(function(li) { return '<li style="margin:2px 0">' + li + '</li>'; }).join('') + '</' + listKind + '>');
26955
+ listKind = null; listItems = [];
26956
+ }
26957
+ function paraStart() { out.push('<p style="margin:8px 0">'); }
26958
+ function paraEnd() {
26959
+ // Close the last <p> if there is one open.
26960
+ var last = out.length - 1;
26961
+ if (last >= 0 && out[last].endsWith('</p>')) return;
26962
+ if (last >= 0 && out[last] === '<p style="margin:8px 0">') { out.pop(); return; }
26963
+ if (last >= 0 && !/^<(h\\d|ul|ol|pre|hr|details)/.test(out[last])) {
26964
+ // We're mid-paragraph — emit the close tag.
26965
+ out.push('</p>');
26966
+ }
26967
+ }
26968
+ var paraOpen = false;
26969
+ function closePara() { if (paraOpen) { out.push('</p>'); paraOpen = false; } }
26970
+ function openPara() { if (!paraOpen) { out.push('<p style="margin:8px 0">'); paraOpen = true; } }
26971
+ for (var i = 0; i < lines.length; i++) {
26972
+ var line = lines[i];
26973
+ // Restore fence placeholders as their own block.
26974
+ var fenceMatch = line.match(/\\u0000FENCE_(\\d+)\\u0000/);
26975
+ if (fenceMatch) { closePara(); flushList(); out.push(fences[Number(fenceMatch[1])]); continue; }
26976
+ // Headers
26977
+ var hMatch = line.match(/^(#{1,6})\\s+(.+)$/);
26978
+ if (hMatch) {
26979
+ closePara(); flushList();
26980
+ var level = hMatch[1].length;
26981
+ var sizes = { 1: '1.4em', 2: '1.2em', 3: '1.05em', 4: '1em', 5: '0.95em', 6: '0.9em' };
26982
+ out.push('<h' + level + ' style="margin:14px 0 6px;font-size:' + sizes[level] + ';font-weight:600;color:var(--text-primary)">' + hMatch[2] + '</h' + level + '>');
26983
+ continue;
26984
+ }
26985
+ // Unordered list — dash, star, or plus prefix
26986
+ var ulMatch = line.match(/^\\s*[-*+]\\s+(.+)$/);
26987
+ if (ulMatch) {
26988
+ closePara();
26989
+ if (listKind && listKind !== 'ul') flushList();
26990
+ listKind = 'ul';
26991
+ listItems.push(ulMatch[1]);
26992
+ continue;
26993
+ }
26994
+ // Ordered list — 1., 2., etc.
26995
+ var olMatch = line.match(/^\\s*\\d+\\.\\s+(.+)$/);
26996
+ if (olMatch) {
26997
+ closePara();
26998
+ if (listKind && listKind !== 'ol') flushList();
26999
+ listKind = 'ol';
27000
+ listItems.push(olMatch[1]);
27001
+ continue;
27002
+ }
27003
+ // Blank line — paragraph break
27004
+ if (!line.trim()) { closePara(); flushList(); continue; }
27005
+ // Hr
27006
+ if (/^---+$/.test(line.trim())) { closePara(); flushList(); out.push('<hr style="border:none;border-top:1px solid var(--border);margin:12px 0">'); continue; }
27007
+ // Default — wrap as paragraph text
27008
+ flushList();
27009
+ openPara();
27010
+ // Append to current paragraph with a soft break between consecutive
27011
+ // text lines (markdown convention: lines in a paragraph join with space).
27012
+ var top = out.length - 1;
27013
+ if (out[top] === '<p style="margin:8px 0">') {
27014
+ out[top] = '<p style="margin:8px 0">' + line;
27015
+ } else {
27016
+ out[top] = out[top] + ' ' + line;
27017
+ }
27018
+ }
27019
+ closePara(); flushList();
27020
+ return out.join('\\n');
27021
+ }
27022
+
27023
+ // 1.18.115 — Skill creation modal. Until now there was no UI to make a
27024
+ // new skill; users had to mkdir + write SKILL.md by hand. Modal collects
27025
+ // name (Anthropic regex enforced client-side), description, body, and an
27026
+ // optional comma-separated tools.allow allowlist; POSTs to a new endpoint
27027
+ // that calls skill-store.parseSkillFolder/write under the hood.
27028
+ function openCreateSkillModal() { _openSkillModal({ mode: 'create' }); }
27029
+ function openEditSkillModal(name) { _openSkillModal({ mode: 'edit', name: name }); }
27030
+
27031
+ async function _openSkillModal(opts) {
27032
+ opts = opts || {};
27033
+ var existing = null;
27034
+ if (opts.mode === 'edit' && opts.name) {
27035
+ try {
27036
+ var r = await apiFetch('/api/skills/' + encodeURIComponent(opts.name));
27037
+ if (r.ok) existing = await r.json();
27038
+ } catch (e) { toast('Failed to load skill: ' + e, 'error'); return; }
27039
+ }
27040
+ var fm = (existing && existing.frontmatter) || {};
27041
+ var ext = fm.clementine || {};
27042
+ var nameVal = fm.name || '';
27043
+ var titleVal = fm.title || '';
27044
+ var descVal = fm.description || '';
27045
+ var bodyVal = (existing && existing.body) || '';
27046
+ var toolsVal = (ext.tools && Array.isArray(ext.tools.allow)) ? ext.tools.allow.join(', ') : '';
27047
+ var modal = document.getElementById('skill-edit-modal');
27048
+ if (!modal) {
27049
+ modal = document.createElement('div');
27050
+ modal.id = 'skill-edit-modal';
27051
+ modal.className = 'modal-overlay';
27052
+ modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:1000;padding:20px';
27053
+ modal.innerHTML =
27054
+ '<div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:10px;width:min(720px,95vw);max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 48px rgba(0,0,0,0.35)">'
27055
+ + '<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border)">'
27056
+ + '<h3 id="skill-modal-title" style="margin:0;font-size:15px;font-weight:600">New skill</h3>'
27057
+ + '<button onclick="closeSkillModal()" style="background:none;border:none;font-size:18px;color:var(--text-muted);cursor:pointer;padding:0 4px;line-height:1">✕</button>'
27058
+ + '</div>'
27059
+ + '<div style="flex:1;overflow-y:auto;padding:18px 22px">'
27060
+ + '<input type="hidden" id="skill-modal-original-name">'
27061
+ + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Name <span style="color:var(--text-muted)">(lowercase, dashes, max 64 chars)</span></label>'
27062
+ + '<input id="skill-modal-name" type="text" placeholder="e.g. morning-briefing" style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px;font-family:\\x27JetBrains Mono\\x27,monospace">'
27063
+ + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Display title <span style="color:var(--text-muted)">(optional, friendlier name)</span></label>'
27064
+ + '<input id="skill-modal-title" type="text" placeholder="e.g. Morning Briefing" style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px">'
27065
+ + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Description <span style="color:var(--text-muted)">(what this skill does — used by Claude to know when to apply it)</span></label>'
27066
+ + '<textarea id="skill-modal-desc" rows="2" placeholder="One paragraph: what does this skill do, when should Claude run it?" style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px;font-family:inherit;resize:vertical"></textarea>'
27067
+ + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Allowed tools <span style="color:var(--text-muted)">(comma-separated, leave blank for default)</span></label>'
27068
+ + '<input id="skill-modal-tools" type="text" placeholder="e.g. Read, Bash, mcp__supabase__list_tables" style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px">'
27069
+ + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Procedure <span style="color:var(--text-muted)">(Markdown — the actual steps Claude follows)</span></label>'
27070
+ + '<textarea id="skill-modal-body" rows="14" placeholder="# Morning Briefing\\n\\nSteps Claude follows when this skill is invoked.\\n\\n1. Check the inbox.\\n2. Summarize.\\n3. Send to Discord." style="width:100%;padding:10px 12px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-family:\\x27JetBrains Mono\\x27,monospace;line-height:1.55;resize:vertical"></textarea>'
27071
+ + '<div id="skill-modal-error" style="display:none;color:var(--red);font-size:12px;margin-top:10px;padding:8px 10px;background:rgba(239,68,68,0.08);border:1px solid var(--red);border-radius:6px"></div>'
27072
+ + '</div>'
27073
+ + '<div style="display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border);background:var(--bg-secondary)">'
27074
+ + '<button onclick="closeSkillModal()" style="padding:7px 14px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--text-primary);cursor:pointer">Cancel</button>'
27075
+ + '<button id="skill-modal-save" onclick="saveSkillFromModal()" class="btn-primary" style="padding:7px 16px;font-size:13px;border:none;border-radius:6px;background:var(--accent);color:#fff;font-weight:500;cursor:pointer">Save skill</button>'
27076
+ + '</div>'
27077
+ + '</div>';
27078
+ document.body.appendChild(modal);
27079
+ }
27080
+ document.getElementById('skill-modal-title').textContent = opts.mode === 'edit' ? 'Edit skill: ' + nameVal : 'New skill';
27081
+ document.getElementById('skill-modal-original-name').value = opts.mode === 'edit' ? nameVal : '';
27082
+ document.getElementById('skill-modal-name').value = nameVal;
27083
+ document.getElementById('skill-modal-name').disabled = opts.mode === 'edit';
27084
+ document.getElementById('skill-modal-title').nextElementSibling; // no-op
27085
+ document.getElementById('skill-modal-title').value = titleVal;
27086
+ document.getElementById('skill-modal-desc').value = descVal;
27087
+ document.getElementById('skill-modal-tools').value = toolsVal;
27088
+ document.getElementById('skill-modal-body').value = bodyVal;
27089
+ var errEl = document.getElementById('skill-modal-error');
27090
+ if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
27091
+ modal.style.display = 'flex';
27092
+ document.getElementById('skill-modal-name').focus();
27093
+ }
27094
+
27095
+ function closeSkillModal() {
27096
+ var m = document.getElementById('skill-edit-modal');
27097
+ if (m) m.style.display = 'none';
27098
+ }
27099
+
27100
+ async function saveSkillFromModal() {
27101
+ var name = (document.getElementById('skill-modal-name')?.value || '').trim();
27102
+ var title = (document.getElementById('skill-modal-title')?.value || '').trim();
27103
+ var desc = (document.getElementById('skill-modal-desc')?.value || '').trim();
27104
+ var toolsRaw = (document.getElementById('skill-modal-tools')?.value || '').trim();
27105
+ var body = (document.getElementById('skill-modal-body')?.value || '');
27106
+ var originalName = (document.getElementById('skill-modal-original-name')?.value || '').trim();
27107
+ var errEl = document.getElementById('skill-modal-error');
27108
+ function fail(msg) {
27109
+ if (errEl) { errEl.textContent = msg; errEl.style.display = ''; }
27110
+ else toast(msg, 'error');
27111
+ }
27112
+ if (!name) return fail('Name is required.');
27113
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) return fail('Name must be lowercase letters/digits/dashes, start with a letter or digit, max 64 chars.');
27114
+ if (!desc) return fail('Description is required (used by Claude to decide when to apply this skill).');
27115
+ if (desc.length > 1024) return fail('Description must be ≤ 1024 chars (Anthropic spec).');
27116
+ if (!body.trim()) return fail('Procedure body is required.');
27117
+ var tools = toolsRaw ? toolsRaw.split(',').map(function(s){ return s.trim(); }).filter(Boolean) : [];
27118
+ var saveBtn = document.getElementById('skill-modal-save');
27119
+ if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving…'; }
27120
+ try {
27121
+ var endpoint = originalName ? '/api/skills/' + encodeURIComponent(originalName) : '/api/skills';
27122
+ var method = originalName ? 'PUT' : 'POST';
27123
+ var r = await apiFetch(endpoint, {
27124
+ method: method,
27125
+ headers: { 'Content-Type': 'application/json' },
27126
+ body: JSON.stringify({ name: name, title: title || undefined, description: desc, tools: tools, body: body }),
27127
+ });
27128
+ if (!r.ok) {
27129
+ var d = await r.json().catch(function(){ return {}; });
27130
+ return fail(d.error || ('Save failed: HTTP ' + r.status));
27131
+ }
27132
+ closeSkillModal();
27133
+ toast(originalName ? 'Skill updated' : 'Skill created', 'success');
27134
+ if (typeof refreshSkillsPage === 'function') await refreshSkillsPage();
27135
+ // Auto-open the freshly-saved skill so the user sees their work.
27136
+ if (typeof showSkillDetail === 'function') showSkillDetail(name);
27137
+ } catch (err) { fail('Save failed: ' + err); }
27138
+ finally { if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save skill'; } }
27139
+ }
27140
+
27141
+ async function confirmDeleteSkill(name) {
27142
+ if (!confirm('Delete skill "' + name + '"? The folder will be removed; the .md.bak (if present) is preserved.')) return;
27143
+ try {
27144
+ var r = await apiFetch('/api/skills/' + encodeURIComponent(name), { method: 'DELETE' });
27145
+ if (!r.ok) {
27146
+ var d = await r.json().catch(function(){ return {}; });
27147
+ toast(d.error || 'Delete failed', 'error');
27148
+ return;
27149
+ }
27150
+ toast('Skill "' + name + '" deleted', 'success');
27151
+ if (typeof refreshSkillsPage === 'function') await refreshSkillsPage();
27152
+ } catch (err) { toast('Delete failed: ' + err, 'error'); }
27153
+ }
27154
+
26751
27155
  async function refreshSkillsPage() {
26752
27156
  var listEl = document.getElementById('skills-list');
26753
27157
  var detailEl = document.getElementById('skills-detail');
@@ -27027,7 +27431,10 @@ function renderSkillDetail(s) {
27027
27431
  html += renderSkillSection('Usage', '<div style="font-size:12px;color:var(--text-secondary)">' + ubits.map(esc).join(' · ') + '</div>');
27028
27432
  }
27029
27433
 
27030
- // ── 7. Procedure body (with line counter)
27434
+ // ── 7. Procedure body — rendered as markdown (1.18.115). Prior shipped
27435
+ // raw <pre> source which read like config, not procedure. Headers/lists/
27436
+ // bold/code now render visually. Line counter still appears so authors
27437
+ // know if they're approaching Anthropic's ≤500-line guidance.
27031
27438
  if (s.body && s.body.trim()) {
27032
27439
  var bodyClass = bodyLines > 500 ? 'color:var(--yellow)' : 'color:var(--text-muted)';
27033
27440
  html += '<div style="margin-top:18px">';
@@ -27035,10 +27442,17 @@ function renderSkillDetail(s) {
27035
27442
  html += '<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;font-weight:500">Procedure</div>';
27036
27443
  html += '<div style="font-size:10px;' + bodyClass + ';font-family:\\x27JetBrains Mono\\x27,monospace">' + bodyLines + ' / 500 lines</div>';
27037
27444
  html += '</div>';
27038
- html += '<pre style="font-size:12px;line-height:1.55;background:var(--bg-tertiary);padding:14px 16px;border-radius:6px;white-space:pre-wrap;word-break:break-word;font-family:inherit;border:1px solid var(--border);max-height:500px;overflow:auto">' + esc(s.body) + '</pre>';
27445
+ html += '<div class="skill-md" style="font-size:13px;line-height:1.6;color:var(--text-primary);background:var(--bg-secondary);padding:18px 22px;border-radius:8px;border:1px solid var(--border);max-height:560px;overflow:auto">' + renderMarkdown(s.body) + '</div>';
27039
27446
  html += '</div>';
27040
27447
  }
27041
27448
 
27449
+ // ── 8. Action footer (1.18.115) — Edit + Delete + Open file. The pane
27450
+ // was read-only; users had to leave the dashboard to edit anything.
27451
+ html += '<div style="margin-top:24px;display:flex;gap:8px;flex-wrap:wrap">';
27452
+ html += '<button class="btn-primary" onclick="openEditSkillModal(\\x27' + jsStr(fm.name) + '\\x27)" style="font-size:12px;padding:6px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:500;cursor:pointer">Edit skill</button>';
27453
+ html += '<button class="btn-sm" onclick="confirmDeleteSkill(\\x27' + jsStr(fm.name) + '\\x27)" style="font-size:12px;padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--red);cursor:pointer">Delete</button>';
27454
+ html += '</div>';
27455
+
27042
27456
  // ── 8. Schema-specific footer
27043
27457
  if (s.schemaVersion === 'legacy') {
27044
27458
  html += '<div style="margin-top:24px;padding:12px 14px;background:rgba(245,158,11,0.08);border:1px solid var(--yellow);border-radius:6px;font-size:12px;color:var(--text-secondary);line-height:1.5">'
@@ -27616,11 +28030,20 @@ function renderSkillsPickerList() {
27616
28030
  return hay.indexOf(q) !== -1;
27617
28031
  });
27618
28032
  }
28033
+ // 1.18.115 — "+ Create new skill" affordance pinned to the top of the
28034
+ // picker. Lets users author a skill in-flight without losing their cron
28035
+ // edit; the new skill appears in the list right after the modal closes.
28036
+ var createRow = '<div class="cap-picker-row" style="border:1px dashed var(--accent);background:transparent" onclick="openCreateSkillModal()">'
28037
+ + '<div class="cap-picker-row-body">'
28038
+ + '<div class="cap-picker-row-title" style="color:var(--accent);font-weight:600">+ Create new skill</div>'
28039
+ + '<div class="cap-picker-row-desc">Author a fresh skill — opens the editor without leaving this task.</div>'
28040
+ + '</div>'
28041
+ + '</div>';
27619
28042
  if (skills.length === 0) {
27620
- listEl.innerHTML = '<div class="cap-picker-empty-state">' + (q ? 'No matches.' : 'No skills available.') + '</div>';
28043
+ listEl.innerHTML = createRow + '<div class="cap-picker-empty-state">' + (q ? 'No matches.' : 'No skills available.') + '</div>';
27621
28044
  return;
27622
28045
  }
27623
- listEl.innerHTML = skills.slice(0, 50).map(function(s) {
28046
+ listEl.innerHTML = createRow + skills.slice(0, 50).map(function(s) {
27624
28047
  var sel = _cronSelectedSkills.indexOf(s.name) !== -1;
27625
28048
  var triggers = (s.triggers || []).slice(0, 4).join(', ');
27626
28049
  return '<div class="cap-picker-row' + (sel ? ' selected' : '') + '" onclick="addSkillToTrick(\\x27' + jsStr(s.name) + '\\x27)">'
@@ -28300,19 +28723,21 @@ function onPredictableChange() {
28300
28723
  function renderCronLegacyBanner(job) {
28301
28724
  var host = document.getElementById('cron-legacy-banner-host');
28302
28725
  if (!host) return;
28303
- // Banner only when predictable is undefined or explicitly false. Jobs
28304
- // saved with predictable: true are migrated and skip the banner.
28726
+ // Predictable jobs skip the tip entirely; legacy jobs get a non-alarming
28727
+ // suggestion. The previous wording ("OUTPUT MAY NOT MATCH WHAT YOU SEE
28728
+ // HERE") read as "the editor is lying to you" and made every legacy task
28729
+ // feel broken. It isn't — runs work fine. The toggle below explains the
28730
+ // trade-off; this is just a one-click shortcut for the common upgrade.
28305
28731
  if (job && job.predictable === true) { host.innerHTML = ''; return; }
28306
- var msg = (job && job.predictable === false)
28307
- ? "This task is set to legacy mode. At fire-time the runner injects MEMORY.md, recent team activity, the delegation queue, and auto-matches MCP servers based on prompt text — even if your prompt forbids them. The <strong>What will run</strong> tab shows what actually gets attached."
28308
- : "This task was created before Predictable Mode existed. At fire-time the runner still injects MEMORY.md, recent team activity, and auto-matches MCP servers based on prompt text. Open the <strong>What will run</strong> tab to see what actually gets attached.";
28309
28732
  host.innerHTML =
28310
- '<div class="cron-banner warn">'
28311
- + '<h5>⚠ Legacy mode — output may not match what you see here</h5>'
28312
- + '<div>' + msg + '</div>'
28313
- + '<div class="banner-actions">'
28314
- + '<button class="btn-primary btn-sm" onclick="enablePredictableFromBanner()">Migrate now</button>'
28315
- + '<button class="btn-sm" onclick="switchCronTab(\\x27preview\\x27)" style="background:transparent;border:1px solid var(--border);color:var(--text-primary)">See what will run</button>'
28733
+ '<div class="cron-banner info">'
28734
+ + '<div style="display:flex;align-items:flex-start;gap:10px">'
28735
+ + '<span style="font-size:14px;line-height:1.2;flex-shrink:0">💡</span>'
28736
+ + '<div style="flex:1;min-width:0">'
28737
+ + '<div style="font-size:13px;font-weight:600;color:var(--text-primary);margin-bottom:2px">Strict mode available</div>'
28738
+ + '<div style="font-size:12px;color:var(--text-secondary);line-height:1.45">Runs use only the prompt + pinned skills/MCP below — no auto-injected memory or team comms. More reproducible. <a href="javascript:void(0)" onclick="switchCronTab(\\x27preview\\x27)" style="color:var(--accent);text-decoration:none">See what runs today →</a></div>'
28739
+ + '</div>'
28740
+ + '<button class="btn-primary btn-sm" onclick="enablePredictableFromBanner()" style="flex-shrink:0;font-size:11px;padding:5px 12px">Switch on</button>'
28316
28741
  + '</div>'
28317
28742
  + '</div>';
28318
28743
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.114",
3
+ "version": "1.18.117",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",