@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,128 +1,170 @@
1
- # swl-mcp-server — STUB EXPERIMENTAL
2
-
3
- > **NO USAR EN PRODUCCIÓN**. Este es un stub experimental que demuestra
4
- > el patrón de exponer la memoria de swl-ses a clientes MCP externos. La
5
- > implementación completa requiere trabajo adicional (auth, observabilidad,
6
- > tests de integración, schema migration). Ver sección "Limitaciones" más
7
- > abajo.
8
-
9
- ## Qué hace
10
-
11
- `bin/swl-mcp-server.js` es un servidor MCP en modo stdio que expone 3
12
- endpoints de solo lectura:
13
-
14
- 1. **`swl_memory_search`** búsqueda hybrid sobre memoria SWL
15
- (aprendizajes + sesiones + instintos) usando `hooks/lib/memory-search`
16
- con RRF fusion.
17
- 2. **`swl_aprendizajes_recientes`** — últimos N aprendizajes de
18
- `.planning/APRENDIZAJES.md`.
19
- 3. **`swl_instintos_activos`** — instintos con `effective_confidence ≥
20
- umbral`.
21
-
22
- El server lee el estado file-based de swl-ses tal como existe en `cwd`
23
- (o el directorio especificado por `SWL_MCP_BASE_DIR`). NO escribe solo
24
- lectura.
25
-
26
- ## Cómo arrancar (para testing)
27
-
28
- ```bash
29
- # Modo standalone (smoke test)
30
- echo '{"jsonrpc":"2.0","id":1,"method":"initialize"}' | node bin/swl-mcp-server.js
31
-
32
- # Output esperado en stdout:
33
- # {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":false}},"serverInfo":{"name":"swl-mcp-server","version":"0.1.0-experimental"}}}
34
-
35
- # Listar herramientas
36
- echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | node bin/swl-mcp-server.js
37
-
38
- # Buscar memoria
39
- echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"swl_memory_search","arguments":{"query":"RRF fusion","limit":3}}}' | node bin/swl-mcp-server.js
40
- ```
41
-
42
- ## Cómo configurar en clientes MCP (NO recomendado en producción)
43
-
44
- ### Cursor (~/.cursor/mcp.json)
45
-
46
- ```json
47
- {
48
- "mcpServers": {
49
- "swl-memory": {
50
- "command": "node",
51
- "args": ["/ruta/absoluta/a/swl-ses/bin/swl-mcp-server.js"],
52
- "env": {
53
- "SWL_MCP_BASE_DIR": "/ruta/al/proyecto/que/quiero/recuperar"
54
- }
55
- }
56
- }
57
- }
58
- ```
59
-
60
- ### Gemini CLI
61
-
62
- Similar, agregando el server a la config del cliente que soporte MCP stdio.
63
-
64
- ### Claude Code (NO necesario)
65
-
66
- Claude Code ya tiene acceso directo a los archivos de swl-ses dentro de
67
- su propio runtime. NO usar el MCP server desde Claude Code en el mismo
68
- proyecto — sería redundante y agregaría latencia.
69
-
70
- ## Limitaciones (lo que NO se hace en este stub)
71
-
72
- | Limitación | Impacto | Cuándo se debe arreglar |
73
- |---|---|---|
74
- | **Sin auth** | Cualquier proceso con acceso al stdio puede leer toda la memoria | Antes de exponer en redes públicas o multi-usuario |
75
- | **Sin rate limiting** | Cliente malicioso/buggy puede saturar lectura de archivos | Cuando se observen ≥1 incidentes de saturación |
76
- | **Sin HTTP transport** | Solo stdio; no se puede conectar remotamente | Cuando el caso de uso requiera servidor de red |
77
- | **Sin tests de integración** | Solo smoke tests manuales | Antes de v1.0 del MCP server |
78
- | **Sin observabilidad / métricas** | Logs JSON a stderr son lo único que hay | Cuando se use en >1 cliente simultáneo |
79
- | **Sin hot-reload** | Cambios en swl-ses no se reflejan hasta restart del server | Ya — el server lee files en cada call, así que SÍ se reflejan; documentado por completitud |
80
- | **Sin caching** | Cada call lee files de disco | Cuando latencia sea problema (~10ms hoy) |
81
- | **Sin schema versioning** | Si cambia formato de APRENDIZAJES.md, los handlers pueden romper | Cuando se introduzca breaking change en el formato |
82
- | **Sin support de resources/prompts** | Solo tools | Cuando el caso de uso lo demande |
83
- | **Sin paginación** | Resultados grandes se truncan a `limit` | Cuando se requiera browse de >50 entries |
84
- | **Single-tenant** | Asume un solo proyecto por instancia | Multi-tenancy necesita rediseño |
85
-
86
- ## Trigger para implementación completa
87
-
88
- **Hoy**: 0 instalaciones reportadas. Mantener como stub.
89
-
90
- **Trigger para invertir esfuerzo en implementación robusta**: el usuario
91
- reporta uso real consistente de ≥2 runtimes distintos (Cursor + Claude
92
- Code, o Gemini + Claude Code, etc.) sobre el mismo proyecto SWL durante
93
- ≥1 mes. Sin esto, la inversión de ~25 horas en hardening del server
94
- no se justifica.
95
-
96
- ## Diseño futuro (cuando se implemente completo)
97
-
98
- 1. **Auth**: API key estática + bearer token con scopes:
99
- - `swl:memory:read` (búsqueda y lectura)
100
- - `swl:memory:write` (crear aprendizajes desde MCP — requiere validación)
101
- - `swl:instintos:write` (modificar confidence — alto riesgo)
102
- 2. **HTTP transport opcional**: además de stdio, ofrecer servidor HTTP/SSE
103
- con TLS y CORS configurable.
104
- 3. **Telemetría**: requests por handler, latencia p50/p95, errores por
105
- tipo. Persistir en `.planning/evolucion/mcp-metrics.jsonl`.
106
- 4. **Caching invalidable**: caché en memoria de las lecturas de
107
- APRENDIZAJES.md / instintos con `mtime`-based invalidation.
108
- 5. **Schema versioning**: cada handler declara `schema_version`. El
109
- cliente puede pedir un version range. Breaking changes bumpan major.
110
- 6. **Tests de integración**: arrancar el server contra una fixture y
111
- ejecutar 50+ scenarios. Smoke en CI.
112
-
113
- ## Estado de seguridad (auditoría rápida del stub)
114
-
115
- - NO expone credenciales ni archivos fuera de `baseDir`.
116
- - NO ejecuta código (solo lee files y devuelve JSON).
117
- - ✓ NO modifica archivos.
118
- - NO valida que `baseDir` sea un proyecto SWL válido — un cliente
119
- podría apuntarlo a un directorio arbitrario y leer cualquier
120
- archivo `*.md` que llamemos `APRENDIZAJES.md`.
121
- - ✗ NO sanitiza queries de búsqueda (los regex en `instintos.yaml` parser
122
- son seguros, pero falta hardening).
123
- - NO hay timeout un proyecto enorme con miles de sesiones podría
124
- hacer colgar el server.
125
-
126
- Estos puntos son ACEPTABLES para un stub experimental usado por el
127
- mantenedor en un proyecto propio. NO ACEPTABLES para uso multi-usuario
128
- o expuesto a la red.
1
+ # swl-mcp-server v1.0.0
2
+
3
+ Servidor MCP de solo lectura para exponer la memoria de swl-ses
4
+ (aprendizajes, sesiones, instintos) a clientes MCP externos como Cursor,
5
+ Codex CLI y Gemini CLI.
6
+
7
+ Promovido de stub experimental (v0.1.x) a v1.0.0 en ADR-0019 Sub-fase 3.
8
+
9
+ ## Qué hace
10
+
11
+ Expone 5 endpoints sobre stdio JSON-RPC:
12
+
13
+ 1. **`swl_memory_search`** — búsqueda hybrid sobre memoria SWL
14
+ (aprendizajes + sesiones + instintos) usando `hooks/lib/memory-search`
15
+ con RRF fusion.
16
+ 2. **`swl_aprendizajes_recientes`** — últimos N aprendizajes de
17
+ `.planning/APRENDIZAJES.md`.
18
+ 3. **`swl_instintos_activos`** — instintos con `effective_confidence ≥
19
+ umbral`.
20
+ 4. **`swl_list_skills`** (Sub-fase 9 v1.5.0) — lista skills SWL
21
+ disponibles con nombre + descripción del frontmatter. Útil para
22
+ descubrir conocimiento operacional antes de invocar uno.
23
+ 5. **`swl_invoke_skill`** (Sub-fase 9 v1.5.0) devuelve el SKILL.md
24
+ completo de un skill por nombre. Para clientes MCP que no cargan
25
+ skills filesystem nativamente (Codex `--local`, Gemini CLI, otros) —
26
+ el cliente recibe el cuerpo del SKILL.md como texto y lo usa como
27
+ contexto en su próxima llamada.
28
+
29
+ El server lee el estado file-based de swl-ses tal como existe en `cwd`
30
+ (o el directorio especificado por `SWL_MCP_BASE_DIR`). NO escribe — solo
31
+ lectura. NO ejecuta scripts referenciados desde un skill.
32
+
33
+ ### Resolución del directorio de skills
34
+
35
+ Para `swl_list_skills` y `swl_invoke_skill`, el server busca skills en
36
+ el primer directorio que exista, en este orden:
37
+
38
+ 1. `<baseDir>/habilidades/` — repo SWL como project root.
39
+ 2. `<baseDir>/.claude/skills/` proyecto consumidor con SWL instalado
40
+ en Claude Code.
41
+ 3. `<baseDir>/.cursor/skills/` — proyecto con SWL instalado en Cursor.
42
+
43
+ ## Features v1.0.0
44
+
45
+ | Feature | Cómo activar | Default |
46
+ |---|---|---|
47
+ | Auth opt-in (Bearer en `params._auth`) | `SWL_MCP_API_KEY=<token>` en el env del server | sin auth (backward-compat) |
48
+ | Caching mtime-based con TTL | Siempre activo | 60 segundos |
49
+ | Override TTL del cache | `SWL_MCP_CACHE_TTL_MS=<ms>` | 60000 |
50
+ | Telemetría JSONL por call | `SWL_MCP_METRICS=1` | sin telemetría |
51
+ | Schema versioning por handler | Siempre presente en `tools/list` | `1.0.0` |
52
+
53
+ ## Cómo arrancar (testing)
54
+
55
+ ```bash
56
+ # Smoke standalone
57
+ echo '{"jsonrpc":"2.0","id":1,"method":"initialize"}' | node bin/swl-mcp-server.js
58
+
59
+ # Output esperado (sin auth):
60
+ # {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{...},"serverInfo":{"name":"swl-mcp-server","version":"1.0.0","authRequired":false,"telemetryEnabled":false}}}
61
+
62
+ # Con auth opt-in
63
+ SWL_MCP_API_KEY="secret-123" node bin/swl-mcp-server.js
64
+
65
+ # Cliente debe enviar params._auth:
66
+ echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"_auth":"secret-123","name":"swl_aprendizajes_recientes","arguments":{"limit":5}}}' | SWL_MCP_API_KEY=secret-123 node bin/swl-mcp-server.js
67
+ ```
68
+
69
+ ## Configuración en clientes MCP
70
+
71
+ ### Cursor (autoconfig disponible)
72
+
73
+ Desde v1.5.0 el instalador puede registrar el server automáticamente:
74
+
75
+ ```bash
76
+ npx @saulwade/swl-ses@latest install --target cursor --with-mcp
77
+ ```
78
+
79
+ Esto genera `.cursor/mcp.json` (per-proyecto) o `~/.cursor/mcp.json`
80
+ (con `--global`) preservando otros mcpServers configurados.
81
+
82
+ Configuración manual:
83
+
84
+ ```json
85
+ {
86
+ "mcpServers": {
87
+ "swl-memory": {
88
+ "command": "node",
89
+ "args": ["/path/to/swl-ses/bin/swl-mcp-server.js"],
90
+ "env": {
91
+ "SWL_MCP_BASE_DIR": "/path/to/proyecto",
92
+ "SWL_MCP_API_KEY": "opcional-token-secreto"
93
+ }
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ ### Codex CLI (autoconfig disponible)
100
+
101
+ ```bash
102
+ npx @saulwade/swl-ses@latest install --target codex --global --with-mcp
103
+ ```
104
+
105
+ Esto inserta `[mcp_servers.swl-memory]` en `~/.codex/config.toml`
106
+ preservando otros servers configurados. Verificar con `codex mcp list`.
107
+
108
+ ### Gemini CLI
109
+
110
+ Manualmente similar a Cursor el server expone stdio JSON-RPC estándar.
111
+
112
+ ### Claude Code (NO necesario)
113
+
114
+ Claude Code ya tiene acceso directo a los archivos de swl-ses dentro de
115
+ su propio runtime. NO usar el MCP server desde Claude Code en el mismo
116
+ proyecto sería redundante.
117
+
118
+ ## Cambios v0.1.x v1.0.0
119
+
120
+ | Aspecto | v0.1.x (stub) | v1.0.0 |
121
+ |---|---|---|
122
+ | Auth | sin auth | opt-in con `SWL_MCP_API_KEY` + Bearer en `params._auth` |
123
+ | Caching | sin caching | mtime-based con TTL configurable |
124
+ | Telemetría | logs stderr | JSONL en `.planning/evolucion/mcp-metrics.jsonl` (opt-in) |
125
+ | Schema versioning | implícito | `_schemaVersion` en cada tool de `tools/list` |
126
+ | Tests | smoke manual | 38 tests unitarios (`tests/mcp-server/*.test.js`) |
127
+ | Estado | "NO usar en producción" | apto para los proyectos del usuario |
128
+
129
+ ## Backward compatibility
130
+
131
+ v1.0.0 es 100% backward-compatible con clientes que conectan sin auth.
132
+ Si `SWL_MCP_API_KEY` no está set en el env del server, el comportamiento
133
+ es idéntico al stub v0.1.x — no se requiere ningún cambio en clientes
134
+ configurados antes.
135
+
136
+ ## Diseño futuro (cuando aparezca demanda real)
137
+
138
+ - **HTTP transport opcional**: además de stdio, ofrecer servidor HTTP/SSE
139
+ con TLS y CORS configurable.
140
+ - **Handlers de escritura** (`swl:memory:write`, `swl:instintos:write`)
141
+ fuera de scope v1.0 — requerirían scopes en el token y validación
142
+ semántica de cada mutación.
143
+ - **Rate limiting** por API key cuando aparezca un caso multi-cliente.
144
+ - **OpenTelemetry** para latencias p95 distribuidas si el server se
145
+ consume desde múltiples editores en paralelo.
146
+
147
+ ## Estado de seguridad
148
+
149
+ - ✓ Read-only — no ejecuta código ni modifica archivos.
150
+ - ✓ Auth opt-in con comparación constante para defender contra timing
151
+ attacks.
152
+ - ✓ Caching con invalidación correcta (mtime + TTL).
153
+ - ✓ Telemetría con error silencioso — nunca rompe el server.
154
+ - ✗ `baseDir` no se valida contra patrón "proyecto SWL válido". Un
155
+ cliente con acceso al stdio puede apuntarlo a cualquier directorio
156
+ con `APRENDIZAJES.md`. Mitigación: el operador configura el server
157
+ con `SWL_MCP_BASE_DIR` explícito.
158
+ - ✗ Sin límite de tamaño de query — un cliente malicioso podría enviar
159
+ una query gigante. Mitigación parcial: `limit` está clampeado a 50/100.
160
+
161
+ Para casos de uso single-user en máquinas confiables del operador, los
162
+ puntos restantes son aceptables. Para uso multi-usuario o exposición a
163
+ red, evaluar primero el alcance del riesgo y considerar las features
164
+ de "diseño futuro" arriba.
165
+
166
+ ## Referencias
167
+
168
+ - ADR-0019 Sub-fase 3: `.planning/adrs/0019-integracion-codex-y-cursor-completa.md`.
169
+ - Tests: `tests/mcp-server/*.test.js` (auth, cache, telemetry, rutear).
170
+ - Configuración Cursor: `docs/MCP-SERVER-CURSOR-NOTAS.md`.
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Auth opt-in para swl-mcp-server v1.0.0 (ADR-0019 Sub-fase 3).
5
+ *
6
+ * El stub experimental v0.1.x corría sin auth. Cualquier proceso con acceso al
7
+ * stdio podía leer toda la memoria SWL. Esa decisión era aceptable solo en
8
+ * single-user local — no en multi-usuario, no expuesto a red, no compartido.
9
+ *
10
+ * v1.0.0 introduce auth **opt-in**:
11
+ * - Si `SWL_MCP_API_KEY` NO está definida en el entorno del server, el comportamiento
12
+ * es idéntico al stub (sin auth). Compatibilidad total con clientes existentes.
13
+ * - Si `SWL_MCP_API_KEY` ESTÁ definida, cada `tools/call` debe incluir
14
+ * `params._auth = "<api-key>"`. Sin el token o con token incorrecto, se devuelve
15
+ * `-32001 Unauthorized`.
16
+ *
17
+ * Decisión deliberada de NO usar header `Authorization: Bearer ...`:
18
+ * - El protocolo MCP sobre stdio NO transporta headers HTTP.
19
+ * - Inventar un campo en `initialize.capabilities` específico para swl-ses
20
+ * rompería la spec del protocolo.
21
+ * - `_auth` como campo en `params` es el patrón de menor fricción.
22
+ *
23
+ * Limitaciones aceptadas:
24
+ * - Token estático sin rotación automática — el operador rota manualmente
25
+ * cambiando la env var del proceso.
26
+ * - Sin scopes (read/write) porque v1 sigue siendo read-only.
27
+ *
28
+ * @module scripts/mcp-server/auth
29
+ */
30
+
31
+ const ENV_VAR_NAME = 'SWL_MCP_API_KEY';
32
+ const ERROR_CODE_UNAUTHORIZED = -32001;
33
+ const ERROR_CODE_FORBIDDEN = -32002;
34
+
35
+ /**
36
+ * Construye un validador de auth desde el entorno actual.
37
+ *
38
+ * Pattern de "construct once, use many": leer la env var una sola vez al arranque
39
+ * y devolver una función pura que valida cada request. Evita race conditions con
40
+ * tests que mutan process.env.
41
+ *
42
+ * @param {object} [opciones] - { env: NodeJS.ProcessEnv } sustituible para tests.
43
+ * @returns {{ requerida: boolean, validar: (request: object) => { ok: boolean, code?: number, message?: string } }}
44
+ */
45
+ function construirValidador(opciones = {}) {
46
+ const env = opciones.env || process.env;
47
+ const apiKey = env[ENV_VAR_NAME];
48
+ const requerida = typeof apiKey === 'string' && apiKey.length > 0;
49
+
50
+ return {
51
+ requerida,
52
+ validar: (request) => {
53
+ if (!requerida) return { ok: true };
54
+ // Solo validar tools/call — initialize, ping, tools/list son metadata pública.
55
+ const metodo = request && request.method;
56
+ if (metodo !== 'tools/call') return { ok: true };
57
+
58
+ const params = request.params || {};
59
+ const tokenCliente = params._auth;
60
+
61
+ if (typeof tokenCliente !== 'string' || tokenCliente.length === 0) {
62
+ return {
63
+ ok: false,
64
+ code: ERROR_CODE_UNAUTHORIZED,
65
+ message: 'swl-mcp-server requiere autenticación: SWL_MCP_API_KEY está configurada en el server. El cliente debe enviar params._auth con el API key.',
66
+ };
67
+ }
68
+ if (!comparacionConstante(tokenCliente, apiKey)) {
69
+ return {
70
+ ok: false,
71
+ code: ERROR_CODE_FORBIDDEN,
72
+ message: 'Token inválido.',
73
+ };
74
+ }
75
+ return { ok: true };
76
+ },
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Comparación de strings en tiempo constante para evitar timing attacks
82
+ * sobre el API key. Implementación zero-deps simple.
83
+ *
84
+ * Si las longitudes difieren, igual se itera la longitud máxima para no filtrar
85
+ * información sobre el largo del secreto vía duración de la comparación.
86
+ */
87
+ function comparacionConstante(a, b) {
88
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
89
+ const len = Math.max(a.length, b.length);
90
+ let diff = a.length ^ b.length;
91
+ for (let i = 0; i < len; i++) {
92
+ const ca = i < a.length ? a.charCodeAt(i) : 0;
93
+ const cb = i < b.length ? b.charCodeAt(i) : 0;
94
+ diff |= ca ^ cb;
95
+ }
96
+ return diff === 0;
97
+ }
98
+
99
+ module.exports = {
100
+ construirValidador,
101
+ comparacionConstante,
102
+ ENV_VAR_NAME,
103
+ ERROR_CODE_UNAUTHORIZED,
104
+ ERROR_CODE_FORBIDDEN,
105
+ };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cache mtime-based para handlers del swl-mcp-server v1.0.0 (ADR-0019 Sub-fase 3).
5
+ *
6
+ * El stub experimental leía disco en cada call (latencia medida ~10ms con
7
+ * APRENDIZAJES.md de 1172 líneas). Para datasets más grandes (>10k líneas) o
8
+ * múltiples clientes concurrentes, el caching reduce sustancialmente la carga.
9
+ *
10
+ * Patrón:
11
+ * - Key = path absoluto del archivo.
12
+ * - Value = { mtime, contenido, parsed }.
13
+ * - Invalidación: si fs.stat().mtime > value.mtime → recargar y reparsear.
14
+ * - TTL opcional vía `SWL_MCP_CACHE_TTL_MS` (default 60000 ms): aunque mtime no
15
+ * haya cambiado, recargar tras TTL para defensa contra clock skew o ediciones
16
+ * que no actualizan mtime (raras pero posibles en filesystems exóticos).
17
+ *
18
+ * Zero-deps. NO usar para escritura — sigue read-only.
19
+ *
20
+ * @module scripts/mcp-server/cache
21
+ */
22
+
23
+ const fs = require('fs');
24
+
25
+ const DEFAULT_TTL_MS = 60 * 1000;
26
+
27
+ /**
28
+ * Crea una instancia de cache mtime-based.
29
+ *
30
+ * @param {object} [opciones]
31
+ * @param {number} [opciones.ttlMs] - TTL en milisegundos. Default 60000.
32
+ * @param {object} [opciones.env] - Para leer SWL_MCP_CACHE_TTL_MS en tests.
33
+ * @returns {{ get: Function, invalidate: Function, stats: Function, _store: Map }}
34
+ */
35
+ function crearCache(opciones = {}) {
36
+ const env = opciones.env || process.env;
37
+ const ttlMs = opciones.ttlMs !== undefined
38
+ ? opciones.ttlMs
39
+ : (parseInt(env.SWL_MCP_CACHE_TTL_MS, 10) || DEFAULT_TTL_MS);
40
+
41
+ const store = new Map();
42
+ const stats = { hits: 0, misses: 0, invalidations: 0 };
43
+
44
+ /**
45
+ * Obtiene el valor cacheado para `path`. Si el archivo cambió (mtime) o el TTL
46
+ * expiró, vuelve a leer y parsear con `parser(contenido, path)`.
47
+ *
48
+ * @param {string} ruta - Path absoluto del archivo a cachear.
49
+ * @param {(contenido: string, ruta: string) => any} parser - Función que transforma
50
+ * el contenido del archivo en el objeto a cachear. DEBE ser pura.
51
+ * @returns {{ data: any, hit: boolean } | null} null si el archivo no existe.
52
+ */
53
+ function get(ruta, parser) {
54
+ let stat;
55
+ try {
56
+ stat = fs.statSync(ruta);
57
+ } catch (err) {
58
+ if (err.code === 'ENOENT') return null;
59
+ throw err;
60
+ }
61
+
62
+ const mtimeMs = stat.mtimeMs;
63
+ const ahora = Date.now();
64
+ const cached = store.get(ruta);
65
+
66
+ if (cached && cached.mtimeMs === mtimeMs && (ahora - cached.cargadoEn) < ttlMs) {
67
+ stats.hits++;
68
+ return { data: cached.data, hit: true };
69
+ }
70
+
71
+ if (cached) stats.invalidations++;
72
+ else stats.misses++;
73
+
74
+ const contenido = fs.readFileSync(ruta, 'utf-8');
75
+ const data = parser(contenido, ruta);
76
+ store.set(ruta, { mtimeMs, cargadoEn: ahora, data });
77
+ return { data, hit: false };
78
+ }
79
+
80
+ /**
81
+ * Invalida una entrada o todo el cache.
82
+ * @param {string} [ruta] - Si se omite, vacía todo.
83
+ */
84
+ function invalidate(ruta) {
85
+ if (ruta === undefined) {
86
+ const n = store.size;
87
+ store.clear();
88
+ stats.invalidations += n;
89
+ return n;
90
+ }
91
+ if (store.delete(ruta)) stats.invalidations++;
92
+ return 0;
93
+ }
94
+
95
+ return {
96
+ get,
97
+ invalidate,
98
+ stats: () => ({ ...stats, size: store.size, ttlMs }),
99
+ _store: store, // exposed para tests
100
+ };
101
+ }
102
+
103
+ module.exports = {
104
+ crearCache,
105
+ DEFAULT_TTL_MS,
106
+ };