// skills.js — Skills tab in the Memory modal. // // Skills are SKILL.md files (frontmatter + body) under data/skills/. // This UI supports: list, search, view (read SKILL.md), edit (replace // content), publish/draft toggle, delete, and "run as slash" via the // / path. import uiModule from './ui.js'; import * as spinnerModule from './spinner.js'; const API = window.location.origin; let skills = []; let builtinSkills = []; // read-only agent tool capabilities (TOOL_SECTIONS) let loaded = false; let _loadPromise = null; function esc(s) { return uiModule.esc(String(s ?? '')); } let _pendingFocusSkill = null; let _cascadeNext = false; // set true to play the domino-in entrance on the next render function _playSkillsCascade(container = document.getElementById('skills-list')) { if (!container || !container.querySelector('.skill-card')) return false; container.classList.remove('doclib-just-opened'); void container.offsetWidth; container.classList.add('doclib-just-opened'); setTimeout(() => container.classList.remove('doclib-just-opened'), 900); return true; } // Cache of SKILL.md text by skill name, so expanding is instant (no async // fetch + content-settle jump). Populated lazily on expand AND eagerly in // the background for all visible cards right after render. const _mdCache = new Map(); async function _fetchSkillMarkdown(name) { if (_mdCache.has(name)) return _mdCache.get(name); const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}/markdown`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const md = data.markdown || ''; _mdCache.set(name, md); return md; } // Background-load the markdown for every currently-rendered skill card so it // is ready (in the card's
 + _mdLoaded) before the user expands it.
function _preloadVisibleMarkdown() {
  document.querySelectorAll('#skills-list .skill-card[data-skill-name]').forEach(card => {
    const name = card.dataset.skillName;
    if (!name || card._mdLoaded) return;
    const pre = card.querySelector('.skill-md-pre');
    const apply = (md) => { if (pre) pre.textContent = md || '(empty)'; card._mdLoaded = true; card._md = md || ''; };
    if (_mdCache.has(name)) { apply(_mdCache.get(name)); return; }
    _fetchSkillMarkdown(name).then(apply).catch(() => {});
  });
}

// Collapsed skills sections ("user" / "builtin"), persisted so the
// choice survives reloads. Built-in defaults to collapsed (it's
// reference info, not the user's own skills).
const _collapsedSections = (() => {
  try {
    const raw = localStorage.getItem('skillsSectionsCollapsed');
    if (raw) return new Set(JSON.parse(raw));
  } catch (_) {}
  return new Set(['builtin']);
})();
function _saveCollapsedSections() {
  try { localStorage.setItem('skillsSectionsCollapsed', JSON.stringify([..._collapsedSections])); } catch (_) {}
}
function _applySectionCollapse(container) {
  if (!container) return;
  container.querySelectorAll('.skills-section-header').forEach(h => {
    h.classList.toggle('collapsed', _collapsedSections.has(h.dataset.section));
  });
  container.querySelectorAll('.doclib-card[data-skill-section]').forEach(c => {
    c.classList.toggle('skill-card-section-hidden', _collapsedSections.has(c.dataset.skillSection));
  });
}

export async function loadSkills(cascade = false) {
  // Play the domino-in entrance on this load (set when the tab is opened,
  // not for the silent re-loads after an edit/delete).
  if (cascade) _cascadeNext = true;
  if (cascade && loaded && !_loadPromise && _playSkillsCascade()) {
    _cascadeNext = false;
    updateCount();
    return;
  }
  if (_loadPromise) return _loadPromise;
  _loadPromise = (async () => {
  try {
    const res = await fetch(`${API}/api/skills`);
    const data = await res.json();
    skills = data.skills || [];
    _loadSkillApprovalThreshold();
    // Built-in capabilities are no longer surfaced in the Skills menu.
    loaded = true;
    renderSkillsList();
    updateCount();
    if (_pendingFocusSkill) {
      _focusSkillRow(_pendingFocusSkill);
      _pendingFocusSkill = null;
    }
    // If a background audit is running, re-show its progress panel.
    if (!_auditPoll) {
      _fetchAuditStatus().then(st => {
        if (st.status === 'running') _auditAllSkills();
      }).catch(() => {});
    }
  } catch (e) {
    console.error('Failed to load skills:', e);
  } finally {
    _loadPromise = null;
  }
  })();
  return _loadPromise;
}

function _focusSkillRow(name) {
  setTimeout(() => {
    const card = document.querySelector(`.skill-card[data-skill-name="${CSS.escape(name)}"]`);
    if (!card) return;
    card.scrollIntoView({ behavior: 'smooth', block: 'center' });
    card.classList.add('skill-row-flash');
    setTimeout(() => card.classList.remove('skill-row-flash'), 2000);
    // Expand it so the linked skill opens to its SKILL.md directly.
    _expandSkillCard(card, name);
  }, 200);
}

// Open the Memory modal → Skills tab → focus a specific skill row.
// Used by the chat anchor-link delegate ([name](#skill-)).
export function openSkill(name) {
  _pendingFocusSkill = name || null;
  // Open the memory modal if not already open.
  const memBtn = document.getElementById('tool-memory-btn');
  if (memBtn) memBtn.click();
  // Switch to the skills tab (triggers lazy loadSkills()).
  setTimeout(() => {
    const tab = document.querySelector('.memory-tab[data-memory-tab="skills"]');
    if (tab) tab.click();
    else loadSkills();  // fallback if tab structure differs
  }, 120);
}

let _skillsSort = 'confidence';
let _showDraftsOnly = false;
let _showPublishedOnly = false;
let _confMax = null;   // confidence ceiling filter (%, e.g. 90 = show ≤90%); null = off
let _selectMode = false;
const _selectedNames = new Set();
let _skillApprovalThreshold = 0.85;

function updateCount() {
  const el = document.getElementById('skills-count');
  if (el) el.textContent = skills.length || '0';
  const elH = document.getElementById('skills-count-h2');
  if (elH) elH.textContent = skills.length + ' skill' + (skills.length === 1 ? '' : 's');
}

function _sortSkills(list) {
  const arr = list.slice();
  if (_skillsSort === 'confidence') {
    arr.sort((a, b) => (b.confidence || 0) - (a.confidence || 0) || (a.name || '').localeCompare(b.name || ''));
  } else if (_skillsSort === 'uses') {
    arr.sort((a, b) => (b.uses || 0) - (a.uses || 0) || (a.name || '').localeCompare(b.name || ''));
  } else if (_skillsSort === 'recent') {
    arr.sort((a, b) => (b.updated_at || b.created_at || 0) - (a.updated_at || a.created_at || 0));
  } else {
    arr.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
  }
  return arr;
}

function _matches(sk, query) {
  const q = query.toLowerCase();
  return (
    (sk.name || '').toLowerCase().includes(q) ||
    (sk.description || '').toLowerCase().includes(q) ||
    (sk.when_to_use || sk.problem || '').toLowerCase().includes(q) ||
    (sk.category || '').toLowerCase().includes(q) ||
    (sk.tags || []).some(t => (t || '').toLowerCase().includes(q))
  );
}

function _statusPill(sk) {
  const s = sk.status || (sk._legacy ? 'legacy' : 'draft');
  if (s === 'published') return 'published';
  if (s === 'draft')     return 'draft';
  return `${esc(s)}`;
}

// Show a "teacher" badge for skills written by the auto-escalation
// teacher loop. Lets the user tell at-a-glance which procedures were
// hand-authored vs auto-generated so they can audit (and demote /
// edit / publish) before trusting them.
function _sourcePill(sk) {
  if (sk.source !== 'teacher-escalation') return '';
  const teacher = sk.teacher_model || 'teacher';
  return `teacher-created`;
}

function _modelShortName(model) {
  return String(model || '').split('/').filter(Boolean).pop() || String(model || '');
}

function _skillTokens(sk) {
  return new Set(String([
    sk.name || '',
    sk.description || '',
    sk.when_to_use || '',
    ...(sk.tags || []),
  ].join(' ')).toLowerCase()
    .replace(/-\d+\b/g, '')
    .split(/[^a-z0-9]+/)
    .filter(t => t.length > 2 && !['the', 'and', 'with', 'for', 'from', 'using'].includes(t)));
}

function _skillSimilarity(a, b) {
  const A = _skillTokens(a), B = _skillTokens(b);
  if (!A.size || !B.size) return 0;
  let inter = 0;
  for (const t of A) if (B.has(t)) inter++;
  return inter / (A.size + B.size - inter);
}

function _baseSkillName(name) {
  return String(name || '').replace(/-\d+$/, '');
}

function _scoreDuplicateKeeper(sk) {
  return [
    (sk.status === 'published') ? 100000 : 0,
    (sk.uses || 0) * 100,
    Math.round((sk.confidence || 0) * 100),
    sk.audit_by_teacher ? -5 : 0,
    -String(sk.name || '').length / 1000,
  ].reduce((a, b) => a + b, 0);
}

function _duplicateMeta(list) {
  const parent = new Map();
  const names = list.map(s => s.name || s.id).filter(Boolean);
  names.forEach(n => parent.set(n, n));
  const find = (x) => {
    let p = parent.get(x) || x;
    while (p !== parent.get(p)) p = parent.get(p);
    return p;
  };
  const unite = (a, b) => {
    const pa = find(a), pb = find(b);
    if (pa !== pb) parent.set(pb, pa);
  };
  for (let i = 0; i < list.length; i++) {
    for (let j = i + 1; j < list.length; j++) {
      const a = list[i], b = list[j];
      const an = a.name || a.id, bn = b.name || b.id;
      if (!an || !bn) continue;
      if (_baseSkillName(an) === _baseSkillName(bn) || _skillSimilarity(a, b) >= 0.38) {
        unite(an, bn);
      }
    }
  }
  const groups = new Map();
  for (const sk of list) {
    const n = sk.name || sk.id;
    if (!n) continue;
    const root = find(n);
    if (!groups.has(root)) groups.set(root, []);
    groups.get(root).push(sk);
  }
  const meta = new Map();
  let idx = 1;
  for (const group of groups.values()) {
    if (group.length < 2) continue;
    const sorted = group.slice().sort((a, b) => _scoreDuplicateKeeper(b) - _scoreDuplicateKeeper(a));
    const keep = sorted[0].name || sorted[0].id;
    const groupNames = sorted.map(s => s.name || s.id).filter(Boolean);
    for (const sk of sorted) {
      const n = sk.name || sk.id;
      meta.set(n, { group: idx, keep: n === keep, keepName: keep, names: groupNames });
    }
    idx++;
  }
  return meta;
}

function _auditModelPills(sk) {
  const worker = sk.audit_worker_model || '';
  const teacher = sk.audit_teacher_model || '';
  let html = '';
  if (worker) {
    html += `audit`;
  }
  if (sk.audit_by_teacher || teacher) {
    const title = teacher
      ? `Teacher rewrote this skill; audit model passed after the rewrite. Teacher: ${teacher}`
      : 'Teacher rewrote this skill; audit model passed after the rewrite.';
    html += `teacher-fixed`;
  }
  return html;
}

function _necessityKind(sk) {
  const nec = sk && sk.necessity;
  if (sk && sk._duplicateGroup) return 'duplicate';
  if (!nec || nec.necessary !== false) return null;
  const reason = String(nec.reason || '').toLowerCase();
  const redundant = (nec.redundant_with || []).filter(Boolean);
  if (redundant.length || /duplicat|redundan|overlap|same skill|same procedure/.test(reason)) return 'duplicate';
  if (/trivial|generic|capable assistant|without a saved|not need|unnecessary/.test(reason)) return 'trivial';
  return 'irrelevant';
}

function _necessityPill(sk) {
  const kind = _necessityKind(sk);
  if (!kind) return '';
  const nec = sk.necessity || {};
  const dup = (nec.redundant_with || []).filter(Boolean);
  const label = kind === 'duplicate' ? (sk._duplicateGroup ? `duplicate #${sk._duplicateGroup}` : 'duplicate')
    : kind === 'trivial' ? 'generic'
    : 'possibly-irrelevant';
  const group = sk._duplicateNames || [];
  const why = sk._duplicateGroup
    ? `Duplicate group #${sk._duplicateGroup}. Recommended keep: ${sk._duplicateKeepName}. Group: ${group.join(', ')}`
    : (nec.reason || 'May not be worth keeping') + (dup.length ? ' | overlaps: ' + dup.join(', ') : '');
  return `${label}`;
}

function _duplicatePriorityPill(sk) {
  if (!sk._duplicateGroup) return '';
  if (sk._duplicateKeep) {
    return `recommended`;
  }
  return `lower-priority`;
}

// Verified-by-test indicators shown next to the confidence %. A check when a
// test/audit run passed; a graduation-cap when the teacher model had to
// rewrite the skill to make it pass. SVG (no Unicode emoji).
function _auditMarks(sk) {
  let html = '';
  if (sk.audit_verdict === 'pass') {
    html += ``;
  }
  if (sk.audit_by_teacher) {
    const teacher = sk.audit_teacher_model ? `: ${sk.audit_teacher_model}` : '';
    html += ``;
  }
  return html;
}

// Audit verdict dot — removed at user request. The ✓ check-mark next to the
// confidence % still indicates a pass. Stub returns empty so the surrounding
// header HTML still composes without changing other layout.
function _auditDot(sk) { return ''; }

function _isDraftsFilter() { return !!_showDraftsOnly; }

// Confidence → colour. 90%+ is solidly green, scaling down through
// yellow/orange to red at 50% and below (hue 120→0 over 90→50).
function _confColor(conf) {
  const hue = Math.max(0, Math.min(120, ((conf - 50) / 40) * 120));
  return `hsl(${Math.round(hue)}, 70%, 42%)`;
}

// Shared action icons (collapsed kebab menu + expanded footer use the same).
const _ICON = {
  del:   '',
  edit:  '',
  approve: '',
  unpublish: '',
  test:  '',
};
function _svg(paths, { fill = 'none', size = 13 } = {}) {
  const stroke = fill === 'currentColor' ? '' : 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
  return `${paths}`;
}

// Kebab dropdown for a collapsed skill card — same actions + icons as the
// expanded footer (Publish/Unpublish · Edit · Delete).
function _openSkillMenu(btn, card, sk, name, isPublished) {
  document.querySelectorAll('.skill-kebab-menu').forEach(m => m.remove());
  const menu = document.createElement('div');
  menu.className = 'skill-kebab-menu';
  const mk = (paths, label, opts, onClick) => {
    const item = document.createElement('button');
    item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
    item.innerHTML = _svg(paths, opts) + `${label}`;
    item.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); onClick(); });
    menu.appendChild(item);
  };
  if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft'));
  else mk(_ICON.approve, 'Publish', {}, () => _setSkillStatus(name, 'published'));
  mk(_ICON.edit, 'Edit', {}, async () => {
    if (!card.classList.contains('doclib-card-expanded')) await _expandSkillCard(card, name);
    _toggleSkillEdit(card, name);
  });
  mk(_ICON.test, 'Test', {}, () => _testSkill(card, name));
  // Audit kicks off the bulk audit-all loop (test → judge → fix → retry → demote).
  // Starts at the top of the list and walks down.
  mk(_ICON.test, 'Audit', {}, () => _auditAllSkills());
  mk(_ICON.del, 'Delete', { danger: true }, () => _deleteSkill(name, card));

  // Select — enters bulk-select mode and pre-selects this skill. Same pattern
  // as the email/documents/brain Select item, with the email bullet icon.
  const selItem = document.createElement('button');
  selItem.className = 'skill-kebab-item';
  selItem.innerHTML = 'Select';
  selItem.addEventListener('click', (e) => {
    e.stopPropagation();
    menu.remove();
    if (!_selectMode) _enterSelectMode();
    _selectedNames.add(name);
    renderSkillsList();
  });
  menu.appendChild(selItem);

  // Mobile-only Cancel — mirrors the email/documents/brain popup pattern.
  // CSS hides `.dropdown-cancel-mobile` on desktop where outside-click
  // already dismisses cleanly.
  const cancelItem = document.createElement('button');
  cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile';
  cancelItem.innerHTML = 'Cancel';
  cancelItem.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); });
  menu.appendChild(cancelItem);

  document.body.appendChild(menu);
  const r = btn.getBoundingClientRect();
  menu.style.top = (r.bottom + 4) + 'px';
  menu.style.right = Math.max(6, window.innerWidth - r.right) + 'px';
  // Keep it on-screen (mobile): flip above the button if it would overflow the
  // bottom, clamp the left edge, and cap the height as a last resort.
  const mr = menu.getBoundingClientRect();
  if (mr.bottom > window.innerHeight - 6) {
    menu.style.top = Math.max(6, r.top - mr.height - 4) + 'px';
  }
  if (mr.left < 6) {
    menu.style.right = Math.max(6, window.innerWidth - 6 - mr.width) + 'px';
  }
  const mr2 = menu.getBoundingClientRect();
  if (mr2.bottom > window.innerHeight - 6) {
    menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px';
    menu.style.overflowY = 'auto';
  }
  const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } };
  setTimeout(() => document.addEventListener('click', close, true), 0);
}

// Cards for the agent's built-in tool capabilities (from
// /api/skills/builtin → TOOL_SECTIONS). Expandable to preview the
// instruction block; editable with a warning + a revert-to-default
// button (overrides stored in settings, applied to the prompt).
function _buildBuiltinCards() {
  return builtinSkills.map(b => {
    const card = document.createElement('div');
    card.className = 'doclib-card skill-card skill-builtin-card';
    card.dataset.builtinName = b.name;

    const header = document.createElement('div');
    header.className = 'doclib-card-header skill-card-header';
    header.innerHTML = `
      
      
${esc(b.name)} built-in ${b.is_overridden ? 'edited' : ''}
${b.description ? `
${esc(b.description)}
` : ''}
`; card.appendChild(header); const preview = document.createElement('div'); preview.className = 'doclib-card-preview skill-card-preview'; // Warning banner — editing a built-in changes how the assistant uses a native tool. const warn = document.createElement('div'); warn.className = 'skill-builtin-warn'; 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.'; preview.appendChild(warn); const pre = document.createElement('pre'); pre.className = 'skill-md-pre'; pre.textContent = ''; // filled on expand preview.appendChild(pre); // Footer: Revert (left, only meaningful when overridden) · Edit/Save (right). const actions = document.createElement('div'); actions.className = 'doclib-card-expanded-actions'; const revertBtn = document.createElement('button'); revertBtn.className = 'doclib-card-text-btn doclib-card-action-btn doclib-card-text-btn-danger'; revertBtn.innerHTML = 'Revert'; revertBtn.title = 'Restore the original shipped instructions'; revertBtn.addEventListener('click', (e) => { e.stopPropagation(); _revertBuiltin(b.name); }); const editBtn = document.createElement('button'); editBtn.className = 'doclib-card-text-btn doclib-card-action-btn'; editBtn.innerHTML = 'Edit'; editBtn.addEventListener('click', (e) => { e.stopPropagation(); _toggleBuiltinEdit(card, b.name); }); const rightGroup = document.createElement('div'); rightGroup.className = 'doclib-action-group'; const btnRow = document.createElement('div'); btnRow.className = 'doclib-action-btn-row'; btnRow.appendChild(editBtn); rightGroup.appendChild(btnRow); actions.appendChild(revertBtn); actions.appendChild(rightGroup); preview.appendChild(actions); card.appendChild(preview); card.addEventListener('click', (e) => { if (e.target.closest('button, input, textarea')) return; _expandBuiltinCard(card, b.name); }); return card; }); } async function _expandBuiltinCard(card, name) { const grid = card.closest('.doclib-grid'); if (card.classList.contains('doclib-card-expanded')) { card.classList.remove('doclib-card-expanded'); return; } if (grid) grid.querySelectorAll('.doclib-card-expanded').forEach(c => c.classList.remove('doclib-card-expanded')); card.classList.add('doclib-card-expanded'); if (grid) grid.scrollTop = 0; const pre = card.querySelector('.skill-md-pre'); if (pre && !card._loaded) { pre.textContent = 'Loading…'; try { const res = await fetch(`${API}/api/skills/builtin/${encodeURIComponent(name)}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); pre.textContent = data.text || '(empty)'; card._loaded = true; card._text = data.text || ''; card._default = data.default || ''; } catch (e) { pre.textContent = 'Failed to load.'; } } } function _toggleBuiltinEdit(card, name) { const preview = card.querySelector('.skill-card-preview'); if (!preview) return; if (preview.querySelector('.skill-md-editor')) { _saveBuiltinEdit(card, name); return; } const pre = preview.querySelector('.skill-md-pre'); const ta = document.createElement('textarea'); ta.className = 'skill-md-editor'; ta.spellcheck = false; ta.value = (card._text != null ? card._text : (pre ? pre.textContent : '')) || ''; ta.addEventListener('click', (e) => e.stopPropagation()); if (pre) pre.style.display = 'none'; preview.insertBefore(ta, preview.querySelector('.doclib-card-expanded-actions')); ta.focus(); const editBtn = [...preview.querySelectorAll('.doclib-card-action-btn')].find(b => /Edit|Save/.test(b.textContent)); if (editBtn) editBtn.innerHTML = 'Save'; } async function _saveBuiltinEdit(card, name) { const ta = card.querySelector('.skill-md-editor'); if (!ta) return; try { const res = await fetch(`${API}/api/skills/builtin/${encodeURIComponent(name)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: ta.value }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); uiModule.showToast('Built-in capability updated'); builtinSkills = []; // force reload of built-in list (refreshes "edited" badge) await loadSkills(); } catch (e) { uiModule.showError('Save failed: ' + e.message); } } async function _revertBuiltin(name) { if (!(await uiModule.styledConfirm(`Revert "${name}" to its original built-in instructions?`, { confirmText: 'Revert', danger: true }))) return; try { const res = await fetch(`${API}/api/skills/builtin/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); uiModule.showToast('Reverted to default'); builtinSkills = []; await loadSkills(); } catch (e) { uiModule.showError('Revert failed: ' + e.message); } } function _getFilteredSkills() { const query = (document.getElementById('skills-search')?.value || '').toLowerCase(); let filtered = query ? skills.filter(sk => _matches(sk, query)) : skills; if (_showDraftsOnly) { filtered = filtered.filter(sk => (sk.status || 'draft') !== 'published'); } if (_showPublishedOnly) { filtered = filtered.filter(sk => (sk.status || 'draft') === 'published'); } if (_confMax != null) { // "≤ X%" — surface the lower-confidence skills that may need review. filtered = filtered.filter(sk => Math.round((sk.confidence || 0) * 100) <= _confMax); } return _sortSkills(filtered); } function renderSkillsList() { const container = document.getElementById('skills-list'); if (!container) return; // Re-render rebuilds the cards (none expanded), so clear the expand flag // on the admin-card or it would keep the toolbar hidden with nothing open. container.closest('.admin-card')?.classList.remove('skills-has-expanded'); const sorted = _getFilteredSkills(); // Built-in capabilities show as their own read-only section (skipped when // the user is filtering to drafts, since built-ins aren't drafts). // Skills menu shows the user's own skills only (built-in capabilities // are intentionally not surfaced here). const showBuiltin = false; if (!sorted.length && !showBuiltin) { container.innerHTML = `
${loaded ? 'No skills yet, use agent for it to auto extract them.' : 'Loading…'}
`; return; } // Library-style cards: a compact bar that expands in-place to show the // SKILL.md, with a footer (Delete left; Edit / Run / Approve right). // Reuses the proven .doclib-card / .doclib-card-preview / // .doclib-card-expanded-actions markup so the desktop+mobile expand + // footer behaviour matches the document/chat library exactly. // // #skills-list itself becomes the .doclib-grid (rather than a nested // grid) so the global "hide non-grid children when a card is expanded" // rule (.admin-card:has(.doclib-card-expanded) > *:not(.doclib-grid)) // doesn't hide the list container along with everything else. container.classList.add('doclib-grid'); const cards = []; const dupeMeta = _duplicateMeta(sorted); for (const sk of sorted) { const name = sk.name || sk.id; const dm = dupeMeta.get(name); if (dm) { sk._duplicateGroup = dm.group; sk._duplicateKeep = dm.keep; sk._duplicateKeepName = dm.keepName; sk._duplicateNames = dm.names; } else { delete sk._duplicateGroup; delete sk._duplicateKeep; delete sk._duplicateKeepName; delete sk._duplicateNames; } const conf = Math.round((sk.confidence || 0) * 100); const uses = sk.uses || 0; const isPublished = (sk.status === 'published'); const confColor = _confColor(conf); const card = document.createElement('div'); card.className = 'doclib-card skill-card'; card.dataset.skillName = name; card.dataset.skillStatus = sk.status || 'draft'; const checked = _selectedNames.has(name) ? 'checked' : ''; const cbHtml = _selectMode ? `` : ''; // Collapsed header bar: dot · name (wraps) · [pills (right) · stats · menu]. const header = document.createElement('div'); header.className = 'doclib-card-header skill-card-header'; header.innerHTML = ` ${cbHtml} ${_auditDot(sk)}
${esc(name)} ${sk.description ? `
${esc(sk.description)}
` : ''}
${_statusPill(sk)} ${_sourcePill(sk)} ${_auditModelPills(sk)} ${_necessityPill(sk)} ${_duplicatePriorityPill(sk)} ${_auditMarks(sk)}${conf}% · ${uses}u
`; card.appendChild(header); // Kebab dropdown (collapsed-bar quick actions: same set + icons as the // expanded footer). Clicking the kebab opens it; it doesn't expand. header.querySelector('.skill-kebab-btn').addEventListener('click', (e) => { e.stopPropagation(); _openSkillMenu(e.currentTarget, card, sk, name, isPublished); }); // Preview (hidden until expanded) — SKILL.md goes here + footer. const preview = document.createElement('div'); preview.className = 'doclib-card-preview skill-card-preview'; const pre = document.createElement('pre'); pre.className = 'skill-md-pre'; pre.textContent = ''; // filled on expand preview.appendChild(pre); // Footer: Approve/Unpublish on the left, destructive delete on the right. const actions = document.createElement('div'); actions.className = 'doclib-card-expanded-actions'; const delBtn = document.createElement('button'); delBtn.className = 'doclib-card-text-btn doclib-card-action-btn doclib-card-text-btn-danger'; delBtn.innerHTML = 'Delete'; delBtn.addEventListener('click', (e) => { e.stopPropagation(); _deleteSkill(name, card); }); const editBtn = document.createElement('button'); editBtn.className = 'doclib-card-text-btn doclib-card-action-btn'; editBtn.innerHTML = 'Edit'; editBtn.addEventListener('click', (e) => { e.stopPropagation(); _toggleSkillEdit(card, name); }); const pubBtn = document.createElement('button'); pubBtn.className = 'doclib-card-text-btn doclib-card-action-btn'; if (isPublished) { pubBtn.innerHTML = 'Unpublish'; pubBtn.title = 'Move back to draft'; pubBtn.addEventListener('click', (e) => { e.stopPropagation(); _setSkillStatus(name, 'draft'); }); } else { pubBtn.innerHTML = 'Publish'; pubBtn.title = 'Publish — appears in the skills index'; pubBtn.style.color = 'var(--color-success, #4caf50)'; pubBtn.addEventListener('click', (e) => { e.stopPropagation(); _setSkillStatus(name, 'published'); }); } // Test/audit this one skill — same action that's in the kebab, surfaced in // the footer too so it's not buried under the "⋯" menu. const testBtn = document.createElement('button'); testBtn.className = 'doclib-card-text-btn doclib-card-action-btn'; testBtn.innerHTML = _svg(_ICON.test, { size: 11 }) + 'Test'; testBtn.title = 'Test this skill — run it + AI judge'; testBtn.addEventListener('click', (e) => { e.stopPropagation(); // Immediate visual feedback: previously the click looked like nothing // happened because _testSkill awaits a status fetch before overwriting // the preview — so users would tap a second time. Mark the button as // pending right away so the first tap is obviously registered. if (testBtn.dataset.busy === '1') return; // also dedupe rapid double-tap testBtn.dataset.busy = '1'; testBtn.disabled = true; const _origHTML = testBtn.innerHTML; testBtn.innerHTML = _svg(_ICON.test, { size: 11 }) + 'Starting…'; Promise.resolve(_testSkill(card, name)).finally(() => { // The preview gets overwritten by _testSkill, which removes the // testBtn from the DOM. The cleanup below only matters if the // button still exists (e.g. _testSkill bailed early). if (document.body.contains(testBtn)) { testBtn.disabled = false; testBtn.dataset.busy = ''; testBtn.innerHTML = _origHTML; } }); }); const rightGroup = document.createElement('div'); rightGroup.className = 'doclib-action-group'; const btnRow = document.createElement('div'); btnRow.className = 'doclib-action-btn-row'; btnRow.appendChild(testBtn); btnRow.appendChild(editBtn); btnRow.appendChild(delBtn); rightGroup.appendChild(btnRow); actions.appendChild(pubBtn); actions.appendChild(rightGroup); preview.appendChild(actions); card.appendChild(preview); // Click to expand/collapse (unless in select mode → toggle checkbox). card.addEventListener('click', (e) => { if (card._suppressNextClick) { card._suppressNextClick = false; return; } if (e.target.closest('button, input, textarea')) return; if (_selectMode) { const cb = card.querySelector('.skill-select-cb'); if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); } return; } _expandSkillCard(card, name); }); // Long-press anywhere on the card opens the kebab dropdown — mirrors the // documents library + brain memory pattern. Skip when touch starts on a // button/input so per-control handlers keep working. { const kebab = header.querySelector('.skill-kebab-btn'); let hold = null; let start = null; const _lpCancel = () => { if (hold) { clearTimeout(hold); hold = null; } start = null; }; card.addEventListener('pointerdown', (e) => { if (e.target.closest('.skill-kebab-btn, .skill-select-cb, button, input, textarea')) return; start = { x: e.clientX, y: e.clientY }; hold = setTimeout(() => { hold = null; card._suppressNextClick = true; setTimeout(() => { card._suppressNextClick = false; }, 400); if (navigator.vibrate) try { navigator.vibrate(15); } catch {} if (kebab) kebab.click(); }, 500); }); card.addEventListener('pointermove', (e) => { if (!start) return; if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 10) _lpCancel(); }); card.addEventListener('pointerup', _lpCancel); card.addEventListener('pointercancel', _lpCancel); } cards.push(card); } container.innerHTML = ''; // Two collapsible sections — "Your skills" and "Built-in". Headers and // cards are all DIRECT children of the grid (cards tagged with // data-skill-section) so the global expand rule — which hides sibling // .doclib-card elements by direct-child selector — keeps working. // Collapse just toggles display on the tagged cards. const _mkSectionHeader = (sectionId, title, count) => { const collapsed = _collapsedSections.has(sectionId); const hdr = document.createElement('div'); hdr.className = 'skills-section-label skills-section-header' + (collapsed ? ' collapsed' : ''); hdr.dataset.section = sectionId; hdr.innerHTML = `` + `${esc(title)}` + `${count}`; hdr.addEventListener('click', () => { if (_collapsedSections.has(sectionId)) _collapsedSections.delete(sectionId); else _collapsedSections.add(sectionId); _saveCollapsedSections(); _applySectionCollapse(container); }); return hdr; }; // "Your skills" section — show the header only when there's also a // built-in section to distinguish from (otherwise it's just the list). if (cards.length) { if (showBuiltin) container.appendChild(_mkSectionHeader('user', 'Your skills', cards.length)); cards.forEach(c => { c.dataset.skillSection = 'user'; container.appendChild(c); }); } // Built-in capabilities — read-only cards (the agent's native tools). if (showBuiltin) { const builtinCards = _buildBuiltinCards(); container.appendChild(_mkSectionHeader('builtin', 'Built-in capabilities', builtinCards.length)); builtinCards.forEach(c => { c.dataset.skillSection = 'builtin'; container.appendChild(c); }); } _applySectionCollapse(container); // Domino-in cascade when the Skills tab is (re)opened — same sleek // staggered entrance the document/chat library uses (.doclib-just-opened // → section-domino-in on each .doclib-card child). Only consumes the flag // set on tab-open, so search/sort/edit re-renders stay instant. if (_cascadeNext && cards.length) { _cascadeNext = false; _playSkillsCascade(container); } // Select-mode checkbox wiring (card-body click is handled in the card's // own click listener above). if (_selectMode) { container.querySelectorAll('.skill-select-cb').forEach(cb => { cb.addEventListener('change', () => { const name = cb.dataset.name; if (cb.checked) _selectedNames.add(name); else _selectedNames.delete(name); const all = document.getElementById('skills-select-all'); if (all) { const visible = _getFilteredSkills().map(s => s.name || s.id); all.checked = visible.length > 0 && visible.every(n => _selectedNames.has(n)); } _updateBulkBar(); }); }); } // Background-load the visible skills' SKILL.md so expanding any of them is // instant (no first-time async fetch → no jump). Deferred so it never // competes with the render/cascade paint. setTimeout(_preloadVisibleMarkdown, 0); } // ---- Card expand / edit / actions ---- // Collapse an expanded skill card: drop the class AND clear the inline // heights skills.js pinned on the card/preview/
 (otherwise a collapsed
// card keeps its full expanded height) and detach its resize listener.
function _collapseSkillCardEl(c) {
  c.classList.remove('doclib-card-expanded', 'skill-expand-instant');
  c.style.removeProperty('height');
  const pv = c.querySelector('.doclib-card-preview');
  const pr = c.querySelector('.skill-md-pre') || c.querySelector('.skill-md-editor');
  if (pv) { pv.style.removeProperty('height'); pv.style.removeProperty('flex'); pv.style.removeProperty('max-height'); }
  if (pr) { pr.style.removeProperty('height'); pr.style.removeProperty('flex'); }
  if (c._fillH) window.removeEventListener('resize', c._fillH);
}

async function _expandSkillCard(card, name) {
  const grid = card.closest('.doclib-grid');
  const adminCard = card.closest('.admin-card');
  // Toggle collapse if already open.
  if (card.classList.contains('doclib-card-expanded')) {
    _collapseSkillCardEl(card);
    if (adminCard) adminCard.classList.remove('skills-has-expanded');
    return;
  }
  // Were we already showing another expanded card? If so this is a SWITCH,
  // not a fresh open — skip the fade-in. The fade reveals the previous card
  // collapsing behind the new (semi-transparent) one, which read as a jump.
  const switching = !!(grid && grid.querySelector('.doclib-card-expanded'));
  // Collapse any other expanded sibling (full cleanup, not just the class).
  if (grid) grid.querySelectorAll('.doclib-card-expanded').forEach(_collapseSkillCardEl);
  card.classList.add('doclib-card-expanded');
  if (switching) card.classList.add('skill-expand-instant');
  // Explicit class on the admin-card so CSS doesn't depend on :has()
  // (Firefox mobile builds without :has left the expand at ~50%).
  if (adminCard) adminCard.classList.add('skills-has-expanded');
  if (grid) grid.scrollTop = 0;

  // Firefox doesn't treat the absolutely-positioned card's stretched height
  // (inset:0) or height:100% as DEFINITE, so grid/flex children won't fill.
  // Pin an explicit px height = the card's already-rendered height. A px
  // value is unambiguously definite, so the preview + 
 finally fill.
  card._fillH = () => {
    // Reset any prior inline heights so we measure the natural box first
    // (and so switching desktop<->mobile never leaves stale px values).
    card.style.removeProperty('height');
    const preview = card.querySelector('.doclib-card-preview');
    const header = card.querySelector('.skill-card-header');
    const pre = card.querySelector('.skill-md-pre') || card.querySelector('.skill-md-editor');
    if (preview) { preview.style.removeProperty('height'); preview.style.removeProperty('flex'); preview.style.removeProperty('max-height'); }
    if (pre) { pre.style.removeProperty('height'); pre.style.removeProperty('flex'); }

    // The px-pinning is ONLY for the mobile layout (position:absolute fill,
    // where Firefox won't propagate a definite height). On desktop the card
    // expands via normal flex/flow — pinning measured heights there just
    // under-sizes it. So bail on desktop and let the CSS handle it.
    if (!window.matchMedia('(max-width: 768px)').matches) return;

    const cardH = card.getBoundingClientRect().height;
    if (cardH <= 0) return;
    card.style.setProperty('height', cardH + 'px', 'important');
    if (!preview) return;

    const px = (el, prop) => parseFloat(getComputedStyle(el)[prop]) || 0;
    const headerH = header ? header.getBoundingClientRect().height : 0;
    const cardPad = px(card, 'paddingTop') + px(card, 'paddingBottom');
    const previewH = Math.max(0, cardH - headerH - cardPad);
    // Force the preview to an explicit height (flex:none so nothing fights it).
    // A max-height (~335px, resolved from a % rule) was capping it — clear it.
    preview.style.setProperty('flex', '0 0 auto', 'important');
    preview.style.setProperty('max-height', 'none', 'important');
    preview.style.setProperty('height', previewH + 'px', 'important');

    if (pre) {
      // Pre = preview height minus its non-pre siblings (footer, warn banner).
      const prevPad = px(preview, 'paddingTop') + px(preview, 'paddingBottom');
      let siblings = 0;
      for (const child of preview.children) {
        if (child !== pre) siblings += child.getBoundingClientRect().height;
      }
      const preH = Math.max(0, previewH - prevPad - siblings);
      pre.style.setProperty('height', preH + 'px', 'important');
      pre.style.setProperty('flex', '0 0 auto', 'important');
    }
  };
  // Size SYNCHRONOUSLY (not in rAF) so the pinned heights are in place before
  // the browser's first paint of the expanded card. Running it a frame later
  // let the first frame paint at content-height, then snap — the "explosion"
  // that showed on the first expand (when the SKILL.md was still loading).
  card._fillH();
  window.addEventListener('resize', card._fillH);

  const pre = card.querySelector('.skill-md-pre');
  if (pre && !card._mdLoaded) {
    // Use the cache when available (the bg preload usually has it already),
    // so the content is in place synchronously — no async settle/jump.
    if (_mdCache.has(name)) {
      const md = _mdCache.get(name);
      pre.textContent = md || '(empty)';
      card._mdLoaded = true;
      card._md = md || '';
    } else {
      pre.textContent = 'Loading…';
      try {
        const md = await _fetchSkillMarkdown(name);
        pre.textContent = md || '(empty)';
        card._mdLoaded = true;
        card._md = md;
      } catch (e) {
        pre.textContent = 'Failed to load SKILL.md';
      }
    }
  }
}

// Swap the read-only 
 for an editable 
        

Edit the frontmatter and body directly. Save replaces the file via PUT /api/skills/{name}.

`; document.body.appendChild(wrap); const ta = wrap.querySelector('#skill-md-textarea'); ta.value = md; wrap.querySelector('#skill-md-close').addEventListener('click', () => wrap.remove()); wrap.addEventListener('click', (e) => { if (e.target === wrap) wrap.remove(); }); wrap.querySelector('#skill-save-btn').addEventListener('click', async () => { try { // We use the manage_skills-style edit by going through PUT with a // single 'content' field. The route doesn't accept that yet — use the // tool call instead. We have a /api/skills/{name} PUT for fields, but // a full SKILL.md replace is simpler via the parsed-then-PUT approach // below: parse client-side by uploading via the tool route. const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}/markdown`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ markdown: ta.value }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); uiModule.showToast('Saved'); wrap.remove(); await loadSkills(); } catch (e) { uiModule.showError('Save failed: ' + e.message); } }); } async function addSkill() { const name = document.getElementById('new-skill-name')?.value.trim() || document.getElementById('new-skill-title')?.value.trim(); const description = document.getElementById('new-skill-description')?.value.trim() || document.getElementById('new-skill-title')?.value.trim(); const whenToUse = document.getElementById('new-skill-when')?.value.trim() || document.getElementById('new-skill-problem')?.value.trim() || ''; const procedureRaw = document.getElementById('new-skill-procedure')?.value.trim() || document.getElementById('new-skill-solution')?.value.trim() || ''; const tagsRaw = document.getElementById('new-skill-tags')?.value.trim(); const category = document.getElementById('new-skill-category')?.value.trim() || 'general'; if (!description && !name) { uiModule.showError('Description (or name) is required'); return; } const procedure = procedureRaw ? procedureRaw.split('\n').map(s => s.replace(/^\s*(?:[-*]|\d+[.)])\s+/, '').trim()).filter(Boolean) : []; const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : []; try { const res = await fetch(`${API}/api/skills/add`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name || undefined, description, category, when_to_use: whenToUse, procedure, tags, status: 'draft', }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); ['new-skill-name', 'new-skill-title', 'new-skill-description', 'new-skill-when', 'new-skill-problem', 'new-skill-procedure', 'new-skill-solution', 'new-skill-tags', 'new-skill-category'] .forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); await loadSkills(); uiModule.showToast('Skill added (draft)'); } catch (err) { uiModule.showError('Failed to add skill: ' + err.message); } } document.addEventListener('DOMContentLoaded', () => { document.getElementById('add-skill-btn')?.addEventListener('click', addSkill); document.getElementById('skills-search')?.addEventListener('input', renderSkillsList); document.getElementById('skills-sort')?.addEventListener('change', (e) => { // Dropdown holds two optgroups: Sort (sort:) and Filter (filter:). // Picking a sort option leaves the filter alone, and vice-versa. const v = e.target.value || ''; if (v.startsWith('sort:')) { _skillsSort = v.slice(5); } else if (v.startsWith('filter:')) { const f = v.slice(7); if (f === 'all') { _showDraftsOnly = false; _showPublishedOnly = false; _confMax = null; } else if (f === 'drafts') { _showDraftsOnly = true; _showPublishedOnly = false; _confMax = null; } else if (f === 'published') { _showPublishedOnly = true; _showDraftsOnly = false; _confMax = null; } else if (f.startsWith('conf')) { _showDraftsOnly = false; _showPublishedOnly = false; _confMax = parseInt(f.slice(4), 10) || null; } } renderSkillsList(); }); document.getElementById('skills-select-btn')?.addEventListener('click', () => { if (_selectMode) _exitSelectMode(); else _enterSelectMode(); }); document.getElementById('skills-audit-btn')?.addEventListener('click', _auditAllSkills); document.getElementById('skills-select-all')?.addEventListener('change', _toggleSelectAll); document.getElementById('skills-bulk-cancel')?.addEventListener('click', _exitSelectMode); document.getElementById('skills-bulk-audit')?.addEventListener('click', _bulkAudit); document.getElementById('skills-bulk-delete')?.addEventListener('click', _bulkDelete); document.getElementById('skills-bulk-delete-nonpassing')?.addEventListener('click', _bulkDeleteNonPassing); document.getElementById('skills-bulk-publish')?.addEventListener('click', _bulkApprove); document.getElementById('new-skill-title')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') addSkill(); }); document.getElementById('new-skill-name')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') addSkill(); }); }); export default { loadSkills, openSkill }; // Populate the Skills badge on first load so the count is right before the // user clicks into the tab. Cheap fetch — same as the lazy path. document.addEventListener('DOMContentLoaded', () => { loadSkills(); });