1// skills.js â Skills tab in the Memory modal. 2// 3// Skills are SKILL.md files (frontmatter + body) under data/skills/. 4// This UI supports: list, search, view (read SKILL.md), edit (replace 5// content), publish/draft toggle, delete, and "run as slash" via the 6// /<skill-name> path. 7 8import uiModule from './ui.js'; 9import * as spinnerModule from './spinner.js'; 10 11const API = window.location.origin; 12let skills = []; 13let builtinSkills = []; // read-only agent tool capabilities (TOOL_SECTIONS) 14let loaded = false; 15let _loadPromise = null; 16 17function esc(s) { return uiModule.esc(String(s ?? '')); } 18 19let _pendingFocusSkill = null; 20let _cascadeNext = false; // set true to play the domino-in entrance on the next render 21 22function _playSkillsCascade(container = document.getElementById('skills-list')) { 23 if (!container || !container.querySelector('.skill-card')) return false; 24 container.classList.remove('doclib-just-opened'); 25 void container.offsetWidth; 26 container.classList.add('doclib-just-opened'); 27 setTimeout(() => container.classList.remove('doclib-just-opened'), 900); 28 return true; 29} 30 31// Cache of SKILL.md text by skill name, so expanding is instant (no async 32// fetch + content-settle jump). Populated lazily on expand AND eagerly in 33// the background for all visible cards right after render. 34const _mdCache = new Map(); 35async function _fetchSkillMarkdown(name) { 36 if (_mdCache.has(name)) return _mdCache.get(name); 37 const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}/markdown`); 38 if (!res.ok) throw new Error(`HTTP ${res.status}`); 39 const data = await res.json(); 40 const md = data.markdown || ''; 41 _mdCache.set(name, md); 42 return md; 43} 44// Background-load the markdown for every currently-rendered skill card so it 45// is ready (in the card's <pre> + _mdLoaded) before the user expands it. 46function _preloadVisibleMarkdown() { 47 document.querySelectorAll('#skills-list .skill-card[data-skill-name]').forEach(card => { 48 const name = card.dataset.skillName; 49 if (!name || card._mdLoaded) return; 50 const pre = card.querySelector('.skill-md-pre'); 51 const apply = (md) => { if (pre) pre.textContent = md || '(empty)'; card._mdLoaded = true; card._md = md || ''; }; 52 if (_mdCache.has(name)) { apply(_mdCache.get(name)); return; } 53 _fetchSkillMarkdown(name).then(apply).catch(() => {}); 54 }); 55} 56 57// Collapsed skills sections ("user" / "builtin"), persisted so the 58// choice survives reloads. Built-in defaults to collapsed (it's 59// reference info, not the user's own skills). 60const _collapsedSections = (() => { 61 try { 62 const raw = localStorage.getItem('skillsSectionsCollapsed'); 63 if (raw) return new Set(JSON.parse(raw)); 64 } catch (_) {} 65 return new Set(['builtin']); 66})(); 67function _saveCollapsedSections() { 68 try { localStorage.setItem('skillsSectionsCollapsed', JSON.stringify([..._collapsedSections])); } catch (_) {} 69} 70function _applySectionCollapse(container) { 71 if (!container) return; 72 container.querySelectorAll('.skills-section-header').forEach(h => { 73 h.classList.toggle('collapsed', _collapsedSections.has(h.dataset.section)); 74 }); 75 container.querySelectorAll('.doclib-card[data-skill-section]').forEach(c => { 76 c.classList.toggle('skill-card-section-hidden', _collapsedSections.has(c.dataset.skillSection)); 77 }); 78} 79 80export async function loadSkills(cascade = false) { 81 // Play the domino-in entrance on this load (set when the tab is opened, 82 // not for the silent re-loads after an edit/delete). 83 if (cascade) _cascadeNext = true; 84 if (cascade && loaded && !_loadPromise && _playSkillsCascade()) { 85 _cascadeNext = false; 86 updateCount(); 87 return; 88 } 89 if (_loadPromise) return _loadPromise; 90 _loadPromise = (async () => { 91 try { 92 const res = await fetch(`${API}/api/skills`); 93 const data = await res.json(); 94 skills = data.skills || []; 95 _loadSkillApprovalThreshold(); 96 // Built-in capabilities are no longer surfaced in the Skills menu. 97 loaded = true; 98 renderSkillsList(); 99 updateCount(); 100 if (_pendingFocusSkill) { 101 _focusSkillRow(_pendingFocusSkill); 102 _pendingFocusSkill = null; 103 }
104 // If a background audit is running, re-show its progress panel. 105 if (!_auditPoll) { 106 _fetchAuditStatus().then(st => { 107 if (st.status === 'running') _auditAllSkills(); 108 }).catch(() => {}); 109 } 110 } catch (e) { 111 console.error('Failed to load skills:', e); 112 } finally { 113 _loadPromise = null; 114 } 115 })(); 116 return _loadPromise; 117} 118 119function _focusSkillRow(name) { 120 setTimeout(() => { 121 const card = document.querySelector(`.skill-card[data-skill-name="${CSS.escape(name)}"]`); 122 if (!card) return; 123 card.scrollIntoView({ behavior: 'smooth', block: 'center' }); 124 card.classList.add('skill-row-flash'); 125 setTimeout(() => card.classList.remove('skill-row-flash'), 2000); 126 // Expand it so the linked skill opens to its SKILL.md directly. 127 _expandSkillCard(card, name); 128 }, 200); 129} 130 131// Open the Memory modal â Skills tab â focus a specific skill row. 132// Used by the chat anchor-link delegate ([name](#skill-<name>)). 133export function openSkill(name) { 134 _pendingFocusSkill = name || null; 135 // Open the memory modal if not already open. 136 const memBtn = document.getElementById('tool-memory-btn'); 137 if (memBtn) memBtn.click(); 138 // Switch to the skills tab (triggers lazy loadSkills()). 139 setTimeout(() => { 140 const tab = document.querySelector('.memory-tab[data-memory-tab="skills"]'); 141 if (tab) tab.click(); 142 else loadSkills(); // fallback if tab structure differs 143 }, 120); 144} 145 146let _skillsSort = 'confidence'; 147let _showDraftsOnly = false; 148let _showPublishedOnly = false; 149let _confMax = null; // confidence ceiling filter (%, e.g. 90 = show â¤90%); null = off 150let _selectMode = false; 151const _selectedNames = new Set(); 152let _skillApprovalThreshold = 0.85; 153 154function updateCount() { 155 const el = document.getElementById('skills-count'); 156 if (el) el.textContent = skills.length || '0'; 157 const elH = document.getElementById('skills-count-h2'); 158 if (elH) elH.textContent = skills.length + ' skill' + (skills.length === 1 ? '' : 's'); 159} 160 161function _sortSkills(list) { 162 const arr = list.slice(); 163 if (_skillsSort === 'confidence') { 164 arr.sort((a, b) => (b.confidence || 0) - (a.confidence || 0) || (a.name || '').localeCompare(b.name || '')); 165 } else if (_skillsSort === 'uses') { 166 arr.sort((a, b) => (b.uses || 0) - (a.uses || 0) || (a.name || '').localeCompare(b.name || '')); 167 } else if (_skillsSort === 'recent') { 168 arr.sort((a, b) => (b.updated_at || b.created_at || 0) - (a.updated_at || a.created_at || 0)); 169 } else { 170 arr.sort((a, b) => (a.name || '').localeCompare(b.name || '')); 171 } 172 return arr; 173} 174 175function _matches(sk, query) { 176 const q = query.toLowerCase(); 177 return ( 178 (sk.name || '').toLowerCase().includes(q) || 179 (sk.description || '').toLowerCase().includes(q) || 180 (sk.when_to_use || sk.problem || '').toLowerCase().includes(q) || 181 (sk.category || '').toLowerCase().includes(q) || 182 (sk.tags || []).some(t => (t || '').toLowerCase().includes(q)) 183 ); 184} 185 186function _statusPill(sk) { 187 const s = sk.status || (sk._legacy ? 'legacy' : 'draft'); 188 if (s === 'published') return '<span class="memory-cat-badge skill-status-pill" data-status="published" style="background:color-mix(in srgb, var(--accent, #4ade80) 30%, transparent)">published</span>'; 189 if (s === 'draft') return '<span class="memory-cat-badge skill-status-pill" data-status="draft" style="background:color-mix(in srgb, var(--fg) 14%, transparent)">draft</span>'; 190 return `<span class="memory-cat-badge skill-status-pill" data-status="${esc(s)}" style="opacity:0.6">${esc(s)}</span>`; 191} 192 193// Show a "teacher" badge for skills written by the auto-escalation 194// teacher loop. Lets the user tell at-a-glance which procedures were 195// hand-authored vs auto-generated so they can audit (and demote / 196// edit / publish) before trusting them. 197function _sourcePill(sk) { 198 if (sk.source !== 'teacher-escalation') return ''; 199 const teacher = sk.teacher_model || 'teacher'; 200 return `<span class="memory-cat-badge" title="Created by teacher escalation: ${esc(teacher)}" style="background:color-mix(in srgb, var(--color-warning, #f0ad4e) 22%, transparent);">teacher-created</span>`;
201} 202 203function _modelShortName(model) { 204 return String(model || '').split('/').filter(Boolean).pop() || String(model || ''); 205} 206 207function _skillTokens(sk) { 208 return new Set(String([ 209 sk.name || '', 210 sk.description || '', 211 sk.when_to_use || '', 212 ...(sk.tags || []), 213 ].join(' ')).toLowerCase() 214 .replace(/-\d+\b/g, '') 215 .split(/[^a-z0-9]+/) 216 .filter(t => t.length > 2 && !['the', 'and', 'with', 'for', 'from', 'using'].includes(t))); 217} 218 219function _skillSimilarity(a, b) { 220 const A = _skillTokens(a), B = _skillTokens(b); 221 if (!A.size || !B.size) return 0; 222 let inter = 0; 223 for (const t of A) if (B.has(t)) inter++; 224 return inter / (A.size + B.size - inter); 225} 226 227function _baseSkillName(name) { 228 return String(name || '').replace(/-\d+$/, ''); 229} 230 231function _scoreDuplicateKeeper(sk) { 232 return [ 233 (sk.status === 'published') ? 100000 : 0, 234 (sk.uses || 0) * 100, 235 Math.round((sk.confidence || 0) * 100), 236 sk.audit_by_teacher ? -5 : 0, 237 -String(sk.name || '').length / 1000, 238 ].reduce((a, b) => a + b, 0); 239} 240 241function _duplicateMeta(list) { 242 const parent = new Map(); 243 const names = list.map(s => s.name || s.id).filter(Boolean); 244 names.forEach(n => parent.set(n, n)); 245 const find = (x) => { 246 let p = parent.get(x) || x; 247 while (p !== parent.get(p)) p = parent.get(p); 248 return p; 249 }; 250 const unite = (a, b) => { 251 const pa = find(a), pb = find(b); 252 if (pa !== pb) parent.set(pb, pa); 253 }; 254 for (let i = 0; i < list.length; i++) { 255 for (let j = i + 1; j < list.length; j++) { 256 const a = list[i], b = list[j]; 257 const an = a.name || a.id, bn = b.name || b.id; 258 if (!an || !bn) continue; 259 if (_baseSkillName(an) === _baseSkillName(bn) || _skillSimilarity(a, b) >= 0.38) { 260 unite(an, bn); 261 } 262 } 263 } 264 const groups = new Map(); 265 for (const sk of list) { 266 const n = sk.name || sk.id; 267 if (!n) continue; 268 const root = find(n); 269 if (!groups.has(root)) groups.set(root, []); 270 groups.get(root).push(sk); 271 } 272 const meta = new Map(); 273 let idx = 1; 274 for (const group of groups.values()) { 275 if (group.length < 2) continue; 276 const sorted = group.slice().sort((a, b) => _scoreDuplicateKeeper(b) - _scoreDuplicateKeeper(a)); 277 const keep = sorted[0].name || sorted[0].id; 278 const groupNames = sorted.map(s => s.name || s.id).filter(Boolean); 279 for (const sk of sorted) { 280 const n = sk.name || sk.id; 281 meta.set(n, { group: idx, keep: n === keep, keepName: keep, names: groupNames }); 282 } 283 idx++; 284 } 285 return meta; 286} 287 288function _auditModelPills(sk) { 289 const worker = sk.audit_worker_model || ''; 290 const teacher = sk.audit_teacher_model || ''; 291 let html = ''; 292 if (worker) { 293 html += `<span class="memory-cat-badge skill-model-pill skill-model-student" title="Last audited by default audit model: ${esc(worker)}">audit</span>`; 294 } 295 if (sk.audit_by_teacher || teacher) { 296 const title = teacher 297 ? `Teacher rewrote this skill; audit model passed after the rewrite. Teacher: ${teacher}` 298 : 'Teacher rewrote this skill; audit model passed after the rewrite.'; 299 html += `<span class="memory-cat-badge skill-model-pill skill-model-teacher" title="${esc(title)}">teacher-fixed</span>`; 300 } 301 return html; 302} 303 304function _necessityKind(sk) { 305 const nec = sk && sk.necessity; 306 if (sk && sk._duplicateGroup) return 'duplicate'; 307 if (!nec || nec.necessary !== false) return null; 308 const reason = String(nec.reason || '').toLowerCase(); 309 const redundant = (nec.redundant_with || []).filter(Boolean); 310 if (redundant.length || /duplicat|redundan|overlap|same skill|same procedure/.test(reason)) return 'duplicate'; 311 if (/trivial|generic|capable assistant|without a saved|not need|unnecessary/.test(reason)) return 'trivial'; 312 return 'irrelevant'; 313} 314 315function _necessityPill(sk) { 316 const kind = _necessityKind(sk); 317 if (!kind) return ''; 318 const nec = sk.necessity || {}; 319 const dup = (nec.redundant_with || []).filter(Boolean); 320 const label = kind === 'duplicate' ? (sk._duplicateGroup ? `duplicate #${sk._duplicateGroup}` : 'duplicate') 321 : kind === 'trivial' ? 'generic' 322 : 'possibly-irrelevant'; 323 const group = sk._duplicateNames || []; 324 const why = sk._duplicateGroup 325 ? `Duplicate group #${sk._duplicateGroup}. Recommended keep: ${sk._duplicateKeepName}. Group: ${group.join(', ')}` 326 : (nec.reason || 'May not be worth keeping') + (dup.length ? ' | overlaps: ' + dup.join(', ') : ''); 327 return `<span class="memory-cat-badge skill-necessity-pill skill-necessity-${kind}" title="${esc(why)}">${label}</span>`;
328} 329 330function _duplicatePriorityPill(sk) { 331 if (!sk._duplicateGroup) return ''; 332 if (sk._duplicateKeep) { 333 return `<span class="memory-cat-badge skill-duplicate-keep" title="Best duplicate candidate by published status, uses, confidence, and specificity">recommended</span>`; 334 } 335 return `<span class="memory-cat-badge skill-duplicate-lower" title="Lower-priority duplicate. Suggested keeper: ${esc(sk._duplicateKeepName || '')}">lower-priority</span>`; 336} 337 338// Verified-by-test indicators shown next to the confidence %. A check when a 339// test/audit run passed; a graduation-cap when the teacher model had to 340// rewrite the skill to make it pass. SVG (no Unicode emoji). 341function _auditMarks(sk) { 342 let html = ''; 343 if (sk.audit_verdict === 'pass') { 344 html += `<span class="skill-verified" title="Passed an automated test"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>`; 345 } 346 if (sk.audit_by_teacher) { 347 const teacher = sk.audit_teacher_model ? `: ${sk.audit_teacher_model}` : ''; 348 html += `<span class="skill-teachermark" title="Teacher rewrote this skill; audit model passed after the rewrite${esc(teacher)}"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 10L12 5 2 10l10 5 10-5z"/><path d="M6 12v5c0 1 3 2 6 2s6-1 6-2v-5"/></svg></span>`; 349 } 350 return html; 351} 352 353// Audit verdict dot â removed at user request. The â check-mark next to the 354// confidence % still indicates a pass. Stub returns empty so the surrounding 355// header HTML still composes without changing other layout. 356function _auditDot(sk) { return ''; } 357 358function _isDraftsFilter() { return !!_showDraftsOnly; } 359 360// Confidence â colour. 90%+ is solidly green, scaling down through 361// yellow/orange to red at 50% and below (hue 120â0 over 90â50). 362function _confColor(conf) { 363 const hue = Math.max(0, Math.min(120, ((conf - 50) / 40) * 120)); 364 return `hsl(${Math.round(hue)}, 70%, 42%)`; 365} 366 367// Shared action icons (collapsed kebab menu + expanded footer use the same). 368const _ICON = { 369 del: '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>', 370 edit: '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>', 371 approve: '<polyline points="20 6 9 17 4 12"/>', 372 unpublish: '<path d="M5 12l5 5L20 7"/>', 373 test: '<polygon points="5 3 19 12 5 21 5 3"/>', 374}; 375function _svg(paths, { fill = 'none', size = 13 } = {}) { 376 const stroke = fill === 'currentColor' ? '' : 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; 377 return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" ${stroke} style="vertical-align:-2px;flex-shrink:0;">${paths}</svg>`; 378} 379 380// Kebab dropdown for a collapsed skill card â same actions + icons as the 381// expanded footer (Publish/Unpublish · Edit · Delete). 382function _openSkillMenu(btn, card, sk, name, isPublished) { 383 document.querySelectorAll('.skill-kebab-menu').forEach(m => m.remove()); 384 const menu = document.createElement('div'); 385 menu.className = 'skill-kebab-menu'; 386 const mk = (paths, label, opts, onClick) => { 387 const item = document.createElement('button'); 388 item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : ''); 389 item.innerHTML = _svg(paths, opts) + `<span>${label}</span>`; 390 item.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); onClick(); }); 391 menu.appendChild(item); 392 }; 393 if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft')); 394 else mk(_ICON.approve, 'Publish', {}, () => _setSkillStatus(name, 'published')); 395 mk(_ICON.edit, 'Edit', {}, async () => { 396 if (!card.classList.contains('doclib-card-expanded')) await _expandSkillCard(card, name); 397 _toggleSkillEdit(card, name); 398 }); 399 mk(_ICON.test, 'Test', {}, () => _testSkill(card, name));
400 // Audit kicks off the bulk audit-all loop (test â judge â fix â retry â demote). 401 // Starts at the top of the list and walks down. 402 mk(_ICON.test, 'Audit', {}, () => _auditAllSkills()); 403 mk(_ICON.del, 'Delete', { danger: true }, () => _deleteSkill(name, card)); 404 405 // Select â enters bulk-select mode and pre-selects this skill. Same pattern 406 // as the email/documents/brain Select item, with the email bullet icon. 407 const selItem = document.createElement('button'); 408 selItem.className = 'skill-kebab-item'; 409 selItem.innerHTML = '<span style="display:inline-flex;width:14px;height:14px;align-items:center;justify-content:center;"><span style="font-size:16px;line-height:1;">â</span></span><span>Select</span>'; 410 selItem.addEventListener('click', (e) => { 411 e.stopPropagation(); 412 menu.remove(); 413 if (!_selectMode) _enterSelectMode(); 414 _selectedNames.add(name); 415 renderSkillsList(); 416 }); 417 menu.appendChild(selItem); 418 419 // Mobile-only Cancel â mirrors the email/documents/brain popup pattern. 420 // CSS hides `.dropdown-cancel-mobile` on desktop where outside-click 421 // already dismisses cleanly. 422 const cancelItem = document.createElement('button'); 423 cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile'; 424 cancelItem.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Cancel</span>'; 425 cancelItem.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); }); 426 menu.appendChild(cancelItem); 427 428 document.body.appendChild(menu); 429 const r = btn.getBoundingClientRect(); 430 menu.style.top = (r.bottom + 4) + 'px'; 431 menu.style.right = Math.max(6, window.innerWidth - r.right) + 'px'; 432 // Keep it on-screen (mobile): flip above the button if it would overflow the 433 // bottom, clamp the left edge, and cap the height as a last resort. 434 const mr = menu.getBoundingClientRect(); 435 if (mr.bottom > window.innerHeight - 6) { 436 menu.style.top = Math.max(6, r.top - mr.height - 4) + 'px'; 437 } 438 if (mr.left < 6) { 439 menu.style.right = Math.max(6, window.innerWidth - 6 - mr.width) + 'px'; 440 } 441 const mr2 = menu.getBoundingClientRect(); 442 if (mr2.bottom > window.innerHeight - 6) { 443 menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px'; 444 menu.style.overflowY = 'auto'; 445 } 446 const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } }; 447 setTimeout(() => document.addEventListener('click', close, true), 0); 448} 449 450// Cards for the agent's built-in tool capabilities (from 451// /api/skills/builtin â TOOL_SECTIONS). Expandable to preview the 452// instruction block; editable with a warning + a revert-to-default 453// button (overrides stored in settings, applied to the prompt). 454function _buildBuiltinCards() { 455 return builtinSkills.map(b => { 456 const card = document.createElement('div'); 457 card.className = 'doclib-card skill-card skill-builtin-card'; 458 card.dataset.builtinName = b.name; 459 460 const header = document.createElement('div'); 461 header.className = 'doclib-card-header skill-card-header'; 462 header.innerHTML = ` 463 <span class="skill-conf-dot" style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent, var(--red));flex-shrink:0;margin-right:6px;opacity:0.55;"></span> 464 <div style="flex:1;min-width:0;overflow:hidden;"> 465 <div class="doclib-card-title" style="display:flex;align-items:center;gap:6px;min-width:0;"> 466 <code style="font-weight:600;font-size:0.9em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex-shrink:1;min-width:0;">${esc(b.name)}</code> 467 <span class="memory-cat-badge" style="background:color-mix(in srgb, var(--fg) 14%, transparent)">built-in</span> 468 ${b.is_overridden ? '<span class="memory-cat-badge" title="You have edited this built-in capability" style="background:color-mix(in srgb, var(--color-warning, #f0ad4e) 30%, transparent);">edited</span>' : ''} 469 </div> 470 ${b.description ? `<div class="doclib-card-session" title="${esc(b.description)}" style="font-size:10px;opacity:0.55;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(b.description)}</div>` : ''} 471 </div>
472 <span class="doclib-card-chevron"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span> 473 `; 474 card.appendChild(header); 475 476 const preview = document.createElement('div'); 477 preview.className = 'doclib-card-preview skill-card-preview'; 478 // Warning banner â editing a built-in changes how the assistant uses a native tool. 479 const warn = document.createElement('div'); 480 warn.className = 'skill-builtin-warn'; 481 warn.innerHTML = 'â This is a built-in capability. Editing changes how the assistant is instructed to use this native tool â it can break or alter core behaviour. Use Revert to restore the shipped default.'; 482 preview.appendChild(warn); 483 const pre = document.createElement('pre'); 484 pre.className = 'skill-md-pre'; 485 pre.textContent = ''; // filled on expand 486 preview.appendChild(pre); 487 488 // Footer: Revert (left, only meaningful when overridden) · Edit/Save (right). 489 const actions = document.createElement('div'); 490 actions.className = 'doclib-card-expanded-actions'; 491 492 const revertBtn = document.createElement('button'); 493 revertBtn.className = 'doclib-card-text-btn doclib-card-action-btn doclib-card-text-btn-danger';
494 revertBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>Revert'; 495 revertBtn.title = 'Restore the original shipped instructions'; 496 revertBtn.addEventListener('click', (e) => { e.stopPropagation(); _revertBuiltin(b.name); }); 497 498 const editBtn = document.createElement('button'); 499 editBtn.className = 'doclib-card-text-btn doclib-card-action-btn'; 500 editBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Edit'; 501 editBtn.addEventListener('click', (e) => { e.stopPropagation(); _toggleBuiltinEdit(card, b.name); }); 502 503 const rightGroup = document.createElement('div'); 504 rightGroup.className = 'doclib-action-group'; 505 const btnRow = document.createElement('div'); 506 btnRow.className = 'doclib-action-btn-row'; 507 btnRow.appendChild(editBtn); 508 rightGroup.appendChild(btnRow); 509 510 actions.appendChild(revertBtn); 511 actions.appendChild(rightGroup); 512 preview.appendChild(actions); 513 card.appendChild(preview); 514 515 card.addEventListener('click', (e) => { 516 if (e.target.closest('button, input, textarea')) return; 517 _expandBuiltinCard(card, b.name); 518 }); 519 return card; 520 }); 521} 522 523async function _expandBuiltinCard(card, name) { 524 const grid = card.closest('.doclib-grid'); 525 if (card.classList.contains('doclib-card-expanded')) { 526 card.classList.remove('doclib-card-expanded'); 527 return; 528 } 529 if (grid) grid.querySelectorAll('.doclib-card-expanded').forEach(c => c.classList.remove('doclib-card-expanded')); 530 card.classList.add('doclib-card-expanded'); 531 if (grid) grid.scrollTop = 0; 532 const pre = card.querySelector('.skill-md-pre'); 533 if (pre && !card._loaded) { 534 pre.textContent = 'Loadingâ¦'; 535 try { 536 const res = await fetch(`${API}/api/skills/builtin/${encodeURIComponent(name)}`); 537 if (!res.ok) throw new Error(`HTTP ${res.status}`); 538 const data = await res.json(); 539 pre.textContent = data.text || '(empty)'; 540 card._loaded = true; 541 card._text = data.text || ''; 542 card._default = data.default || ''; 543 } catch (e) { 544 pre.textContent = 'Failed to load.'; 545 } 546 } 547} 548 549function _toggleBuiltinEdit(card, name) { 550 const preview = card.querySelector('.skill-card-preview'); 551 if (!preview) return; 552 if (preview.querySelector('.skill-md-editor')) { _saveBuiltinEdit(card, name); return; } 553 const pre = preview.querySelector('.skill-md-pre'); 554 const ta = document.createElement('textarea'); 555 ta.className = 'skill-md-editor'; 556 ta.spellcheck = false; 557 ta.value = (card._text != null ? card._text : (pre ? pre.textContent : '')) || ''; 558 ta.addEventListener('click', (e) => e.stopPropagation()); 559 if (pre) pre.style.display = 'none'; 560 preview.insertBefore(ta, preview.querySelector('.doclib-card-expanded-actions')); 561 ta.focus(); 562 const editBtn = [...preview.querySelectorAll('.doclib-card-action-btn')].find(b => /Edit|Save/.test(b.textContent)); 563 if (editBtn) editBtn.innerHTML = '<svg width="11" height="11" viewB
563ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save'; 564} 565 566async function _saveBuiltinEdit(card, name) { 567 const ta = card.querySelector('.skill-md-editor'); 568 if (!ta) return; 569 try { 570 const res = await fetch(`${API}/api/skills/builtin/${encodeURIComponent(name)}`, { 571 method: 'PUT', 572 headers: { 'Content-Type': 'application/json' }, 573 body: JSON.stringify({ text: ta.value }), 574 }); 575 if (!res.ok) throw new Error(`HTTP ${res.status}`); 576 uiModule.showToast('Built-in capability updated'); 577 builtinSkills = []; // force reload of built-in list (refreshes "edited" badge) 578 await loadSkills(); 579 } catch (e) { uiModule.showError('Save failed: ' + e.message); } 580} 581 582async function _revertBuiltin(name) { 583 if (!(await uiModule.styledConfirm(`Revert "${name}" to its original built-in instructions?`, { confirmText: 'Revert', danger: true }))) return; 584 try { 585 const res = await fetch(`${API}/api/skills/builtin/${encodeURIComponent(name)}`, { method: 'DELETE' }); 586 if (!res.ok) throw new Error(`HTTP ${res.status}`); 587 uiModule.showToast('Reverted to default'); 588 builtinSkills = []; 589 await loadSkills(); 590 } catch (e) { uiModule.showError('Revert failed: ' + e.message); } 591} 592 593function _getFilteredSkills() { 594 const query = (document.getElementById('skills-search')?.value || '').toLowerCase(); 595 let filtered = query ? skills.filter(sk => _matches(sk, query)) : skills; 596 if (_showDraftsOnly) { 597 filtered = filtered.filter(sk => (sk.status || 'draft') !== 'published'); 598 } 599 if (_showPublishedOnly) { 600 filtered = filtered.filter(sk => (sk.status || 'draft') === 'published'); 601 } 602 if (_confMax != null) { 603 // "⤠X%" â surface the lower-confidence skills that may need review. 604 filtered = filtered.filter(sk => Math.round((sk.confidence || 0) * 100) <= _confMax); 605 } 606 return _sortSkills(filtered); 607} 608
609function renderSkillsList() { 610 const container = document.getElementById('skills-list'); 611 if (!container) return; 612 // Re-render rebuilds the cards (none expanded), so clear the expand flag 613 // on the admin-card or it would keep the toolbar hidden with nothing open. 614 container.closest('.admin-card')?.classList.remove('skills-has-expanded'); 615 616 const sorted = _getFilteredSkills(); 617 // Built-in capabilities show as their own read-only section (skipped when 618 // the user is filtering to drafts, since built-ins aren't drafts). 619 // Skills menu shows the user's own skills only (built-in capabilities 620 // are intentionally not surfaced here). 621 const showBuiltin = false; 622 623 if (!sorted.length && !showBuiltin) { 624 container.innerHTML = `<div style="text-align:center;opacity:0.4;padding:24px 0;font-size:11px;">${loaded ? 'No skills yet, use agent for it to auto extract them.' : 'Loadingâ¦'}</div>`; 625 return; 626 } 627 628 // Library-style cards: a compact bar that expands in-place to show the 629 // SKILL.md, with a footer (Delete left; Edit / Run / Approve right). 630 // Reuses the proven .doclib-card / .doclib-card-preview / 631 // .doclib-card-expanded-actions markup so the desktop+mobile expand + 632 // footer behaviour matches the document/chat library exactly. 633 // 634 // #skills-list itself becomes the .doclib-grid (rather than a nested 635 // grid) so the global "hide non-grid children when a card is expanded" 636 // rule (.admin-card:has(.doclib-card-expanded) > *:not(.doclib-grid)) 637 // doesn't hide the list container along with everything else. 638 container.classList.add('doclib-grid'); 639 const cards = []; 640 const dupeMeta = _duplicateMeta(sorted); 641 642 for (const sk of sorted) { 643 const name = sk.name || sk.id; 644 const dm = dupeMeta.get(name); 645 if (dm) { 646 sk._duplicateGroup = dm.group; 647 sk._duplicateKeep = dm.keep; 648 sk._duplicateKeepName = dm.keepName; 649 sk._duplicateNames = dm.names; 650 } else { 651 delete sk._duplicateGroup; 652 delete sk._duplicateKeep; 653 delete sk._duplicateKeepName; 654 delete sk._duplicateNames; 655 } 656 const conf = Math.round((sk.confidence || 0) * 100); 657 const uses = sk.uses || 0; 658 const isPublished = (sk.status === 'published'); 659 const confColor = _confColor(conf); 660 661 const card = document.createElement('div'); 662 card.className = 'doclib-card skill-card'; 663 card.dataset.skillName = name; 664 card.dataset.skillStatus = sk.status || 'draft'; 665 666 const checked = _selectedNames.has(name) ? 'checked' : ''; 667 const cbHtml = _selectMode 668 ? `<input type="checkbox" class="memory-select-cb skill-select-cb" data-name="${esc(name)}" ${checked} style="margin-right:6px;flex-shrink:0;cursor:pointer;" />` 669 : ''; 670 671 // Collapsed header bar: dot · name (wraps) · [pills (right) · stats · menu]. 672 const header = document.createElement('div'); 673 header.className = 'doclib-card-header skill-card-header'; 674 header.innerHTML = ` 675 ${cbHtml} 676 ${_auditDot(sk)} 677 <div class="skill-card-textcol"> 678 <code class="skill-card-name">${esc(name)}</code> 679 ${sk.description ? `<div class="skill-card-desc">${esc(sk.description)}</div>` : ''} 680 </div> 681 <div class="skill-card-right"> 682 ${_statusPill(sk)} 683 ${_sourcePill(sk)} 684 ${_auditModelPills(sk)} 685 ${_necessityPill(sk)} 686 ${_duplicatePriorityPill(sk)} 687 <span class="skill-stats">${_auditMarks(sk)}<span class="skill-conf" style="color:${confColor};">${conf}%</span> · ${uses}u</span> 688 <span class="skill-chevron-up" title="Collapse"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></span> 689 <button class="skill-kebab-btn" title="Actions" aria-label="Actions"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="19" r="1.6"/></svg></button> 690 </div> 691 `; 692 card.appendChild(header); 693 694 // Kebab dropdown (collapsed-bar quick actions: same set + icons as the 695 // expanded footer). Clicking the kebab opens it; it doesn't expand. 696 header.querySelector('.skill-kebab-btn').addEventListener('click', (e) => { 697 e.stopPropagation(); 698 _openSkillMenu(e.currentTarget, card, sk, name, isPublished); 699 }); 700 701 // Preview (hidden until expanded) â SKILL.md goes here + footer. 702 const preview = document.createElement('div'); 703 preview.className = 'doclib-card-preview skill-card-preview'; 704 const pre = document.createElement('pre'); 705 pre.className = 'skill-md-pre'; 706 pre.textContent = ''; // filled on expand 707 preview.appendChild(pre); 708 709 // Footer: Approve/Unpublish on the left, destructive delete on the right. 710 const actions = document.createElement('div'); 711 actions.className = 'doclib-card-expanded-actions'; 712 713 const delBtn = document.createElement('button'); 714 delBtn.className = 'doclib-card-text-btn doclib-card-action-btn doclib-card-text-btn-danger';
715 delBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>Delete'; 716 delBtn.addEventListener('click', (e) => { e.stopPropagation(); _deleteSkill(name, card); }); 717 718 const editBtn = document.createElement('button'); 719 editBtn.className = 'doclib-card-text-btn doclib-card-action-btn'; 720 editBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Edit'; 721 editBtn.addEventListener('click', (e) => { e.stopPropagation(); _toggleSkillEdit(card, name); }); 722 723 const pubBtn = document.createElement('button'); 724 pubBtn.className = 'doclib-card-text-btn doclib-card-action-btn'; 725 if (isPublished) {
726 pubBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12l5 5L20 7"/></svg>Unpublish'; 727 pubBtn.title = 'Move back to draft'; 728 pubBtn.addEventListener('click', (e) => { e.stopPropagation(); _setSkillStatus(name, 'draft'); }); 729 } else { 730 pubBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>Publish'; 731 pubBtn.title = 'Publish â appears in the skills index'; 732 pubBtn.style.color = 'var(--color-success, #4caf50)'; 733 pubBtn.addEventListener('click', (e) => { e.stopPropagation(); _setSkillStatus(name, 'published'); }); 734 } 735 736 // Test/audit this one skill â same action that's in the kebab, surfaced in 737 // the footer too so it's not buried under the "â¯" menu. 738 const testBtn = document.createElement('button'); 739 testBtn.className = 'doclib-card-text-btn doclib-card-action-btn'; 740 testBtn.innerHTML = _svg(_ICON.test, { size: 11 }) + 'Test'; 741 testBtn.title = 'Test this skill â run it + AI judge'; 742 testBtn.addEventListener('click', (e) => { 743 e.stopPropagation(); 744 // Immediate visual feedback: previously the click looked like nothing 745 // happened because _testSkill awaits a status fetch before overwriting 746 // the preview â so users would tap a second time. Mark the button as 747 // pending right away so the first tap is obviously registered. 748 if (testBtn.dataset.busy === '1') return; // also dedupe rapid double-tap 749 testBtn.dataset.busy = '1'; 750 testBtn.disabled = true; 751 const _origHTML = testBtn.innerHTML; 752 testBtn.innerHTML = _svg(_ICON.test, { size: 11 }) + 'Startingâ¦'; 753 Promise.resolve(_testSkill(card, name)).finally(() => { 754 // The preview gets overwritten by _testSkill, which removes the 755 // testBtn from the DOM. The cleanup below only matters if the 756 // button still exists (e.g. _testSkill bailed early). 757 if (document.body.contains(testBtn)) { 758 testBtn.disabled = false; 759 testBtn.dataset.busy = ''; 760 testBtn.innerHTML = _origHTML; 761 } 762 }); 763 }); 764 765 const rightGroup = document.createElement('div'); 766 rightGroup.className = 'doclib-action-group'; 767 const btnRow = document.createElement('div'); 768 btnRow.className = 'doclib-action-btn-row'; 769 btnRow.appendChild(testBtn); 770 btnRow.appendChild(editBtn); 771 btnRow.appendChild(delBtn); 772 rightGroup.appendChild(btnRow); 773 774 actions.appendChild(pubBtn); 775 actions.appendChild(rightGroup); 776 preview.appendChild(actions); 777 card.appendChild(preview); 778 779 // Click to expand/collapse (unless in select mode â toggle checkbox). 780 card.addEventListener('click', (e) => { 781 if (card._suppressNextClick) { card._suppressNextClick = false; return; } 782 if (e.target.closest('button, input, textarea')) return; 783 if (_selectMode) { 784 const cb = card.querySelector('.skill-select-cb'); 785 if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); } 786 return; 787 } 788 _expandSkillCard(card, name); 789 }); 790 791 // Long-press anywhere on the card opens the kebab dropdown â mirrors the 792 // documents library + brain memory pattern. Skip when touch starts on a 793 // button/input so per-control handlers keep working. 794 { 795 const kebab = header.querySelector('.skill-kebab-btn'); 796 let hold = null; 797 let start = null; 798 const _lpCancel = () => { if (hold) { clearTimeout(hold); hold = null; } start = null; }; 799 card.addEventListener('pointerdown', (e) => { 800 if (e.target.closest('.skill-kebab-btn, .skill-select-cb, button, input, textarea')) return; 801 start = { x: e.clientX, y: e.clientY }; 802 hold = setTimeout(() => { 803 hold = null; 804 card._suppressNextClick = true; 805 setTimeout(() => { card._suppressNextClick = false; }, 400); 806 if (navigator.vibrate) try { navigator.vibrate(15); } catch {} 807 if (kebab) kebab.click(); 808 }, 500); 809 }); 810 card.addEventListener('pointermove', (e) => { 811 if (!start) return; 812 if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 10) _lpCancel(); 813 }); 814 card.addEventListener('pointerup', _lpCancel); 815 card.addEventListener('pointercancel', _lpCancel); 816 } 817 818 cards.push(card); 819 } 820 container.innerHTML = ''; 821 822 // Two collapsible sections â "Your skills" and "Built-in". Headers and 823 // cards are all DIRECT children of the grid (cards tagged with 824 // data-skill-section) so the global expand rule â which hides sibling 825 // .doclib-card elements by direct-child selector â keeps working. 826 // Collapse just toggles display on the tagged cards. 827 const _mkSectionHeader = (sectionId, title, count) => { 828 const collapsed = _collapsedSections.has(sectionId); 829 const hdr = document.createElement('div'); 830 hdr.className = 'skills-section-label skills-section-header' + (collapsed ? ' collapsed' : ''); 831 hdr.dataset.section = sectionId; 832 hdr.innerHTML = 833 `<svg class="skills-section-chevron" width="10" height="10" viewB
833ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>` + 834 `<span>${esc(title)}</span>` + 835 `<span class="skills-section-count">${count}</span>`; 836 hdr.addEventListener('click', () => { 837 if (_collapsedSections.has(sectionId)) _collapsedSections.delete(sectionId); 838 else _collapsedSections.add(sectionId); 839 _saveCollapsedSections(); 840 _applySectionCollapse(container); 841 }); 842 return hdr; 843 }; 844 845 // "Your skills" section â show the header only when there's also a 846 // built-in section to distinguish from (otherwise it's just the list). 847 if (cards.length) { 848 if (showBuiltin) container.appendChild(_mkSectionHeader('user', 'Your skills', cards.length)); 849 cards.forEach(c => { c.dataset.skillSection = 'user'; container.appendChild(c); }); 850 } 851 852 // Built-in capabilities â read-only cards (the agent's native tools). 853 if (showBuiltin) { 854 const builtinCards = _buildBuiltinCards(); 855 container.appendChild(_mkSectionHeader('builtin', 'Built-in capabilities', builtinCards.length)); 856 builtinCards.forEach(c => { c.dataset.skillSection = 'builtin'; container.appendChild(c); }); 857 } 858 859 _applySectionCollapse(container); 860 861 // Domino-in cascade when the Skills tab is (re)opened â same sleek 862 // staggered entrance the document/chat library uses (.doclib-just-opened 863 // â section-domino-in on each .doclib-card child). Only consumes the flag 864 // set on tab-open, so search/sort/edit re-renders stay instant. 865 if (_cascadeNext && cards.length) { 866 _cascadeNext = false; 867 _playSkillsCascade(container); 868 } 869 870 // Select-mode checkbox wiring (card-body click is handled in the card's 871 // own click listener above). 872 if (_selectMode) { 873 container.querySelectorAll('.skill-select-cb').forEach(cb => { 874 cb.addEventListener('change', () => { 875 const name = cb.dataset.name; 876 if (cb.checked) _selectedNames.add(name); else _selectedNames.delete(name); 877 const all = document.getElementById('skills-select-all'); 878 if (all) { 879 const visible = _getFilteredSkills().map(s => s.name || s.id); 880 all.checked = visible.length > 0 && visible.every(n => _selectedNames.has(n)); 881 } 882 _updateBulkBar(); 883 }); 884 }); 885 } 886 887 // Background-load the visible skills' SKILL.md so expanding any of them is 888 // instant (no first-time async fetch â no jump). Deferred so it never 889 // competes with the render/cascade paint. 890 setTimeout(_preloadVisibleMarkdown, 0); 891} 892 893// ---- Card expand / edit / actions ---- 894 895// Collapse an expanded skill card: drop the class AND clear the inline 896// heights skills.js pinned on the card/preview/<pre> (otherwise a collapsed 897// card keeps its full expanded height) and detach its resize listener. 898function _collapseSkillCardEl(c) { 899 c.classList.remove('doclib-card-expanded', 'skill-expand-instant'); 900 c.style.removeProperty('height'); 901 const pv = c.querySelector('.doclib-card-preview'); 902 const pr = c.querySelector('.skill-md-pre') || c.querySelector('.skill-md-editor'); 903 if (pv) { pv.style.removeProperty('height'); pv.style.removeProperty('flex'); pv.style.removeProperty('max-height'); } 904 if (pr) { pr.style.removeProperty('height'); pr.style.removeProperty('flex'); } 905 if (c._fillH) window.removeEventListener('resize', c._fillH); 906} 907 908async function _expandSkillCard(card, name) { 909 const grid = card.closest('.doclib-grid'); 910 const adminCard = card.closest('.admin-card'); 911 // Toggle collapse if already open. 912 if (card.classList.contains('doclib-card-expanded')) { 913 _collapseSkillCardEl(card); 914 if (adminCard) adminCard.classList.remove('skills-has-expanded'); 915 return; 916 } 917 // Were we already showing another expanded card? If so this is a SWITCH, 918 // not a fresh open â skip the fade-in. The fade reveals the previous card 919 // collapsing behind the new (semi-transparent) one, which read as a jump. 920 const switching = !!(grid && grid.querySelector('.doclib-card-expanded')); 921 // Collapse any other expanded sibling (full cleanup, not just the class). 922 if (grid) grid.querySelectorAll('.doclib-card-expanded').forEach(_collapseSkillCardEl); 923 card.classList.add('doclib-card-expanded'); 924 if (switching) card.classList.add('skill-expand-instant'); 925 // Explicit class on the admin-card so CSS doesn't depend on :has() 926 // (Firefox mobile builds without :has left the expand at ~50%). 927 if (adminCard) adminCard.classList.add('skills-has-expanded'); 928 if (grid) grid.scrollTop = 0; 929 930 // Firefox doesn't treat the absolutely-positioned card's stretched height 931 // (inset:0) or height:100% as DEFINITE, so grid/flex children won't fill. 932 // Pin an explicit px height = the card's already-rendered height. A px
933 // value is unambiguously definite, so the preview + <pre> finally fill. 934 card._fillH = () => { 935 // Reset any prior inline heights so we measure the natural box first 936 // (and so switching desktop<->mobile never leaves stale px values). 937 card.style.removeProperty('height'); 938 const preview = card.querySelector('.doclib-card-preview'); 939 const header = card.querySelector('.skill-card-header'); 940 const pre = card.querySelector('.skill-md-pre') || card.querySelector('.skill-md-editor'); 941 if (preview) { preview.style.removeProperty('height'); preview.style.removeProperty('flex'); preview.style.removeProperty('max-height'); } 942 if (pre) { pre.style.removeProperty('height'); pre.style.removeProperty('flex'); } 943 944 // The px-pinning is ONLY for the mobile layout (position:absolute fill, 945 // where Firefox won't propagate a definite height). On desktop the card 946 // expands via normal flex/flow â pinning measured heights there just 947 // under-sizes it. So bail on desktop and let the CSS handle it. 948 if (!window.matchMedia('(max-width: 768px)').matches) return; 949 950 const cardH = card.getBoundingClientRect().height; 951 if (cardH <= 0) return; 952 card.style.setProperty('height', cardH + 'px', 'important'); 953 if (!preview) return; 954 955 const px = (el, prop) => parseFloat(getComputedStyle(el)[prop]) || 0; 956 const headerH = header ? header.getBoundingClientRect().height : 0; 957 const cardPad = px(card, 'paddingTop') + px(card, 'paddingBottom'); 958 const previewH = Math.max(0, cardH - headerH - cardPad); 959 // Force the preview to an explicit height (flex:none so nothing fights it). 960 // A max-height (~335px, resolved from a % rule) was capping it â clear it. 961 preview.style.setProperty('flex', '0 0 auto', 'important'); 962 preview.style.setProperty('max-height', 'none', 'important'); 963 preview.style.setProperty('height', previewH + 'px', 'important'); 964 965 if (pre) { 966 // Pre = preview height minus its non-pre siblings (footer, warn banner). 967 const prevPad = px(preview, 'paddingTop') + px(preview, 'paddingBottom'); 968 let siblings = 0; 969 for (const child of preview.children) { 970 if (child !== pre) siblings += child.getBoundingClientRect().height; 971 } 972 const preH = Math.max(0, previewH - prevPad - siblings); 973 pre.style.setProperty('height', preH + 'px', 'important'); 974 pre.style.setProperty('flex', '0 0 auto', 'important'); 975 } 976 }; 977 // Size SYNCHRONOUSLY (not in rAF) so the pinned heights are in place before 978 // the browser's first paint of the expanded card. Running it a frame later 979 // let the first frame paint at content-height, then snap â the "explosion" 980 // that showed on the first expand (when the SKILL.md was still loading). 981 card._fillH(); 982 window.addEventListener('resize', card._fillH); 983 984 const pre = card.querySelector('.skill-md-pre'); 985 if (pre && !card._mdLoaded) { 986 // Use the cache when available (the bg preload usually has it already), 987 // so the content is in place synchronously â no async settle/jump. 988 if (_mdCache.has(name)) { 989 const md = _mdCache.get(name); 990 pre.textContent = md || '(empty)'; 991 card._mdLoaded = true; 992 card._md = md || ''; 993 } else { 994 pre.textContent = 'Loadingâ¦'; 995 try { 996 const md = await _fetchSkillMarkdown(name); 997 pre.textContent = md || '(empty)'; 998 card._mdLoaded = true; 999 card._md = md; 1000 } catch (e) { 1001 pre.textContent = 'Failed to load SKILL.md'; 1002 } 1003 } 1004 } 1005} 1006 1007// Swap the read-only <pre> for an editable <textarea> (and back). The 1008// Edit button toggles; a Save button commits via the markdown endpoint. 1009function _toggleSkillEdit(card, name) { 1010 const preview = card.querySelector('.skill-card-preview'); 1011 if (!preview) return; 1012 const existing = preview.querySelector('.skill-md-editor'); 1013 if (existing) { 1014 // Already editing â treat Edit as Save. 1015 _saveSkillEdit(card, name); 1016 return; 1017 } 1018 const pre = preview.querySelector('.skill-md-pre'); 1019 const ta = document.createElement('textarea'); 1020 ta.className = 'skill-md-editor'; 1021 ta.spellcheck = false;
1022 ta.value = (card._md != null ? card._md : (pre ? pre.textContent : '')) || ''; 1023 ta.addEventListener('click', (e) => e.stopPropagation()); 1024 if (pre) pre.style.display = 'none'; 1025 preview.insertBefore(ta, preview.querySelector('.doclib-card-expanded-actions')); 1026 ta.focus(); 1027 // Flip the Edit button label to "Save". 1028 const editBtn = [...preview.querySelectorAll('.doclib-card-action-btn')].find(b => /Edit|Save/.test(b.textContent)); 1029 if (editBtn) editBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save'; 1030} 1031 1032async function _saveSkillEdit(card, name) { 1033 const preview = card.querySelector('.skill-card-preview'); 1034 const ta = preview?.querySelector('.skill-md-editor'); 1035 if (!ta) return; 1036 try { 1037 const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}/markdown`, { 1038 method: 'POST', 1039 headers: { 'Content-Type': 'application/json' }, 1040 body: JSON.stringify({ markdown: ta.value }), 1041 }); 1042 if (!res.ok) throw new Error(`HTTP ${res.status}`); 1043 // Refresh the cached markdown so the preload/expand show the new text. 1044 _mdCache.set(name, ta.value); 1045 uiModule.showToast('Saved'); 1046 await loadSkills(); // re-render (frontmatter changes like name/status may have changed) 1047 } catch (e) { 1048 uiModule.showError('Save failed: ' + e.message); 1049 } 1050} 1051 1052async function _deleteSkill(name, card = null) { 1053 if (!(await uiModule.styledConfirm(`Delete skill "${name}"? This removes the SKILL.md.`, { confirmText: 'Delete', danger: true }))) return; 1054 // Locate the card if the caller didn't hand one over, so we can collapse it 1055 // away gracefully (same fade+shrink as the document library) instead of 1056 // re-rendering the whole list. 1057 if (!card) { 1058 card = [...document.querySelectorAll('.skill-card')] 1059 .find(c => { const n = c.querySelector('.skill-card-name'); return n && n.textContent === name; }) || null; 1060 } 1061 try { 1062 await fetch(`${API}/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' }); 1063 _mdCache.delete(name); 1064 if (card) { 1065 if (card._testPoll) { clearInterval(card._testPoll); card._testPoll = null; } 1066 _setCardRunning(card, false); 1067 card.classList.add('doclib-card-deleting'); 1068 card.addEventListener('transitionend', () => card.remove(), { once: true }); 1069 setTimeout(() => { if (card.parentElement) card.remove(); }, 400); 1070 } else { 1071 await loadSkills(); 1072 } 1073 uiModule.showToast('Skill deleted'); 1074 } catch (e) { uiModule.showError('Delete failed: ' + e.message); } 1075} 1076 1077async function _setSkillStatus(name, status) { 1078 try { 1079 await fetch(`${API}/api/skills/${encodeURIComponent(name)}`, { 1080 method: 'PUT', 1081 headers: { 'Content-Type': 'application/json' }, 1082 body: JSON.stringify({ status }), 1083 }); 1084 await loadSkills(); 1085 uiModule.showToast(status === 'published' ? 'Skill approved' : 'Skill moved to draft'); 1086 } catch (e) { uiModule.showError('Update failed: ' + e.message); } 1087} 1088 1089// ---- Test a skill (sandbox agent run + AI eval) ---- 1090 1091async function _fetchTestStatus(name) { 1092 try { 1093 const r = await fetch(`${API}/api/skills/${encodeURIComponent(name)}/test-status`); 1094 return r.ok ? await r.json() : { status: 'none' }; 1095 } catch { return { status: 'none' }; } 1096} 1097 1098function _renderTestLog(logEl, verdictEl, job, card, name) { 1099 if (!logEl) return; 1100 logEl.innerHTML = ''; 1101 const add = (txt, cls) => { const d = document.createElement('div'); if (cls) d.className = cls; d.textContent = txt; logEl.appendChild(d); }; 1102 for (const ev of (job.log || [])) { 1103 if (ev.type === 'skill_test_start') { add('Task: ' + ev.task, 'skill-test-task'); add('Model: ' + ev.model, 'skill-test-meta'); } 1104 else if (ev.type === 'agent_step') add('â round ' + ev.round + ' â', 'skill-test-round'); 1105 else if (ev.type === 'tool_start') add('⸠' + ev.tool + ' ' + String(ev.command || '').slice(0, 200), 'skill-test-tool'); 1106 else if (ev.type === 'tool_output') add(String(ev.output || '').slice(0, 500), 'skill-test-out'); 1107 else if (ev.type === 'say') add(ev.text || '', 'skill-test-say'); 1108 else if (ev.type === 'evaluating') add('Evaluating runâ¦', 'skill-test-meta'); 1109 else if (ev.type === 'error') add('Error: ' + (ev.error || 'run failed'), 'skill-test-err'); 1110 } 1111 if (job.status === 'running') add('â¦running (you can close this â it keeps going)', 'skill-test-meta'); 1112 logEl.scrollTop = logEl.scrollHeight; 1113 if (job.status === 'done' && job.verdict) _renderTestVerdict(verdictEl, job.verdict, card, name); 1114 else if (verdictEl) verdictEl.innerHTML = ''; 1115} 1116 1117// `force` = start a fresh run even if a finished result already exists (Retry). 1118async function _testSkill(card, name, force = false) { 1119 if (!card.classList.contains('doclib-card-expanded')) await _expandSkillCard(card, name); 1120 const preview = card.querySelector('.skill-card-preview'); 1121 if (!preview) return; 1122 preview.innerHTML = 1123 '<div class="skill-test"><div class="skill-test-log"></div>' + 1124 '<div class="skill-test-verdict"></div></div>'; 1125 const logEl = preview.querySelector('.skill-test-log'); 1126 const verdictEl = preview.querySelector('.skill-test-verdict'); 1127 if (card._testPoll) { clearInterval(card._testPoll); card._testPoll = null; } 1128 1129 // Attach to an existing job unless forcing a fresh run. 1130 let job = force ? { status: 'none' } : await _fetchTestStatus(name); 1131 1132 if (job.status === 'none') { 1133 logEl.innerHTML = '<div class="skill-test-meta">Starting testâ¦</div>'; 1134 let model = '', endpoint_url = ''; 1135 try { 1136 const sm = window.sessionModule; 1137 model = (sm && sm.getCurrentModel && sm.getCurrentModel()) || ''; 1138 endpoint_url = (sm && sm.getCurrentEndpointUrl && sm.getCurrentEndpointUrl()) || ''; 1139 } catch (_) {} 1140 try { 1141 const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}/test`, { 1142 method: 'POST', headers: { 'Content-Type': 'application/json' }, 1143 body: JSON.stringify({ model, endpoint_url }), 1144 }); 1145 if (!res.ok) { logEl.innerHTML = '<div class="skill-test-err">Test failed: HTTP ' + res.status + '</div>'; return; } 1146 } catch (e) { logEl.innerHTML = '<div class="skill-test-err">Test failed: ' + (e.message || e) + '</div>'; return; } 1147 job = await _fetchTestStatus(name); 1148 } 1149 1150 _renderTestLog(logEl, verdictEl, job, card, name); 1151 _setCardRunning(card, job.status === 'running'); 1152 1153 if (job.status === 'running') { 1154 card._testPoll = setInterval(async () => { 1155 // Keep polling even if the card is collapsed (the test runs server-side); 1156 // only stop once the card itself is gone from the DOM. 1157 if (!document.body.contains(card)) { clearInterval(card._testPoll); card._testPoll = null; _setCardRunning(card, false); return; } 1158 const s = await _fetchTestStatus(name); 1159 // Update the expanded log only while it's still on screen. 1160 if (document.body.contains(logEl)) _renderTestLog(logEl, verdictEl, s, card, name); 1161 if (s.status !== 'running') { 1162 clearInterval(card._testPoll); card._testPoll = null; 1163 _setCardRunning(card, false); 1164 // If the log isn't visible (card was collapsed), still update the 1165 // header dot/% so the result shows on the folded card. 1166 if (!document.body.contains(logEl) && s.verdict && s.verdict.verdict) { 1167 _applyVerdictToHeader(card, s.verdict.verdict); 1168 } 1169 } 1170 }, 1300); 1171 } 1172} 1173 1174// Show/hide the app-wide whirlpool spinner next to the skill name while a test 1175// is in flight. Works on the collapsed header too, since we inject a real DOM 1176// element rather than a CSS pseudo on a class. 1177function _setCardRunning(card, on) { 1178 if (!card) return; 1179 card.classList.toggle('skill-test-running', !!on); 1180 if (on) { 1181 if (card._testSpinner) return; 1182 const nameEl = card.querySelector('.skill-card-name'); 1183 if (!nameEl) return; 1184 const wp = spinnerModule.createWhirlpool(12); 1185 wp.element.style.cssText = 'display:inline-flex;width:12px;height:12px;margin:0 0 0 7px;vertical-align:middle;flex-shrink:0;'; 1186 // Append INSIDE the <code> name (inline-flow), not after it. The textcol 1187 // is a flex column, so a sibling-after lands on its own line â putting 1188 // the spinner inside the inline code keeps it on the title row. 1189 nameEl.appendChild(wp.element); 1190 card._testSpinner = wp; 1191 } else if (card._testSpinner) { 1192 try { card._testSpinner.destroy(); } catch (_) {} 1193 if (card._testSpinner.element && card._testSpinner.element.parentElement) { 1194 card._testSpinner.element.remove(); 1195 } 1196 card._testSpinner = null; 1197 } 1198} 1199 1200// Reflect a test/audit verdict on the (possibly collapsed) card header without 1201// a full reload: the glowing audit dot, the confidence %, and the pass check. 1202// Works whether the card is expanded or folded so a test that finishes after 1203// you collapse still updates the card. 1204function _applyVerdictToHeader(card, verdict) { 1205 if (!card || !verdict) return; 1206 const dotColor = { 1207 pass: 'var(--color-success, #4ade80)', 1208 needs_work: 'var(--color-warning, #f0ad4e)', 1209 inconclusive: 'var(--color-warning, #f0ad4e)', 1210 fail: 'var(--color-danger, #e06c75)', 1211 }[verdict]; 1212 // Audit dot removed at user request â strip any pre-existing one so the 1213 // post-audit live update doesn't leave a stale dot from an old render. 1214 const header = card.querySelector('.skill-card-header'); 1215 if (header) { 1216 header.querySelectorAll('.skill-audit-dot').forEach(n => n.remove()); 1217 } 1218 const newConf = { pass: 95, needs_work: 60, fail: 40 }[verdict]; 1219 const statsEl = card.querySelector('.skill-stats'); 1220 if (statsEl && newConf != null) { 1221 const confEl = statsEl.querySelector('.skill-conf'); 1222 if (confEl) { confEl.textContent = newConf + '%'; confEl.style.color = _confColor(newConf); } 1223 } 1224 // Fold the verdict into the status (draft / published) pill â colour the
1225 // pill itself and append a tiny check/warn/cross glyph so the audit result 1226 // lives next to the label instead of dangling in the stats row. 1227 const pill = card.querySelector('.skill-status-pill'); 1228 if (pill) { 1229 // Inline glyphs for the per-verdict pill â appear next to the "checked" 1230 // label so the verdict reads as a real badge. 1231 const ICON = { 1232 pass: '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="20 6 9 17 4 12"/></svg>', 1233 needs_work: '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><line x1="12" y1="8" x2="12" y2="13"/><line x1="12" y1="17" x2="12" y2="17"/></svg>', 1234 inconclusive: '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><line x1="12" y1="8" x2="12" y2="13"/><line x1="12" y1="17" x2="12" y2="17"/></svg>', 1235 fail: '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>', 1236 }[verdict]; 1237 // Wash the pill's bg + tint the text so a glance at the badge tells you 1238 // pass/needs-work/fail without expanding the card. 1239 const tint = { 1240 pass: { bg: 'color-mix(in srgb, var(--color-success, #4ade80) 30%, transparent)', fg: 'var(--color-success, #4ade80)' }, 1241 needs_work: { bg: 'color-mix(in srgb, var(--color-warning, #f0ad4e) 30%, transparent)', fg: 'var(--color-warning, #f0ad4e)' }, 1242 inconclusive: { bg: 'color-mix(in srgb, var(--color-warning, #f0ad4e) 30%, transparent)', fg: 'var(--color-warning, #f0ad4e)' }, 1243 fail: { bg: 'color-mix(in srgb, var(--color-danger, #e06c75) 30%, transparent)', fg: 'var(--color-danger, #e06c75)' }, 1244 }[verdict]; 1245 // The status pill (draft / published) keeps its own colours now â the 1246 // verdict lives in a separate "checked" pill that's inserted next to it. 1247 // Remove any prior audit glyph (was previously inserted inside the pill; 1248 // now scrub both the in-pill and sibling locations on every refresh). 1249 pill.querySelectorAll('.skill-pill-verdict').forEach(n => n.remove()); 1250 if (pill.parentElement) { 1251 pill.parentElement.querySelectorAll(':scope > .skill-pill-verdict').forEach(n => n.remove()); 1252 } 1253 if (ICON) { 1254 // Full "checked" pill badge â sits LEFT of the draft/published pill, 1255 // styled like the other memory-cat-badges so it reads as a real chip. 1256 const span = document.createElement('span'); 1257 span.className = 'memory-cat-badge skill-pill-verdict'; 1258 span.title = 'Audited: ' + verdict.replace(/_/g, ' '); 1259 span.innerHTML = ICON + '<span>checked</span>'; 1260 if (tint) { 1261 span.style.background = tint.bg; 1262 span.style.color = tint.fg; 1263 } 1264 if (pill.parentElement) { 1265 pill.parentElement.insertBefore(span, pill); 1266 } else { 1267 pill.insertAdjacentElement('beforebegin', span); 1268 } 1269 } 1270 } 1271 // Old free-floating .skill-verified check (next to confidence %) is no 1272 // longer added â the pill carries the verdict glyph now. Remove a stale 1273 // one in case the card was rendered by an earlier build. 1274 card.querySelectorAll('.skill-verified').forEach(n => n.remove()); 1275} 1276 1277function _renderTestVerdict(el, v, card, name) { 1278 if (!el) return; 1279 const verdict = (v && v.verdict) || 'unknown'; 1280 const cls = { pass: 'ok', needs_work: 'warn', fail: 'bad', inconclusive: 'unknown' }[verdict] || 'unknown'; 1281 const label = { pass: 'PASS', needs_work: 'NEEDS WORK', fail: 'FAIL', inconclusive: 'INCONCLUSIVE', unknown: 'UNCLEAR' }[verdict] || 'UNCLEAR'; 1282 const conf = v && typeof v.confidence === 'number' ? Math.round(v.confidence * 100) + '%' : '';
1283 const issues = Array.isArray(v && v.issues) ? v.issues : []; 1284 // Reflect the skill's current state: if it's already published, the button 1285 // confirms "Approved" (click to unpublish) rather than offering to approve. 1286 const isPub = card && card.dataset && card.dataset.skillStatus === 'published'; 1287 const approveLabel = isPub ? 'Approved' : 'Approve'; 1288 const approveCls = 'skill-eval-approve' + (isPub ? ' is-approved' : (verdict === 'pass' ? ' suggested' : '')); 1289 const approveTitle = isPub ? 'Already approved â click to unpublish' : 'Publish â appears in the skills index'; 1290 el.innerHTML = 1291 '<div class="skill-eval-head"><span class="skill-eval-badge skill-eval-' + cls + '">' + label + (conf ? ' · ' + conf : '') + '</span>' + 1292 '<span class="skill-eval-summary">' + esc((v && v.summary) || '') + '</span></div>' + 1293 (issues.length ? '<ul class="skill-eval-issues">' + issues.map(i => '<li>' + esc(i) + '</li>').join('') + '</ul>' : '') + 1294 '<div class="doclib-card-expanded-actions skill-eval-actions-wrap">' + 1295 '<button class="doclib-card-text-btn doclib-card-action-btn ' + approveCls + '" data-act="approve" title="' + approveTitle + '">' + approveLabel + '</button>' + 1296 '<div class="doclib-action-group"><div class="doclib-action-btn-row">' + 1297 '<button class="doclib-card-text-btn doclib-card-action-btn" data-act="retry" title="Run the test again">Retry</button>' + 1298 '<button class="doclib-card-text-btn doclib-card-action-btn" data-act="copy" title="Copy the run output + verdict">Copy</button>' + 1299 '<button class="doclib-card-text-btn doclib-card-action-btn" data-act="edit">Edit</button>' + 1300 '<button class="doclib-card-text-btn doclib-card-action-btn doclib-card-text-btn-danger" data-act="del">
1300<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>' + 1301 '</div></div>' + 1302 '</div>'; 1303 _applyVerdictToHeader(card, verdict); 1304 el.querySelector('[data-act="approve"]')?.addEventListener('click', async (e) => { 1305 e.stopPropagation(); 1306 const nowPub = card.dataset.skillStatus === 'published'; 1307 await _setSkillStatus(name, nowPub ? 'draft' : 'published'); 1308 // _setSkillStatus reloads the list, but if this card survives, relabel it. 1309 card.dataset.skillStatus = nowPub ? 'draft' : 'published'; 1310 const btn = el.querySelector('[data-act="approve"]'); 1311 if (btn) { 1312 const pub = card.dataset.skillStatus === 'published'; 1313 btn.textContent = pub ? 'Approved' : 'Approve'; 1314 btn.title = pub ? 'Already approved â click to unpublish' : 'Publish â appears in the skills index'; 1315 btn.classList.toggle('is-approved', pub); 1316 btn.classList.toggle('suggested', !pub && verdict === 'pass'); 1317 } 1318 }); 1319 el.querySelector('[data-act="del"]')?.addEventListener('click', (e) => { e.stopPropagation(); _deleteSkill(name, card); }); 1320 el.querySelector('[data-act="edit"]')?.addEventListener('click', (e) => { e.stopPropagation(); _toggleSkillEdit(card, name); }); 1321 el.querySelector('[data-act="retry"]')?.addEventListener('click', (e) => { e.stopPropagation(); _testSkill(card, name, true); }); 1322 el.querySelector('[data-act="copy"]')?.addEventListener('click', (e) => { 1323 e.stopPropagation(); 1324 const logEl = card.querySelector('.skill-test-log'); 1325 const issuesTxt = issues.length ? '\nIssues:\n- ' + issues.join('\n- ') : ''; 1326 const text = (logEl ? logEl.innerText.trim() + '\n\n' : '') + 1327 '=== Eval: ' + label + (conf ? ' (' + conf + ')' : '') + ' ===\n' + ((v && v.summary) || '') + issuesTxt; 1328 // Shared helper falls back to execCommand on plain HTTP (navigator.clipboard 1329 // is unavailable in non-secure contexts, which is why the raw call failed). 1330 uiModule.copyToClipboard(text); 1331 }); 1332} 1333 1334// ---- Audit all skills (autonomous: test â fix â retry â teacher â flag) ---- 1335 1336let _auditPoll = null; 1337let _auditSeenResults = 0; 1338 1339function _confirmAuditSkills(label) { 1340 return new Promise(resolve => { 1341 let overlay = document.getElementById('skills-audit-confirm-overlay'); 1342 if (!overlay) { 1343 overlay = document.createElement('div'); 1344 overlay.id = 'skills-audit-confirm-overlay'; 1345 overlay.className = 'modal'; 1346 overlay.innerHTML = 1347 '<div class="modal-content styled-confirm-box">' + 1348 '<div class="modal-header"><h4>Audit Skills</h4></div>' + 1349 '<div class="modal-body">' + 1350 '<p id="skills-audit-confirm-msg"></p>' + 1351 '<label class="memory-bulk-check-all" style="margin-top:10px;display:inline-flex;align-items:center;gap:7px;">' + 1352 '<input type="checkbox" id="skills-audit-skip-audited" checked />' + 1353 '<span>Skip already audited</span>' + 1354 '</label>' + 1355 '</div>' + 1356 '<div class="modal-footer">' + 1357 '<button id="skills-audit-confirm-cancel" class="confirm-btn confirm-btn-secondary">Cancel</button>' + 1358 '<button id="skills-audit-confirm-ok" class="confirm-btn confirm-btn-primary">Audit</button>' + 1359 '</div>' + 1360 '</div>'; 1361 document.body.appendChild(overlay); 1362 } 1363 1364 const msg = overlay.querySelector('#skills-audit-confirm-msg'); 1365 const skip = overlay.querySelector('#skills-audit-skip-audited'); 1366 const okBtn = overlay.querySelector('#skills-audit-confirm-ok'); 1367 const cancelBtn = overlay.querySelector('#skills-audit-confirm-cancel'); 1368 msg.textContent = `Audit ${label}
1368? Each is tested from top to bottom, then published or moved to draft using your auto-approve confidence threshold.`; 1369 skip.checked = true; 1370 overlay.classList.remove('hidden'); 1371 overlay.style.display = ''; 1372 1373 function cleanup(result) { 1374 overlay.classList.add('hidden'); 1375 overlay.style.display = 'none'; 1376 okBtn.removeEventListener('click', onOk); 1377 cancelBtn.removeEventListener('click', onCancel); 1378 overlay.removeEventListener('click', onBackdrop); 1379 document.removeEventListener('keydown', onKey); 1380 resolve(result); 1381 } 1382 function onOk() { cleanup({ ok: true, skipAudited: !!skip.checked }); } 1383 function onCancel() { cleanup({ ok: false, skipAudited: false }); } 1384 function onBackdrop(e) { if (e.target === overlay) onCancel(); } 1385 function onKey(e) { 1386 if (e.key === 'Escape') { 1387 e.preventDefault(); 1388 e.stopPropagation(); 1389 cleanup({ ok: false, skipAudited: false }); 1390 } 1391 } 1392 1393 okBtn.addEventListener('click', onOk); 1394 cancelBtn.addEventListener('click', onCancel); 1395 overlay.addEventListener('click', onBackdrop); 1396 document.addEventListener('keydown', onKey); 1397 okBtn.focus(); 1398 }); 1399} 1400 1401async function _auditAllSkills(opts = {}) { 1402 const panel = document.getElementById('skills-audit-panel'); 1403 if (!panel) return; 1404 // If a run is already going, just (re)attach to it. 1405 let st = await _fetchAuditStatus(); 1406 if (st.status !== 'running') { 1407 const explicitNames = Array.isArray(opts.names) ? opts.names.filter(Boolean) : null; 1408 const visibleNames = _getFilteredSkills() 1409 .map(sk => sk.name || sk.id) 1410 .filter(Boolean); 1411 const names = explicitNames || visibleNames; 1412 const label = explicitNames 1413 ? `${names.length} selected ${names.length === 1 ? 'skill' : 'skills'}` 1414 : `${names.length} visible ${names.length === 1 ? 'skill' : 'skills'}`; 1415 if (!names.length) { 1416 uiModule.showToast(explicitNames ? 'No selected skills to audit' : 'No visible skills to audit'); 1417 return; 1418 } 1419 const confirmed = await _confirmAuditSkills(label); 1420 if (!confirmed.ok) return; 1421 try { 1422 const r = await fetch(`${API}/api/skills/audit-all`, { 1423 method: 'POST', headers: { 'Content-Type': 'application/json' }, 1424 body: JSON.stringify({ scope: explicitNames ? 'selected' : 'all', names, skip_audited: confirmed.skipAudited }), 1425 }); 1426 if (!r.ok) { uiModule.showError('Audit failed to start (HTTP ' + r.status + ')'); return; } 1427 st = await _fetchAuditStatus(); 1428 } catch (e) { uiModule.showError('Audit failed: ' + (e.message || e)); return; } 1429 _auditSeenResults = 0; 1430 } 1431 panel.classList.remove('hidden'); 1432 _auditSeenResults = Math.min(_auditSeenResults, (st.results || []).length); 1433 _renderAuditPanel(panel, st); 1434 _applyAuditResults(st); 1435 _highlightAuditCard(st.status === 'running' ? st.current : null); 1436 if (_auditPoll) clearInterval(_auditPoll); 1437 if (st.status === 'running') { 1438 _auditPoll = setInterval(async () => { 1439 const s = await _fetchAuditStatus(); 1440 _renderAuditPanel(panel, s); 1441 _applyAuditResults(s); 1442 _highlightAuditCard(s.status === 'running' ? s.current : null); 1443 if (s.status !== 'running') { 1444 clearInterval(_auditPoll); _auditPoll = null; 1445 _highlightAuditCard(null); 1446 loadSkills(); // refresh statuses (some may have been demoted/edited) 1447 } 1448 }, 1500); 1449 } 1450} 1451 1452function _findSkillCard(name) { 1453 if (!name) return null; 1454 return [...document.querySelectorAll('.skill-card[data-skill-name]')] 1455 .find(c => c.dataset.skillName === name) || null; 1456} 1457 1458function _mergeSkillState(state) { 1459 if (!state || !state.name) return; 1460 const idx = skills.findIndex(s => (s.name || s.id) === state.name); 1461 if (idx >= 0) skills[idx] = { ...skills[idx], ...state }; 1462} 1463 1464function _applySkillStateToHeader(card, state, fallbackVerdict) { 1465 if (!card) return; 1466 const verdict = state?.audit_verdict || fallbackVerdict; 1467 if (verdict) _applyVerdictToHeader(card, verdict); 1468 if (state && typeof state.confidence === 'number') { 1469 const conf = Math.round(state.confidence * 100); 1470 const confEl = card.querySelector('.skill-conf'); 1471 if (confEl) { confEl.textContent = conf + '%'; confEl.style.color = _confColor(conf); } 1472 } 1473 if (state?.status) { 1474 card.dataset.skillStatus = state.status; 1475 const oldPill = card.querySelector('.skill-status-pill'); 1476 if (oldPill) { 1477 const wrap = document.createElement('span'); 1478 wrap.innerHTML = _statusPill(state); 1479 const next = wrap.firstElementChild; 1480 if (next) oldPill.replaceWith(next); 1481 } 1482 } 1483 const right = card.querySelector('.skill-card-right'); 1484 if (right && state) { 1485 right.querySelectorAll('.skill-model-pill, .skill-necessity-pill').forEach(n => n.remove()); 1486 const stats = right.querySelector('.skill-stats'); 1487 const wrap = document.createElement('span'); 1488 wrap.innerHTML = _auditModelPills(state) + _necessityPill(state);
1489 [...wrap.children].forEach(p => { 1490 if (stats) right.insertBefore(p, stats); 1491 else right.appendChild(p); 1492 }); 1493 } 1494} 1495 1496function _applyAuditResults(st) { 1497 const results = st && Array.isArray(st.results) ? st.results : []; 1498 if (!results.length) return; 1499 for (const r of results.slice(_auditSeenResults)) { 1500 const name = r && r.skill; 1501 if (!name) continue; 1502 const state = r.skill_state || null; 1503 _mergeSkillState(state); 1504 const verdict = state?.audit_verdict || r.verdict?.verdict || (r.result === 'flagged' ? 'fail' : null); 1505 _applySkillStateToHeader(_findSkillCard(name), state, verdict); 1506 } 1507 _auditSeenResults = results.length; 1508} 1509 1510// Make the card currently being audited glow, so it's obvious which one the 1511// "Audit now" run is processing. Pass null to clear all highlights. 1512function _highlightAuditCard(name) { 1513 document.querySelectorAll('.skill-card.skill-audit-active') 1514 .forEach(c => { c.classList.remove('skill-audit-active'); _setCardRunning(c, false); }); 1515 if (!name) return; 1516 const card = _findSkillCard(name); 1517 if (card) { 1518 card.classList.add('skill-audit-active'); 1519 _setCardRunning(card, true); 1520 card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 1521 } 1522} 1523 1524async function _fetchAuditStatus() { 1525 try { 1526 const r = await fetch(`${API}/api/skills/audit-all/status`); 1527 return r.ok ? await r.json() : { status: 'none' }; 1528 } catch { return { status: 'none' }; } 1529} 1530 1531function _renderAuditPanel(panel, st) { 1532 if (st.status === 'none') { panel.classList.add('hidden'); panel.innerHTML = ''; return; } 1533 const done = st.done || 0, total = st.total || 0; 1534 const pct = total ? Math.round((done / total) * 100) : 0; 1535 const counts = {}; 1536 for (const r of (st.results || [])) counts[r.result] = (counts[r.result] || 0) + 1; 1537 const summary = Object.entries(counts).map(([k, v]) => v + ' ' + k.replace(/_/g, ' ')).join(' · '); 1538 const running = st.status === 'running'; 1539 const cancelled = st.status === 'cancelled'; 1540 const head = running 1541 ? `Auditing ${done}/${total}${st.current ? ' â ' + esc(st.current) : ''}` 1542 : cancelled 1543 ? `Audit cancelled â ${done}/${total}` 1544 : `Audit complete â ${total} skill${total === 1 ? '' : 's'}`; 1545 panel.innerHTML = 1546 '<div class="skills-audit-head">' + 1547 '<span class="skills-audit-title-wrap" style="display:inline-flex;align-items:center;gap:8px;">' + 1548 '<span class="skills-audit-title">' + head + '</span>' + 1549 '</span>' + 1550 (running 1551 ? '<button class="memory-toolbar-btn" data-act="audit-cancel">Cancel</button>' 1552 : '<button class="memory-toolbar-btn" data-act="audit-close">Close</button>') + 1553 '</div>' + 1554 '<div class="skills-audit-bar"><div class="skills-audit-fill" style="width:' + pct + '%"></div></div>' + 1555 (summary ? '<div class="skills-audit-summary">' + esc(summary) + (st.teacher ? ' · teacher: ' + esc(st.teacher) : '') + '</div>' : '') + 1556 '<div class="skills-audit-log">' + (st.log || []).slice(-40).map(l => '<div>' + esc(l) + '</div>').join('') + '</div>'; 1557 // Whirlpool sits next to the title while the audit is actually running. 1558 if (running) { 1559 const titleWrap = panel.querySelector('.skills-audit-title-wrap'); 1560 if (titleWrap) { 1561 const wp = spinnerModule.createWhirlpool(12); 1562 wp.element.style.cssText = 'display:inline-flex;width:12px;height:12px;margin:0;vertical-align:middle;flex-shrink:0;'; 1563 titleWrap.appendChild(wp.element); 1564 } 1565 } 1566 const cancel = panel.querySelector('[data-act="audit-cancel"]'); 1567 if (cancel) cancel.addEventListener('click', async (e) => { 1568 e.stopPropagation(); 1569 cancel.disabled = true; 1570 cancel.textContent = 'Cancelling...'; 1571 try { 1572 await fetch(`${API}/api/skills/audit-all/cancel`, { method: 'POST', credentials: 'same-origin' }); 1573 const s = await _fetchAuditStatus(); 1574 _renderAuditPanel(panel, { ...s, status: s.status === 'none' ? 'cancelled' : s.status }); 1575 _highlightAuditCard(null); 1576 } catch { 1577 cancel.disabled = false; 1578 cancel.textContent = 'Cancel'; 1579 } 1580 }); 1581 const close = panel.querySelector('[data-act="audit-close"]'); 1582 if (close) close.addEventListener('click', () => { panel.classList.add('hidden'); panel.innerHTML = ''; }); 1583 const logEl = panel.querySelector('.skills-audit-log'); 1584 if (logEl) logEl.scrollTop = logEl.scrollHeight; 1585} 1586 1587// ---- Select mode / bulk actions ---- 1588 1589function _enterSelectMode() { 1590 _selectMode = true; 1591 _selectedNames.clear(); 1592 const bar = document.getElementById('skills-bulk-bar'); 1593 const btn = document.getElementById('skills-select-btn'); 1594 if (bar) bar.classList.remove('hidden'); 1595 if (btn) { btn.classList.add('active'); btn.textContent = 'Cancel'; } 1596 _updateBulkBar(); 1597 renderSkillsList(); 1598} 1599 1600function _exitSelectMode() { 1601 _selectMode = false;
1602 _selectedNames.clear(); 1603 const bar = document.getElementById('skills-bulk-bar'); 1604 const btn = document.getElementById('skills-select-btn'); 1605 const all = document.getElementById('skills-select-all'); 1606 if (bar) bar.classList.add('hidden'); 1607 if (btn) { btn.classList.remove('active'); btn.textContent = 'Select'; } 1608 if (all) all.checked = false; 1609 renderSkillsList(); 1610} 1611 1612function _updateBulkBar() { 1613 const countEl = document.getElementById('skills-selected-count'); 1614 const delBtn = document.getElementById('skills-bulk-delete'); 1615 const delNonPassingBtn = document.getElementById('skills-bulk-delete-nonpassing'); 1616 const pubBtn = document.getElementById('skills-bulk-publish'); 1617 const auditBtn = document.getElementById('skills-bulk-audit'); 1618 if (countEl) countEl.textContent = `${_selectedNames.size} Selected`; 1619 if (delBtn) delBtn.disabled = _selectedNames.size === 0; 1620 if (auditBtn) auditBtn.disabled = _selectedNames.size === 0; 1621 if (delNonPassingBtn) { 1622 const count = _selectedNonPassingSkills().length; 1623 delNonPassingBtn.disabled = count === 0; 1624 delNonPassingBtn.title = count 1625 ? `Delete ${count} selected non-passing ${count === 1 ? 'skill' : 'skills'}` 1626 : 'No selected non-passing skills'; 1627 } 1628 // Approve is only meaningful when at least one selected skill is still a draft. 1629 const anyDraft = [..._selectedNames].some(n => { 1630 const sk = skills.find(s => (s.name || s.id) === n); 1631 return sk && (sk.status || 'draft') !== 'published'; 1632 }); 1633 if (pubBtn) pubBtn.disabled = !anyDraft; 1634} 1635 1636function _toggleSelectAll() { 1637 const all = document.getElementById('skills-select-all'); 1638 if (!all) return; 1639 const visible = _getFilteredSkills().map(s => s.name || s.id); 1640 if (all.checked) visible.forEach(n => _selectedNames.add(n)); 1641 else visible.forEach(n => _selectedNames.delete(n)); 1642 _updateBulkBar(); 1643 renderSkillsList(); 1644} 1645 1646async function _bulkDelete() { 1647 if (!_selectedNames.size) return; 1648 const n = _selectedNames.size; 1649 const ok = await uiModule.styledConfirm( 1650 `Delete ${n} ${n === 1 ? 'skill' : 'skills'}? This removes their SKILL.md files.`, 1651 { confirmText: 'Delete', danger: true } 1652 ); 1653 if (!ok) return; 1654 let deleted = 0; 1655 const deletedNames = []; 1656 for (const name of _selectedNames) { 1657 try { 1658 const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' }); 1659 if (res.ok) { 1660 deleted++; 1661 deletedNames.push(name); 1662 } 1663 } catch {} 1664 } 1665 for (const name of deletedNames) { 1666 const card = document.querySelector(`.skill-card[data-skill-name="${CSS.escape(name)}"]`); 1667 if (card) card.classList.add('doclib-card-deleting'); 1668 } 1669 if (deletedNames.length) await new Promise(resolve => setTimeout(resolve, 320)); 1670 _exitSelectMode(); 1671 await loadSkills(); 1672 uiModule.showToast(`Deleted ${deleted}`); 1673} 1674 1675async function _loadSkillApprovalThreshold() { 1676 try { 1677 const res = await fetch(`${API}/api/prefs`, { credentials: 'same-origin' }); 1678 if (!res.ok) return; 1679 const prefs = await res.json(); 1680 const raw = prefs.skill_min_confidence ?? prefs.skill_autosave_min_confidence; 1681 const val = Number(raw); 1682 if (Number.isFinite(val)) _skillApprovalThreshold = Math.max(0, Math.min(1, val)); 1683 } catch {} 1684} 1685 1686function _selectedNonPassingSkills() { 1687 const selected = new Set(_selectedNames); 1688 return skills.filter(sk => { 1689 const name = sk.name || sk.id; 1690 if (!selected.has(name)) return false; 1691 const conf = Number(sk.confidence || 0); 1692 const necessity = _necessityKind(sk); 1693 if (necessity === 'duplicate' || necessity === 'trivial' || necessity === 'irrelevant') return true; 1694 if ((sk.audit_verdict || '') !== 'pass') return true; 1695 return conf < _skillApprovalThreshold; 1696 }); 1697} 1698 1699async function _bulkDeleteNonPassing() { 1700 const targets = _selectedNonPassingSkills(); 1701 if (!targets.length) { 1702 uiModule.showToast('No selected non-passing skills'); 1703 return; 1704 } 1705 const thresholdPct = Math.round(_skillApprovalThreshold * 100); 1706 const names = targets.map(sk => sk.name || sk.id).filter(Boolean); 1707 const ok = await uiModule.styledConfirm( 1708 `Delete ${names.length} selected non-passing ${names.length === 1 ? 'skill' : 'skills'}? This removes duplicates, generic/irrelevant skills, failed audits, and anything below ${thresholdPct}%.`, 1709 { confirmText: 'Delete non passing', danger: true } 1710 ); 1711 if (!ok) return; 1712 let deleted = 0; 1713 const deletedNames = []; 1714 for (const name of names) { 1715 try { 1716 const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' }); 1717 if (res.ok) { 1718 deleted++; 1719 deletedNames.push(name); 1720 _mdCache.delete(name); 1721 } 1722 } catch {} 1723 } 1724 for (const name of deletedNames) { 1725 const card = document.querySelector(`.skill-card[data-skill-name="${CSS.escape(name)}"]`); 1726 if (card) card.classList.add('doclib-card-deleting'); 1727 } 1728 if (deletedNames.length) await new Promise(resolve => setTimeout(resolve, 320)); 1729 _exitSelectMode(); 1730 await loadSkills(); 1731 uiModule.showToast(`Deleted ${deleted} non-passing`); 1732} 1733 1734async function _bulkApprove() { 1735 if (!_selectedNames.size) return; 1736 let published = 0; 1737 for (const name of _selectedNames) { 1738 const sk = skills.find(s => (s.name || s.id) === name); 1739 if (sk && sk.status === 'published') continue; 1740 try { 1741 const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}`, { 1742 method: 'PUT', 1743 headers: { 'Content-Type': 'application/json' }, 1744 body: JSON.stringify({ status: 'published' }), 1745 }); 1746 if (res.ok) published++; 1747 } catch {} 1748 } 1749 _exitSelectMode(); 1750 await loadSkills(); 1751 uiModule.showToast(`Published ${published}`); 1752} 1753 1754async function _bulkAudit() { 1755 if (!_selectedNames.size) return; 1756 const selected = new Set(_selectedNames); 1757 const ordered = _getFilteredSkills() 1758 .map(sk => sk.name || sk.id) 1759 .filter(n => selected.has(n)); 1760 _exitSelectMode(); 1761 await _auditAllSkills({ names: ordered }); 1762} 1763 1764async function _showSkillSource(name) { 1765 let md = ''; 1766 try { 1767 const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}/markdown`); 1768 if (!res.ok) throw new Error(`HTTP ${res.status}`); 1769 const data = await res.json(); 1770 md = data.markdown || ''; 1771 } catch (e) {
1772 uiModule.showError('Failed to load SKILL.md'); 1773 return; 1774 } 1775 1776 // Lightweight modal â reuses the .modal CSS the rest of the app uses. 1777 const wrap = document.createElement('div'); 1778 wrap.className = 'modal'; 1779 wrap.style.display = 'block'; 1780 wrap.innerHTML = ` 1781 <div class="modal-content" style="max-width:760px;max-height:85vh;display:flex;flex-direction:column"> 1782 <div class="modal-header"> 1783 <h4>SKILL.md â <code>${esc(name)}</code></h4> 1784 <span style="flex:1"></span> 1785 <button class="memory-toolbar-btn" id="skill-save-btn">Save</button> 1786 <button class="close-btn" id="skill-md-close">â</button> 1787 </div> 1788 <div class="modal-body" style="display:flex;flex-direction:column;gap:8px"> 1789 <textarea id="skill-md-textarea" spellcheck="false" style="flex:1;min-height:50vh;width:100%;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;padding:10px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--fg);box-sizing:border-box"></textarea> 1790 <p class="memory-desc" style="margin:0">Edit the frontmatter and body directly. Save replaces the file via PUT /api/skills/{name}.</p> 1791 </div> 1792 </div> 1793 `; 1794 document.body.appendChild(wrap); 1795 const ta = wrap.querySelector('#skill-md-textarea'); 1796 ta.value = md; 1797 wrap.querySelector('#skill-md-close').addEventListener('click', () => wrap.remove()); 1798 wrap.addEventListener('click', (e) => { if (e.target === wrap) wrap.remove(); }); 1799 wrap.querySelector('#skill-save-btn').addEventListener('click', async () => { 1800 try { 1801 // We use the manage_skills-style edit by going through PUT with a 1802 // single 'content' field. The route doesn't accept that yet â use the 1803 // tool call instead. We have a /api/skills/{name} PUT for fields, but 1804 // a full SKILL.md replace is simpler via the parsed-then-PUT approach 1805 // below: parse client-side by uploading via the tool route. 1806 const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}/markdown`, { 1807 method: 'POST', 1808 headers: { 'Content-Type': 'application/json' }, 1809 body: JSON.stringify({ markdown: ta.value }), 1810 }); 1811 if (!res.ok) throw new Error(`HTTP ${res.status}`); 1812 uiModule.showToast('Saved'); 1813 wrap.remove(); 1814 await loadSkills(); 1815 } catch (e) { 1816 uiModule.showError('Save failed: ' + e.message); 1817 } 1818 }); 1819} 1820 1821async function importSkillFromUrl() { 1822 const input = document.getElementById('skill-import-url'); 1823 const url = (input?.value || '').trim(); 1824 if (!url) { 1825 uiModule.showError('Paste a GitHub or skills.sh URL first'); 1826 return; 1827 } 1828 const btn = document.getElementById('skill-import-url-btn'); 1829 if (btn) btn.disabled = true; 1830 try { 1831 const res = await fetch(`${API}/api/skills/import-from-url`, { 1832 method: 'POST', 1833 headers: { 'Content-Type': 'application/json' }, 1834 body: JSON.stringify({ url }), 1835 }); 1836 const data = await res.json().catch(() => ({})); 1837 if (!res.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`); 1838 if (input) input.value = ''; 1839 await loadSkills(); 1840 const name = data.skill?.name || 'skill'; 1841 uiModule.showToast(`Imported ${name} (${data.files || 1} file(s))`); 1842 if (name) openSkill(name); 1843 } catch (err) { 1844 uiModule.showError('Import failed: ' + err.message); 1845 } finally { 1846 if (btn) btn.disabled = false; 1847 } 1848} 1849 1850async function addSkill() { 1851 const name = document.getElementById('new-skill-name')?.value.trim() 1852 || document.getElementById('new-skill-title')?.value.trim(); 1853 const description = document.getElementById('new-skill-description')?.value.trim() 1854 || document.getElementById('new-skill-title')?.value.trim(); 1855 const whenToUse = document.getElementById('new-skill-when')?.value.trim() 1856 || document.getElementById('new-skill-problem')?.value.trim() || ''; 1857 const procedureRaw = document.getElementById('new-skill-procedure')?.value.trim() 1858 || document.getElementById('new-skill-solution')?.value.trim() || ''; 1859 const tagsRaw = document.getElementById('new-skill-tags')?.value.trim(); 1860 const category = document.getElementById('new-skill-category')?.value.trim() || 'general'; 1861 1862 if (!description && !name) {
1863 uiModule.showError('Description (or name) is required'); 1864 return; 1865 } 1866 const procedure = procedureRaw 1867 ? procedureRaw.split('\n').map(s => s.replace(/^\s*(?:[-*]|\d+[.)])\s+/, '').trim()).filter(Boolean) 1868 : []; 1869 const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : []; 1870 1871 try { 1872 const res = await fetch(`${API}/api/skills/add`, { 1873 method: 'POST', 1874 headers: { 'Content-Type': 'application/json' }, 1875 body: JSON.stringify({ 1876 name: name || undefined, 1877 description, 1878 category, 1879 when_to_use: whenToUse, 1880 procedure, 1881 tags, 1882 status: 'draft', 1883 }), 1884 }); 1885 if (!res.ok) throw new Error(`HTTP ${res.status}`); 1886 ['new-skill-name', 'new-skill-title', 'new-skill-description', 'new-skill-when', 1887 'new-skill-problem', 'new-skill-procedure', 'new-skill-solution', 'new-skill-tags', 1888 'new-skill-category'] 1889 .forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); 1890 await loadSkills(); 1891 uiModule.showToast('Skill added (draft)'); 1892 } catch (err) { 1893 uiModule.showError('Failed to add skill: ' + err.message); 1894 } 1895} 1896 1897document.addEventListener('DOMContentLoaded', () => { 1898 document.getElementById('skill-import-url-btn')?.addEventListener('click', importSkillFromUrl); 1899 document.getElementById('skill-import-url')?.addEventListener('keydown', (e) => { 1900 if (e.key === 'Enter') importSkillFromUrl(); 1901 }); 1902 document.getElementById('add-skill-btn')?.addEventListener('click', addSkill); 1903 document.getElementById('skills-search')?.addEventListener('input', renderSkillsList); 1904 document.getElementById('skills-sort')?.addEventListener('change', (e) => { 1905 // Dropdown holds two optgroups: Sort (sort:<key>) and Filter (filter:<key>). 1906 // Picking a sort option leaves the filter alone, and vice-versa. 1907 const v = e.target.value || ''; 1908 if (v.startsWith('sort:')) { 1909 _skillsSort = v.slice(5); 1910 } else if (v.startsWith('filter:')) { 1911 const f = v.slice(7); 1912 if (f === 'all') { _showDraftsOnly = false; _showPublishedOnly = false; _confMax = null; } 1913 else if (f === 'drafts') { _showDraftsOnly = true; _showPublishedOnly = false; _confMax = null; } 1914 else if (f === 'published') { _showPublishedOnly = true; _showDraftsOnly = false; _confMax = null; } 1915 else if (f.startsWith('conf')) { _showDraftsOnly = false; _showPublishedOnly = false; _confMax = parseInt(f.slice(4), 10) || null; } 1916 } 1917 renderSkillsList(); 1918 }); 1919 document.getElementById('skills-select-btn')?.addEventListener('click', () => { 1920 if (_selectMode) _exitSelectMode(); else _enterSelectMode(); 1921 }); 1922 document.getElementById('skills-audit-btn')?.addEventListener('click', _auditAllSkills); 1923 document.getElementById('skills-select-all')?.addEventListener('change', _toggleSelectAll); 1924 document.getElementById('skills-bulk-cancel')?.addEventListener('click', _exitSelectMode); 1925 document.getElementById('skills-bulk-audit')?.addEventListener('click', _bulkAudit); 1926 document.getElementById('skills-bulk-delete')?.addEventListener('click', _bulkDelete); 1927 document.getElementById('skills-bulk-delete-nonpassing')?.addEventListener('click', _bulkDeleteNonPassing); 1928 document.getElementById('skills-bulk-publish')?.addEventListener('click', _bulkApprove); 1929 document.getElementById('new-skill-title')?.addEventListener('keydown', (e) => { 1930 if (e.key === 'Enter') addSkill(); 1931 }); 1932 document.getElementById('new-skill-name')?.addEventListener('keydown', (e) => { 1933 if (e.key === 'Enter') addSkill(); 1934 }); 1935}); 1936 1937export default { loadSkills, openSkill }; 1938 1939// Populate the Skills badge on first load so the count is right before the 1940// user clicks into the tab. Cheap fetch â same as the lazy path. 1941document.addEventListener('DOMContentLoaded', () => { loadSkills(); });
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.