@saulwade/swl-ses 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * handlers.js — Handlers para los 3 endpoints MCP stub de swl-ses.
4
+ * handlers.js — Handlers para los 3 endpoints MCP del swl-mcp-server.
5
5
  *
6
- * **EXPERIMENTAL** no producción. Sin auth, sin rate limiting, sin
7
- * tests robustos. Ver `scripts/mcp-server/README.md` para limitaciones.
6
+ * v1.0.0 (ADR-0019 Sub-fase 3): integra caching mtime-based desde
7
+ * `scripts/mcp-server/cache.js`. Sigue siendo solo lectura.
8
8
  *
9
9
  * Los handlers leen el estado file-based de swl-ses (APRENDIZAJES.md,
10
10
  * .planning/sessions/, instintos/proyecto.yaml) y devuelven datos
@@ -18,6 +18,11 @@ const path = require('path');
18
18
 
19
19
  const memorySearch = require('../../hooks/lib/memory-search');
20
20
  const scoringInstintos = require('../lib/scoring-instintos');
21
+ const { crearCache } = require('./cache');
22
+
23
+ // Cache singleton — compartido entre handlers para reducir IO en lecturas repetidas.
24
+ // mtime-based, invalidación automática cuando el archivo cambia. ADR-0019 Sub-fase 3.
25
+ const cache = crearCache();
21
26
 
22
27
  // ── handler: swl_memory_search ────────────────────────────────────────────────
23
28
 
@@ -64,18 +69,18 @@ function swlAprendizajesRecientes(baseDir, args = {}) {
64
69
  : 10;
65
70
 
66
71
  const ruta = path.join(baseDir, '.planning', 'APRENDIZAJES.md');
67
- if (!fs.existsSync(ruta)) {
68
- return { error: 'APRENDIZAJES.md no encontrado', results: [] };
69
- }
70
-
71
- let contenido;
72
+ let cached;
72
73
  try {
73
- contenido = fs.readFileSync(ruta, 'utf8');
74
+ cached = cache.get(ruta, (contenido) => {
75
+ const bloques = contenido.split(/^## /m).filter(b => b.trim().length > 0);
76
+ return { bloques };
77
+ });
74
78
  } catch (err) {
75
79
  return { error: 'Error de lectura: ' + err.message, results: [] };
76
80
  }
81
+ if (!cached) return { error: 'APRENDIZAJES.md no encontrado', results: [] };
77
82
 
78
- const bloques = contenido.split(/^## /m).filter(b => b.trim().length > 0);
83
+ const { bloques } = cached.data;
79
84
  // Los más recientes están al FINAL del archivo (append-only por convención)
80
85
  const recientes = bloques.slice(-limit).reverse();
81
86
 
@@ -159,11 +164,156 @@ function swlInstintosActivos(baseDir, args = {}) {
159
164
  };
160
165
  }
161
166
 
167
+ // ── handler: swl_list_skills ──────────────────────────────────────────────────
168
+
169
+ /**
170
+ * Lista los skills disponibles (nombre + descripción del frontmatter).
171
+ *
172
+ * Sub-fase 9 (v1.5.0). Útil para clientes MCP que no cargan skills filesystem
173
+ * nativamente y necesitan descubrir qué skills existen antes de invocarlos
174
+ * con swl_invoke_skill.
175
+ *
176
+ * Resolución del directorio de skills:
177
+ * 1. Si baseDir tiene `habilidades/`, usar ese (repo SWL como project root).
178
+ * 2. Si baseDir tiene `.claude/skills/`, usar ese (proyecto consumidor con SWL instalado).
179
+ * 3. Si baseDir tiene `.cursor/skills/`, usar ese.
180
+ * 4. Si nada de eso, devolver error.
181
+ *
182
+ * @param {string} baseDir
183
+ * @param {object} args - { dominio?: string, limit?: number }
184
+ */
185
+ function swlListSkills(baseDir, args = {}) {
186
+ const dirSkills = resolverDirSkills(baseDir);
187
+ if (!dirSkills) {
188
+ return { error: 'No se encontró directorio de skills (habilidades/, .claude/skills/, .cursor/skills/)', results: [] };
189
+ }
190
+
191
+ const limit = (typeof args.limit === 'number' && args.limit > 0)
192
+ ? Math.min(args.limit, 500) : 200;
193
+ const dominio = typeof args.dominio === 'string' ? args.dominio.toLowerCase() : null;
194
+
195
+ let nombres;
196
+ try {
197
+ nombres = fs.readdirSync(dirSkills, { withFileTypes: true })
198
+ .filter(d => d.isDirectory() && !d.name.startsWith('.'))
199
+ .map(d => d.name);
200
+ } catch (err) {
201
+ return { error: 'Error leyendo directorio: ' + err.message, results: [] };
202
+ }
203
+
204
+ const results = [];
205
+ for (const nombre of nombres) {
206
+ if (dominio && !nombre.toLowerCase().includes(dominio)) continue;
207
+ const skillMd = path.join(dirSkills, nombre, 'SKILL.md');
208
+ if (!fs.existsSync(skillMd)) continue;
209
+ try {
210
+ const contenido = fs.readFileSync(skillMd, 'utf-8');
211
+ const descMatch = contenido.match(/description:\s*>?\s*\n?\s*(.+?)(?=\n\w+:|\n---)/s);
212
+ const descripcion = descMatch ? descMatch[1].replace(/\s+/g, ' ').trim() : '';
213
+ results.push({
214
+ nombre,
215
+ descripcion: descripcion.length > 300 ? descripcion.slice(0, 297) + '...' : descripcion,
216
+ });
217
+ } catch { /* skill ilegible — saltar */ }
218
+ if (results.length >= limit) break;
219
+ }
220
+
221
+ return { results, count: results.length, total: nombres.length, dir: dirSkills };
222
+ }
223
+
224
+ // ── handler: swl_invoke_skill ─────────────────────────────────────────────────
225
+
226
+ /**
227
+ * Devuelve el contenido completo de un SKILL.md por nombre.
228
+ *
229
+ * Sub-fase 9 (v1.5.0). Útil para clientes MCP donde el cliente NO carga skills
230
+ * filesystem nativamente (Codex CLI en `--local`, Gemini CLI, otros) pero quiere
231
+ * acceder a conocimiento operacional SWL. El cliente recibe el cuerpo completo
232
+ * del SKILL.md y puede usarlo como contexto en su próxima llamada.
233
+ *
234
+ * Limitaciones aceptadas:
235
+ * - NO ejecuta scripts del skill — solo devuelve el SKILL.md como texto.
236
+ * - Si el skill referencia `recursos/X.md`, el cliente debe pedirlo aparte
237
+ * (no implementamos retrieval recursivo en v1.5.0).
238
+ * - El cuerpo se trunca a 100 KB para no saturar el contexto del cliente.
239
+ *
240
+ * @param {string} baseDir
241
+ * @param {object} args - { nombre: string }
242
+ */
243
+ function swlInvokeSkill(baseDir, args) {
244
+ if (!args || typeof args.nombre !== 'string' || !args.nombre.trim()) {
245
+ return { error: 'argumento "nombre" (string) requerido' };
246
+ }
247
+ // Sanitización: solo nombres en kebab-case (prevenir path traversal).
248
+ const nombre = args.nombre.trim();
249
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/i.test(nombre)) {
250
+ return { error: 'Nombre de skill inválido. Solo [a-zA-Z0-9-], 1-63 caracteres.' };
251
+ }
252
+
253
+ const dirSkills = resolverDirSkills(baseDir);
254
+ if (!dirSkills) {
255
+ return { error: 'No se encontró directorio de skills (habilidades/, .claude/skills/, .cursor/skills/)' };
256
+ }
257
+
258
+ const skillMd = path.join(dirSkills, nombre, 'SKILL.md');
259
+ if (!fs.existsSync(skillMd)) {
260
+ return { error: `Skill "${nombre}" no encontrado en ${dirSkills}` };
261
+ }
262
+
263
+ let contenido;
264
+ try {
265
+ contenido = fs.readFileSync(skillMd, 'utf-8');
266
+ } catch (err) {
267
+ return { error: 'Error leyendo SKILL.md: ' + err.message };
268
+ }
269
+
270
+ const MAX = 100 * 1024;
271
+ let truncado = false;
272
+ if (Buffer.byteLength(contenido, 'utf-8') > MAX) {
273
+ contenido = contenido.slice(0, MAX) + '\n\n> NOTA: contenido truncado a 100 KB.';
274
+ truncado = true;
275
+ }
276
+
277
+ // Listar recursos disponibles (sin leerlos) para que el cliente sepa qué pedir.
278
+ const recursos = [];
279
+ const dirRecursos = path.join(dirSkills, nombre, 'recursos');
280
+ if (fs.existsSync(dirRecursos)) {
281
+ try {
282
+ const archivos = fs.readdirSync(dirRecursos)
283
+ .filter(f => /\.(md|json|yaml|yml|txt)$/i.test(f));
284
+ recursos.push(...archivos.map(f => `recursos/${f}`));
285
+ } catch { /* sin recursos */ }
286
+ }
287
+
288
+ return { nombre, contenido, truncado, recursos, fuente: skillMd };
289
+ }
290
+
291
+ /**
292
+ * Resuelve el directorio de skills en el orden de precedencia:
293
+ * 1. baseDir/habilidades/ (repo SWL como project root)
294
+ * 2. baseDir/.claude/skills/ (proyecto consumidor con SWL instalado en Claude)
295
+ * 3. baseDir/.cursor/skills/ (proyecto con SWL instalado en Cursor)
296
+ *
297
+ * Retorna null si ninguno existe.
298
+ */
299
+ function resolverDirSkills(baseDir) {
300
+ const candidatos = [
301
+ path.join(baseDir, 'habilidades'),
302
+ path.join(baseDir, '.claude', 'skills'),
303
+ path.join(baseDir, '.cursor', 'skills'),
304
+ ];
305
+ for (const dir of candidatos) {
306
+ if (fs.existsSync(dir)) return dir;
307
+ }
308
+ return null;
309
+ }
310
+
162
311
  // ── exports ───────────────────────────────────────────────────────────────────
163
312
 
164
313
  const HANDLERS = {
165
314
  swl_memory_search: {
166
315
  description: 'Búsqueda hybrid sobre memoria SWL (aprendizajes + sesiones + instintos) con RRF fusion.',
316
+ schemaVersion: '1.0.0',
167
317
  inputSchema: {
168
318
  type: 'object',
169
319
  properties: {
@@ -177,6 +327,7 @@ const HANDLERS = {
177
327
  },
178
328
  swl_aprendizajes_recientes: {
179
329
  description: 'Últimos N aprendizajes de .planning/APRENDIZAJES.md (más recientes primero).',
330
+ schemaVersion: '1.0.0',
180
331
  inputSchema: {
181
332
  type: 'object',
182
333
  properties: {
@@ -187,6 +338,7 @@ const HANDLERS = {
187
338
  },
188
339
  swl_instintos_activos: {
189
340
  description: 'Instintos con effective_confidence ≥ umbral. Default 0.5.',
341
+ schemaVersion: '1.0.0',
190
342
  inputSchema: {
191
343
  type: 'object',
192
344
  properties: {
@@ -196,6 +348,30 @@ const HANDLERS = {
196
348
  },
197
349
  handler: swlInstintosActivos,
198
350
  },
351
+ swl_list_skills: {
352
+ description: 'Lista skills SWL disponibles (nombre + descripción). Útil para descubrir conocimiento operacional antes de invocar swl_invoke_skill.',
353
+ schemaVersion: '1.0.0',
354
+ inputSchema: {
355
+ type: 'object',
356
+ properties: {
357
+ dominio: { type: 'string', description: 'Filtro substring por nombre (opcional)' },
358
+ limit: { type: 'number', description: 'Máximo (default 200, max 500)' },
359
+ },
360
+ },
361
+ handler: swlListSkills,
362
+ },
363
+ swl_invoke_skill: {
364
+ description: 'Devuelve el SKILL.md completo de un skill SWL por nombre. Para clientes MCP que no cargan skills filesystem nativamente — el cliente recibe el cuerpo y lo usa como contexto.',
365
+ schemaVersion: '1.0.0',
366
+ inputSchema: {
367
+ type: 'object',
368
+ properties: {
369
+ nombre: { type: 'string', description: 'Nombre del skill (kebab-case, ej. fastapi-experto)' },
370
+ },
371
+ required: ['nombre'],
372
+ },
373
+ handler: swlInvokeSkill,
374
+ },
199
375
  };
200
376
 
201
377
  module.exports = {
@@ -203,4 +379,8 @@ module.exports = {
203
379
  swlMemorySearch,
204
380
  swlAprendizajesRecientes,
205
381
  swlInstintosActivos,
382
+ swlListSkills,
383
+ swlInvokeSkill,
384
+ // Expuesto para tests — permite invalidar el cache singleton entre runs.
385
+ _cache: cache,
206
386
  };
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Telemetría opt-in del swl-mcp-server v1.0.0 (ADR-0019 Sub-fase 3).
5
+ *
6
+ * Cuando `SWL_MCP_METRICS=1` está activo, cada `tools/call` se registra en
7
+ * `.planning/evolucion/mcp-metrics.jsonl` con:
8
+ * { ts, tool, durationMs, ok, error?, baseDir }
9
+ *
10
+ * Defaults:
11
+ * - SWL_MCP_METRICS no set → no se persiste nada (zero-cost).
12
+ * - Se usa append-only JSONL (alta frecuencia → atomicWriteJSON sería ineficiente).
13
+ * - Si el filesystem no permite escritura (p.ej. baseDir read-only), el error
14
+ * se traga silenciosamente — la telemetría NUNCA debe romper el server.
15
+ *
16
+ * Zero-deps.
17
+ *
18
+ * @module scripts/mcp-server/telemetry
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ /**
25
+ * Construye un grabador de telemetría desde el entorno actual.
26
+ *
27
+ * @param {object} [opciones]
28
+ * @param {string} opciones.baseDir - Directorio raíz del proyecto SWL (donde está .planning/).
29
+ * @param {object} [opciones.env] - Sustituible para tests.
30
+ * @returns {{ habilitada: boolean, registrar: Function, ruta: string|null }}
31
+ */
32
+ function construirTelemetria(opciones = {}) {
33
+ const env = opciones.env || process.env;
34
+ const habilitada = env.SWL_MCP_METRICS === '1' || env.SWL_MCP_METRICS === 'true';
35
+ const baseDir = opciones.baseDir;
36
+
37
+ if (!habilitada || !baseDir) {
38
+ return {
39
+ habilitada: false,
40
+ registrar: () => {}, // noop
41
+ ruta: null,
42
+ };
43
+ }
44
+
45
+ const dirEvolucion = path.join(baseDir, '.planning', 'evolucion');
46
+ const ruta = path.join(dirEvolucion, 'mcp-metrics.jsonl');
47
+
48
+ // Asegurar que el directorio existe — si falla, deshabilitamos silenciosamente.
49
+ let escribible = true;
50
+ try {
51
+ fs.mkdirSync(dirEvolucion, { recursive: true });
52
+ } catch (err) {
53
+ escribible = false;
54
+ }
55
+
56
+ return {
57
+ habilitada: escribible,
58
+ registrar: (evento) => {
59
+ if (!escribible) return;
60
+ try {
61
+ const linea = JSON.stringify({
62
+ ts: new Date().toISOString(),
63
+ ...evento,
64
+ }) + '\n';
65
+ fs.appendFileSync(ruta, linea, { encoding: 'utf-8' });
66
+ } catch {
67
+ // Telemetría nunca debe romper el server — error silencioso.
68
+ // Tras 5 errores consecutivos podríamos deshabilitar, pero no
69
+ // implementamos eso hasta que aparezca como problema real.
70
+ }
71
+ },
72
+ ruta,
73
+ };
74
+ }
75
+
76
+ module.exports = {
77
+ construirTelemetria,
78
+ };