File size: 7,831 Bytes
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73063cc
 
 
 
 
b30b7c5
73063cc
 
 
 
 
 
 
 
 
b30b7c5
 
73063cc
b30b7c5
 
 
 
 
 
73063cc
 
 
 
 
 
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
// Skills de Elffuss Code: instrucciones en markdown (formato SKILL.md de
// Claude Code) que el modelo sigue cuando aplican. Se instalan desde el
// catálogo grande OFICIAL (github.com/anthropics/skills), desde los plugins
// oficiales, o desde CUALQUIER repo público (marketplaces de la comunidad
// tipo OpenClaude/openclaw). Todo transparente: se ve el repo, la lista y el
// SKILL.md que se inyecta. Se guardan en IndexedDB (nada sale del navegador).
import * as db from './db.js';

const KEY = 'skills';          // skills instaladas
const SRC_KEY = 'skills.sources'; // repos personalizados añadidos por el usuario
const MAX_SKILL = 12_000;      // caracteres del cuerpo que se inyectan al modelo

export const CATALOG_REPO = 'anthropics/skills';
export const DEFAULT_SOURCES = [
  { repo: 'bbgnsurftech/claude-skills-collection', label: 'Comunidad · Mega-colección (1000+ skills)', official: true },
  { repo: 'anthropics/skills', label: 'Anthropic · Agent Skills (oficial de Claude Code)', official: true },
  { repo: 'anthropics/claude-plugins-official', label: 'Anthropic · Claude Code Plugins (oficial)', official: true },
];

let cache = []; // instaladas, en memoria (para que el systemPrompt sea síncrono)

// `loaded` distingue «no hay skills» de «no se pudo leer»: si la lectura
// falla, cache queda vacío pero loaded=false, y install() se niega a persistir
// (antes, un fallo transitorio de IndexedDB borraba TODAS las skills al
// guardar la siguiente).
let loaded = false;
export async function initSkills() {
  try {
    const stored = await db.get('kv', KEY);
    cache = Array.isArray(stored) ? stored : [];
    loaded = true;
  } catch (e) {
    cache = [];
    loaded = false;
    console.warn('[elffuss] no se pudieron leer las skills; no las sobrescribiré', e);
  }
  return cache;
}
export function skillsLoaded() { return loaded; }

export async function all() { return cache; }
export function installed() { return cache; }
export function isInstalled(repo, path) { return cache.some(s => s.repo === repo && s.path === path); }

export async function install(skill) {
  // Si la carga inicial falló, cache no representa lo instalado: reintentar
  // leer antes de escribir. Sin esto, guardar aquí borraría las skills reales.
  if (!loaded) {
    await initSkills();
    if (!loaded) throw new Error('No pude leer tus skills guardadas; no instalo para no perderlas. Recarga la página e inténtalo otra vez.');
  }
  cache = cache.filter(s => !(s.repo === skill.repo && s.path === skill.path) && s.name !== skill.name);
  const entry = { ...skill, content: (skill.content || '').slice(0, MAX_SKILL) };
  cache.push(entry);
  await db.set('kv', KEY, cache);
  return entry;                       // devuelve la skill completa (name, description…)
}

// Mensaje «cómo usarla» tras instalar: qué hace + un ejemplo de qué pedir.
export function usageMessage(skill) {
  const desc = (skill.description || '').trim();
  const ejemplo = firstExample(skill) || `algo relacionado con «${skill.name}»`;
  return `✳ **Skill «${skill.name}» instalada.**\n\n` +
    (desc ? desc + '\n\n' : '') +
    `Ya la sigo en cada conversación. Para usarla, pídeme por ejemplo:\n\n> ${ejemplo}`;
}

// Intenta sacar un ejemplo de uso del cuerpo del SKILL.md (líneas de ejemplo/uso).
function firstExample(skill) {
  const body = skill.content || '';
  const m = body.match(/(?:ejemplo|example|uso|usage|prueba|try)[^\n:]*[::]\s*["“]?([^\n"”]{6,90})/i)
    || body.match(/^[-*]\s+([A-ZÁÉÍÓÚ][^\n]{10,80})/m);
  return m ? m[1].trim().replace(/[.*_`]+$/, '') : null;
}

export async function remove(nameOrPath) {
  cache = cache.filter(s => s.name !== nameOrPath && s.path !== nameOrPath);
  await db.set('kv', KEY, cache);
}

export async function get(name) {
  return cache.find(s => s.name.toLowerCase() === String(name).toLowerCase()) || null;
}

// ---- fuentes (repos) ----
export async function sources() {
  const custom = (await db.get('kv', SRC_KEY).catch(() => null)) || [];
  return [...DEFAULT_SOURCES, ...custom];
}
export async function addSource(repoOrUrl, label) {
  const repo = repoOrUrl.trim().replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '').replace(/\/$/, '');
  if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new Error('Usa owner/repo (o la URL de GitHub)');
  const custom = (await db.get('kv', SRC_KEY).catch(() => null)) || [];
  if (!custom.some(s => s.repo === repo) && !DEFAULT_SOURCES.some(s => s.repo === repo))
    custom.push({ repo, label: label || repo });
  await db.set('kv', SRC_KEY, custom);
  return repo;
}
export async function removeSource(repo) {
  const custom = (await db.get('kv', SRC_KEY).catch(() => null)) || [];
  await db.set('kv', SRC_KEY, custom.filter(s => s.repo !== repo));
}

// ---- catálogo desde GitHub ----
// Una sola llamada al árbol git del repo → todos los SKILL.md.
export async function listFromRepo(repo) {
  let tree, branch;
  for (const b of ['main', 'master']) {
    const r = await fetch(`https://api.github.com/repos/${repo}/git/trees/${b}?recursive=1`);
    if (r.ok) { tree = await r.json(); branch = b; break; }
    if (r.status === 403) throw new Error('GitHub limitó las peticiones (60/h sin login). Reintenta en unos minutos.');
  }
  if (!tree) throw new Error('No pude leer el repo (¿existe y es público?)');
  return (tree.tree || [])
    .filter(n => /(^|\/)SKILL\.md$/i.test(n.path))
    .map(n => ({ repo, branch, path: n.path, dir: n.path.replace(/\/SKILL\.md$/i, ''), name: n.path.replace(/\/SKILL\.md$/i, '').split('/').pop() || repo }))
    .sort((a, b) => a.dir.localeCompare(b.dir));
}

// Descarga el SKILL.md y lo instala (frontmatter YAML simple).
export async function installFromRepo(entry) {
  let md = null;
  for (const b of [entry.branch, 'main', 'master'].filter(Boolean)) {
    const r = await fetch(`https://raw.githubusercontent.com/${entry.repo}/${b}/${entry.path}`);
    if (r.ok) { md = await r.text(); break; }
  }
  if (md == null) throw new Error('No pude descargar el SKILL.md');
  const skill = parseSkill(md, entry.name);
  return install({ ...skill, repo: entry.repo, path: entry.path });
}

// SKILL.md → { name, description, content }
export function parseSkill(md, fallbackName = 'skill') {
  const fm = md.match(/^---\n([\s\S]*?)\n---/);
  const meta = fm?.[1] || '';
  const name = meta.match(/^name:\s*(.+)$/m)?.[1]?.trim() || fallbackName;
  const description = (meta.match(/^description:\s*([\s\S]+?)(?:\n\w+:|$)/m)?.[1] || '').replace(/\s+/g, ' ').trim();
  const content = md.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
  return { name, description, content };
}

// Creador de skills: Elffuss fabrica una skill propia (SKILL.md) a partir de
// lo que el usuario quiere, y la instala al instante. Aparece en la pestaña
// Skills y se inyecta en el prompt de los siguientes turnos.
export async function createSkill({ name, description, instructions } = {}) {
  if (!name || !instructions) throw new Error('Faltan name o instructions');
  const skill = {
    name: String(name).slice(0, 48),
    description: (description || '').slice(0, 240),
    content: String(instructions).slice(0, MAX_SKILL),
    repo: 'creada por Elffuss',
    path: 'local/' + Date.now(),
  };
  await install(skill);
  return `Skill «${skill.name}» creada e instalada. Ya la sigo en cada conversación (mírala en la pestaña Skills).`;
}

// Bloque para el systemPrompt (síncrono, desde la caché).
export function skillsPromptBlock() {
  if (!cache.length) return '';
  const parts = cache.map(s =>
    `### Skill «${s.name}»${s.repo ? ` (de ${s.repo})` : ''}\n${s.description || ''}\n${(s.content || '').slice(0, MAX_SKILL)}`);
  return `\n\nSKILLS ACTIVAS (instrucciones especializadas; síguelas cuando la tarea encaje):\n${parts.join('\n\n')}`;
}