agentic-kdd 3.5.6 → 3.5.8

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.
package/mem-curator.cjs CHANGED
@@ -1,584 +1,361 @@
1
+ 'use strict';
1
2
  /**
2
- * Agentic KDD — MemCurator v1.0
3
- *
4
- * Agente de gobernanza autónoma de memoria. Cierra múltiples gaps del reporte:
5
- *
6
- * TTL enforcement: episodios > 30 días → archivo/compresión
7
- * ✅ Deduplicación semántica: nodos similares > umbral → merge
8
- * ✅ Resolución de conflictos: reglas contradictorias → supersesión explícita
9
- * ✅ Scoring de relevancia ponderado: S(k) = cos_sim × exp(-λ×Δt) × log(1+n_accesos)
10
- * ✅ MemCurator independiente: desvinculado del generador de código (evita sesgo de autovalidación)
11
- * ✅ Prevención de ruido semántico: "envenenamiento por redundancia" controlado
12
- *
13
- * El curador corre automáticamente cada 10 ciclos (hookeado en grafo.cjs sync).
14
- * También se puede correr manualmente: node mem-curator.cjs run
15
- *
16
- * Basado en:
17
- * - arXiv 2603.17787 "Governed Memory: A Production Architecture for Multi-Agent Workflows"
18
- * - Zep/Graphiti temporal graph (arXiv 2501.13956)
19
- * - Mem0 atomic fact extraction pattern
20
- *
3
+ * Agentic KDD — Memory Curator v2.0
4
+ * Curación automática real: deduplicación, scoring por relevancia, expiración.
5
+ *
6
+ * Principio: 30 entradas precisas > 300 entradas ruidosas.
7
+ *
21
8
  * Uso:
22
- * node .agentic/grafo/mem-curator.cjs run curation completa
23
- * node .agentic/grafo/mem-curator.cjs ttl — solo TTL enforcement
24
- * node .agentic/grafo/mem-curator.cjs dedup solo deduplicación
25
- * node .agentic/grafo/mem-curator.cjs conflicts — solo conflictos
26
- * node .agentic/grafo/mem-curator.cjs score — recalcular scores de todos los nodos
27
- * node .agentic/grafo/mem-curator.cjs report — reporte sin cambios
9
+ * node .agentic/grafo/mem-curator.cjs run cura completa
10
+ * node .agentic/grafo/mem-curator.cjs report → solo reporte, no modifica
11
+ * node .agentic/grafo/mem-curator.cjs dedup solo deduplicar
12
+ * node .agentic/grafo/mem-curator.cjs score → solo recalcular scores
13
+ * node .agentic/grafo/mem-curator.cjs expire → solo expirar entradas viejas
28
14
  */
29
15
 
30
- 'use strict';
31
-
32
- const path = require('path');
33
16
  const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ const ROOT = process.cwd();
20
+ const MEMORIA_DIR = path.join(ROOT, '.agentic', 'memoria');
21
+ const ERRORES_FILE = path.join(MEMORIA_DIR, 'errores.md');
22
+ const PATRONES_FILE= path.join(MEMORIA_DIR, 'patrones.md');
23
+ const DECISIONES_FILE = path.join(MEMORIA_DIR, 'decisiones.md');
24
+
25
+ // ── Configuración de curación ───────────────────────────────────────────────
26
+
27
+ const CONFIG = {
28
+ // Días sin referencias antes de marcar como candidato a expiración
29
+ EXPIRY_DAYS_LOW_SCORE: 90,
30
+ EXPIRY_DAYS_HIGH_SCORE: 365,
31
+ // Número máximo de entradas por archivo antes de forzar curación
32
+ MAX_ENTRIES_ERRORS: 50,
33
+ MAX_ENTRIES_PATTERNS: 40,
34
+ MAX_ENTRIES_DECISIONS:30,
35
+ // Umbral de similitud para considerar duplicado (0-1)
36
+ SIMILARITY_THRESHOLD: 0.75,
37
+ };
34
38
 
35
- // ─── CONSTANTES ───────────────────────────────────────────────────────────────
39
+ // ── Parser de archivos de memoria ───────────────────────────────────────────
36
40
 
37
- const TTL_EPISODIC_DAYS = 30; // Episodios: retención máxima en caliente
38
- const TTL_WORKING_DAYS = 7; // Working memory: expira rápido
39
- const LAMBDA_DECAY = 0.05; // Constante de decay temporal (λ)
40
- const DEDUP_THRESHOLD = 0.92; // Similitud coseno > 92% → duplicado
41
- const CONFLICT_THRESHOLD = 0.85; // Similitud alta + contenido contradictorio
42
- const MAX_SEMANTIC_NODES = 1000; // Límite de nodos procedurales activos
43
- const MIN_UTILITY_RATIO = 0.15; // util/aplicado < 15% → candidato a deprecar
44
- const CURATOR_LOG_PATH = '.agentic/curator.log';
41
+ function parseMemoryFile(filePath) {
42
+ if (!fs.existsSync(filePath)) return [];
45
43
 
46
- // ─── DB HELPER ────────────────────────────────────────────────────────────────
44
+ const content = fs.readFileSync(filePath, 'utf8');
45
+ const entries = [];
47
46
 
48
- function openDB(projectRoot) {
49
- const dbPath = path.join(projectRoot, '.agentic/memoria.db');
50
- try { return new (require('better-sqlite3'))(dbPath); } catch {}
51
- try { const { DatabaseSync } = require('node:sqlite'); return new DatabaseSync(dbPath); } catch {}
52
- throw new Error('No SQLite driver disponible');
53
- }
47
+ // Detectar entradas por bloques --- o por encabezados ###
48
+ const blocks = content.split(/\n(?=###\s|---\s*\n###)/);
54
49
 
55
- function log(projectRoot, msg) {
56
- const logPath = path.join(projectRoot, CURATOR_LOG_PATH);
57
- const entry = `[${new Date().toISOString()}] ${msg}\n`;
58
- try { fs.appendFileSync(logPath, entry); } catch {}
59
- console.log(`[CURATOR] ${msg}`);
60
- }
50
+ for (const block of blocks) {
51
+ const titleMatch = block.match(/###\s+(.+)/);
52
+ if (!titleMatch) continue;
61
53
 
62
- // ─── SCORING DE RELEVANCIA PONDERADO ─────────────────────────────────────────
63
- /**
64
- * Fórmula del reporte (arXiv 2603.17787):
65
- * S(k) = cosine_sim(k, query) × exp( × Δt_days) × log(1 + n_accesos)
66
- *
67
- * Para nodos sin query (relevancia absoluta):
68
- * S(k) = confidence_weight × exp( × Δt_days) × log(1 + n_accesos)
69
- */
70
- function computeRelevanceScore(node, queryEmbedding, cosineFn) {
71
- const now = Date.now();
72
-
73
- // Δt en días desde última actualización
74
- const lastUpdate = node.fecha_update || node.fecha_creacion || new Date().toISOString();
75
- const deltaDays = (now - new Date(lastUpdate).getTime()) / (1000 * 60 * 60 * 24);
76
-
77
- // Decay temporal exponencial
78
- const decayFactor = Math.exp(-LAMBDA_DECAY * deltaDays);
79
-
80
- // Frecuencia de uso
81
- const usageScore = Math.log(1 + (node.aplicado || 0));
82
-
83
- // Similitud semántica (si hay query embedding disponible)
84
- let simScore = 1.0;
85
- if (queryEmbedding && node.embedding && cosineFn) {
86
- try {
87
- const nodeEmbed = typeof node.embedding === 'string'
88
- ? JSON.parse(node.embedding) : node.embedding;
89
- simScore = cosineFn(queryEmbedding, nodeEmbed);
90
- } catch {}
91
- } else {
92
- // Sin embedding: usar confianza como proxy de similitud
93
- const confMap = { 'ALTA': 0.9, 'MEDIA': 0.6, 'BAJA': 0.3 };
94
- simScore = confMap[node.confianza] || 0.5;
54
+ const entry = {
55
+ title: titleMatch[1].trim(),
56
+ raw: block,
57
+ confidence: extractField(block, 'confianza') || extractField(block, 'confidence') || 'MEDIA',
58
+ date: extractField(block, 'fecha') || extractField(block, 'date') || null,
59
+ references: parseInt(extractField(block, 'referencias') || '0'),
60
+ module: extractField(block, 'módulo') || extractField(block, 'module') || 'global',
61
+ score: 0,
62
+ };
63
+
64
+ entry.score = computeScore(entry);
65
+ entries.push(entry);
95
66
  }
96
67
 
97
- const score = simScore * decayFactor * (1 + usageScore * 0.1);
98
-
99
- return {
100
- node_id: node.id,
101
- titulo: node.titulo,
102
- score: Math.round(score * 1000) / 1000,
103
- components: {
104
- similarity: Math.round(simScore * 1000) / 1000,
105
- decay: Math.round(decayFactor * 1000) / 1000,
106
- usage: Math.round(usageScore * 1000) / 1000,
107
- delta_days: Math.round(deltaDays),
108
- },
109
- };
68
+ return entries;
110
69
  }
111
70
 
112
- // ─── TTL ENFORCEMENT ─────────────────────────────────────────────────────────
113
- /**
114
- * Episodios > 30 días sin consolidar → comprimir en resumen + archivar.
115
- * Working memory > 7 días → eliminar.
116
- * Esto implementa la Capa Episódica del reporte.
117
- */
118
- function enforceEpisodicTTL(db, projectRoot) {
119
- const results = { compressed: 0, archived: 0, deleted: 0 };
71
+ function extractField(text, field) {
72
+ const regex = new RegExp(`\\*\\*${field}\\*\\*:\\s*(.+)`, 'i');
73
+ const match = text.match(regex);
74
+ return match ? match[1].trim() : null;
75
+ }
120
76
 
121
- // 1. Episodios > TTL_EPISODIC_DAYS días sin consolidar → comprimir
122
- try {
123
- const staleEpisodes = db.prepare(`
124
- SELECT episodio_id, tipo, descripcion, resultado, fecha, modulo
125
- FROM episodios
126
- WHERE consolidado = 0
127
- AND julianday('now') - julianday(fecha) > ?
128
- ORDER BY fecha ASC
129
- LIMIT 100
130
- `).all(TTL_EPISODIC_DAYS);
131
-
132
- if (staleEpisodes.length > 0) {
133
- // Generar resumen comprimido por módulo
134
- const byModule = {};
135
- staleEpisodes.forEach(ep => {
136
- const mod = ep.modulo || 'global';
137
- if (!byModule[mod]) byModule[mod] = [];
138
- byModule[mod].push(ep);
139
- });
140
-
141
- for (const [mod, eps] of Object.entries(byModule)) {
142
- const summary = `[COMPRIMIDO] ${eps.length} episodios de ${mod}: ` +
143
- eps.slice(0, 3).map(e => e.descripcion?.substring(0, 50)).join(' | ');
144
-
145
- // Insertar resumen como nodo semántico comprimido
146
- try {
147
- db.prepare(`
148
- INSERT OR IGNORE INTO nodos (tipo, titulo, contenido, area, confianza, estado, vigencia_tipo, fecha_creacion, fecha_update)
149
- VALUES ('episodio_comprimido', ?, ?, ?, 'BAJA', 'ACTIVO', 'HISTORICO', datetime('now'), datetime('now'))
150
- `).run(`Resumen episódico: ${mod} (${eps.length} eventos)`, summary, mod);
151
- } catch {}
152
-
153
- // Marcar episodios como consolidados
154
- const ids = eps.map(e => e.episodio_id);
155
- const placeholders = ids.map(() => '?').join(',');
156
- db.prepare(`UPDATE episodios SET consolidado=1 WHERE episodio_id IN (${placeholders})`).run(...ids);
157
-
158
- results.compressed += eps.length;
159
- }
160
- }
161
- } catch (e) {
162
- log(projectRoot, `TTL episodios error: ${e.message}`);
77
+ // ── Scoring ──────────────────────────────────────────────────────────────────
78
+
79
+ function computeScore(entry) {
80
+ let score = 0;
81
+
82
+ // Confianza base
83
+ if (entry.confidence === 'ALTA') score += 40;
84
+ if (entry.confidence === 'MEDIA') score += 20;
85
+ if (entry.confidence === 'BAJA') score += 5;
86
+
87
+ // Referencias acumuladas
88
+ score += Math.min(entry.references * 5, 30);
89
+
90
+ // Penalización por antigüedad
91
+ if (entry.date) {
92
+ const daysSince = daysSinceDate(entry.date);
93
+ if (daysSince > 180) score -= 10;
94
+ if (daysSince > 365) score -= 20;
163
95
  }
164
96
 
165
- // 2. Episodios consolidados > 90 días → archivar (log + delete del hot store)
97
+ // Bonus por módulo global
98
+ if (entry.module === 'global') score += 5;
99
+
100
+ return Math.max(0, score);
101
+ }
102
+
103
+ function daysSinceDate(dateStr) {
166
104
  try {
167
- const archiveable = db.prepare(`
168
- SELECT COUNT(*) as n FROM episodios
169
- WHERE consolidado = 1
170
- AND julianday('now') - julianday(fecha) > 90
171
- `).get()?.n || 0;
172
-
173
- if (archiveable > 0) {
174
- // Escribir archivo .jsonl antes de borrar
175
- const oldEps = db.prepare(`
176
- SELECT * FROM episodios
177
- WHERE consolidado = 1
178
- AND julianday('now') - julianday(fecha) > 90
179
- LIMIT 500
180
- `).all();
181
-
182
- const archivePath = path.join(projectRoot, '.agentic', `episodios_archive_${Date.now()}.jsonl`);
183
- try {
184
- fs.writeFileSync(archivePath, oldEps.map(e => JSON.stringify(e)).join('\n'));
185
- db.prepare(`
186
- DELETE FROM episodios
187
- WHERE consolidado = 1
188
- AND julianday('now') - julianday(fecha) > 90
189
- `).run();
190
- results.archived += oldEps.length;
191
- } catch {}
192
- }
193
- } catch {}
105
+ const d = new Date(dateStr);
106
+ return Math.floor((Date.now() - d.getTime()) / (1000 * 60 * 60 * 24));
107
+ } catch { return 0; }
108
+ }
109
+
110
+ // ── Deduplicación ────────────────────────────────────────────────────────────
111
+
112
+ function cosineSimilarity(a, b) {
113
+ const wordsA = tokenize(a);
114
+ const wordsB = tokenize(b);
115
+ const vocab = [...new Set([...wordsA, ...wordsB])];
194
116
 
195
- return results;
117
+ const vecA = vocab.map(w => wordsA.filter(x => x === w).length);
118
+ const vecB = vocab.map(w => wordsB.filter(x => x === w).length);
119
+
120
+ const dot = vecA.reduce((s, v, i) => s + v * vecB[i], 0);
121
+ const magA = Math.sqrt(vecA.reduce((s, v) => s + v * v, 0));
122
+ const magB = Math.sqrt(vecB.reduce((s, v) => s + v * v, 0));
123
+
124
+ return (magA && magB) ? dot / (magA * magB) : 0;
196
125
  }
197
126
 
198
- // ─── DEDUPLICACIÓN SEMÁNTICA ─────────────────────────────────────────────────
199
- /**
200
- * Detecta nodos procedurales semánticamente duplicados.
201
- * Estrategia: similitud de título + área + tipo → merge tomando el de mayor confianza.
202
- *
203
- * Sin acceso a embeddings reales (sin GPU): usa similitud de texto Jaccard como proxy.
204
- */
205
- function deduplicateNodes(db, projectRoot) {
206
- const results = { merged: 0, candidates: 0 };
127
+ function tokenize(text) {
128
+ return text.toLowerCase()
129
+ .replace(/[^a-z0-9áéíóúñü\s]/g, ' ')
130
+ .split(/\s+/)
131
+ .filter(w => w.length > 3);
132
+ }
207
133
 
208
- try {
209
- // Obtener nodos activos del mismo tipo y área
210
- const nodes = db.prepare(`
211
- SELECT id, tipo, titulo, contenido, confianza, area, aplicado, util,
212
- fecha_creacion, fecha_update, vigencia_tipo
213
- FROM nodos
214
- WHERE estado = 'ACTIVO'
215
- ORDER BY tipo, area, confianza DESC
216
- `).all();
217
-
218
- // Agrupar por tipo+área
219
- const groups = {};
220
- nodes.forEach(n => {
221
- const key = `${n.tipo}:${n.area || 'global'}`;
222
- if (!groups[key]) groups[key] = [];
223
- groups[key].push(n);
224
- });
225
-
226
- for (const [key, group] of Object.entries(groups)) {
227
- if (group.length < 2) continue;
228
-
229
- for (let i = 0; i < group.length; i++) {
230
- for (let j = i + 1; j < group.length; j++) {
231
- const a = group[i];
232
- const b = group[j];
233
-
234
- // Similitud de Jaccard sobre palabras del título
235
- const sim = jaccardSim(a.titulo || '', b.titulo || '');
236
-
237
- if (sim >= DEDUP_THRESHOLD) {
238
- results.candidates++;
239
-
240
- // Merge: mantener el de mayor confianza, agregar frecuencia de uso
241
- const winner = confRank(a.confianza) >= confRank(b.confianza) ? a : b;
242
- const loser = winner === a ? b : a;
243
-
244
- // Actualizar el winner con la frecuencia acumulada del loser
245
- try {
246
- db.prepare(`
247
- UPDATE nodos SET
248
- aplicado = aplicado + ?,
249
- util = util + ?,
250
- contenido = contenido || ' [MERGED: ' || ? || ']',
251
- fecha_update = datetime('now')
252
- WHERE id = ?
253
- `).run(loser.aplicado || 0, loser.util || 0,
254
- loser.titulo?.substring(0, 40) || '', winner.id);
255
-
256
- // Marcar loser como OBSOLETO
257
- db.prepare(`
258
- UPDATE nodos SET estado='OBSOLETO', vigencia_tipo='OBSOLETO',
259
- fecha_update=datetime('now')
260
- WHERE id = ?
261
- `).run(loser.id);
262
-
263
- results.merged++;
264
- } catch {}
265
- }
134
+ function deduplicateEntries(entries) {
135
+ const kept = [];
136
+ const removed = [];
137
+
138
+ for (let i = 0; i < entries.length; i++) {
139
+ let isDuplicate = false;
140
+
141
+ for (let j = 0; j < kept.length; j++) {
142
+ const sim = cosineSimilarity(entries[i].title + ' ' + entries[i].raw,
143
+ kept[j].title + ' ' + kept[j].raw);
144
+ if (sim >= CONFIG.SIMILARITY_THRESHOLD) {
145
+ // Keep the one with higher score
146
+ if (entries[i].score > kept[j].score) {
147
+ removed.push(kept[j]);
148
+ kept[j] = entries[i];
149
+ } else {
150
+ removed.push(entries[i]);
266
151
  }
152
+ isDuplicate = true;
153
+ break;
267
154
  }
268
155
  }
269
- } catch (e) {
270
- log(projectRoot, `Dedup error: ${e.message}`);
156
+
157
+ if (!isDuplicate) kept.push(entries[i]);
271
158
  }
272
159
 
273
- return results;
160
+ return { kept, removed };
274
161
  }
275
162
 
276
- // ─── RESOLUCIÓN DE CONFLICTOS ─────────────────────────────────────────────────
277
- /**
278
- * Detecta reglas contradictorias en la capa semántica.
279
- * Criterio: mismo área + tipo + título similar + contenido contradictorio.
280
- * Acción: marcar la más antigua como HISTORICO, conservar la más reciente.
281
- */
282
- function resolveConflicts(db, projectRoot) {
283
- const results = { resolved: 0, detected: 0 };
163
+ // ── Expiración ───────────────────────────────────────────────────────────────
284
164
 
285
- try {
286
- // Detectar pares con alta similitud de título pero fechas diferentes
287
- const nodes = db.prepare(`
288
- SELECT id, tipo, titulo, contenido, confianza, area, fecha_creacion, fecha_update, vigencia_tipo
289
- FROM nodos
290
- WHERE estado = 'ACTIVO' AND vigencia_tipo = 'VIGENTE'
291
- ORDER BY fecha_update DESC
292
- `).all();
293
-
294
- for (let i = 0; i < nodes.length; i++) {
295
- for (let j = i + 1; j < nodes.length; j++) {
296
- const a = nodes[i]; // más reciente
297
- const b = nodes[j]; // más antiguo
298
-
299
- if (a.tipo !== b.tipo || a.area !== b.area) continue;
300
-
301
- const titleSim = jaccardSim(a.titulo || '', b.titulo || '');
302
-
303
- // Títulos muy similares pero diferente contenido → posible conflicto
304
- if (titleSim >= 0.7 && titleSim < DEDUP_THRESHOLD) {
305
- results.detected++;
306
-
307
- // El más antiguo pasa a HISTORICO (supersesión)
308
- try {
309
- db.prepare(`
310
- UPDATE nodos SET
311
- vigencia_tipo = 'HISTORICO',
312
- contenido = contenido || ' [SUPERSEDED BY: ' || ? || ']',
313
- fecha_update = datetime('now')
314
- WHERE id = ?
315
- `).run(a.titulo?.substring(0, 40) || '', b.id);
316
-
317
- results.resolved++;
318
- } catch {}
319
- }
165
+ function expireEntries(entries) {
166
+ const now = Date.now();
167
+ const kept = [];
168
+ const expired = [];
169
+
170
+ for (const entry of entries) {
171
+ const maxDays = entry.score >= 40
172
+ ? CONFIG.EXPIRY_DAYS_HIGH_SCORE
173
+ : CONFIG.EXPIRY_DAYS_LOW_SCORE;
174
+
175
+ if (entry.date) {
176
+ const days = daysSinceDate(entry.date);
177
+ if (days > maxDays && entry.confidence === 'BAJA') {
178
+ expired.push(entry);
179
+ continue;
320
180
  }
321
181
  }
322
- } catch (e) {
323
- log(projectRoot, `Conflicts error: ${e.message}`);
182
+
183
+ kept.push(entry);
324
184
  }
325
185
 
326
- return results;
186
+ return { kept, expired };
327
187
  }
328
188
 
329
- // ─── RECALCULAR SCORES DE TODOS LOS NODOS ────────────────────────────────────
330
- /**
331
- * Actualiza el campo decay_score de todos los nodos activos.
332
- * Usa la fórmula S(k) sin query embedding (modo absoluto).
333
- */
334
- function recalculateScores(db, projectRoot) {
335
- let updated = 0;
189
+ // ── Reconstruir archivo ──────────────────────────────────────────────────────
336
190
 
337
- try {
338
- const nodes = db.prepare(`
339
- SELECT id, confianza, aplicado, util, fecha_update, fecha_creacion
340
- FROM nodos WHERE estado = 'ACTIVO'
341
- `).all();
342
-
343
- const stmt = db.prepare(`UPDATE nodos SET decay_score = ? WHERE id = ?`);
344
-
345
- nodes.forEach(node => {
346
- const scored = computeRelevanceScore(node, null, null);
347
- try {
348
- stmt.run(scored.score, node.id);
349
- updated++;
350
- } catch {}
351
- });
352
- } catch (e) {
353
- log(projectRoot, `Score recalc error: ${e.message}`);
191
+ function rebuildFile(filePath, entries, headerLines) {
192
+ // Sort by score descending
193
+ const sorted = [...entries].sort((a, b) => b.score - a.score);
194
+
195
+ const lines = [];
196
+ if (headerLines) lines.push(...headerLines, '');
197
+
198
+ for (const entry of sorted) {
199
+ lines.push(entry.raw.trim());
200
+ lines.push('');
354
201
  }
355
202
 
356
- return { updated };
203
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
357
204
  }
358
205
 
359
- // ─── LIMIT NODOS PROCEDURALES ─────────────────────────────────────────────────
360
- /**
361
- * Si hay > MAX_SEMANTIC_NODES nodos activos, deprecar los de menor score.
362
- * Previene el "colapso de contexto" por grafo demasiado denso.
363
- */
364
- function enforceNodeLimit(db, projectRoot) {
365
- let deprecated = 0;
366
-
367
- try {
368
- const count = db.prepare(`SELECT COUNT(*) as n FROM nodos WHERE estado='ACTIVO'`).get()?.n || 0;
369
-
370
- if (count > MAX_SEMANTIC_NODES) {
371
- const excess = count - MAX_SEMANTIC_NODES;
372
-
373
- // Obtener los nodos de menor score y baja utilidad
374
- const candidates = db.prepare(`
375
- SELECT id FROM nodos
376
- WHERE estado = 'ACTIVO'
377
- AND confianza = 'BAJA'
378
- AND (aplicado = 0 OR (util * 1.0 / aplicado) < ?)
379
- ORDER BY decay_score ASC, fecha_update ASC
380
- LIMIT ?
381
- `).all(MIN_UTILITY_RATIO, excess);
382
-
383
- candidates.forEach(n => {
384
- try {
385
- db.prepare(`
386
- UPDATE nodos SET estado='OBSOLETO', vigencia_tipo='OBSOLETO',
387
- fecha_update=datetime('now')
388
- WHERE id=?
389
- `).run(n.id);
390
- deprecated++;
391
- } catch {}
392
- });
393
- }
394
- } catch (e) {
395
- log(projectRoot, `Node limit error: ${e.message}`);
206
+ function extractHeader(filePath) {
207
+ if (!fs.existsSync(filePath)) return [];
208
+ const content = fs.readFileSync(filePath, 'utf8');
209
+ const lines = content.split('\n');
210
+ const headerLines = [];
211
+ for (const line of lines) {
212
+ if (line.startsWith('### ')) break;
213
+ headerLines.push(line);
396
214
  }
397
-
398
- return { deprecated };
215
+ return headerLines;
399
216
  }
400
217
 
401
- // ─── CURATION COMPLETA ───────────────────────────────────────────────────────
218
+ // ── Curación principal ───────────────────────────────────────────────────────
402
219
 
403
- /**
404
- * Curation completa. Se llama automáticamente cada 10 ciclos desde grafo.cjs.
405
- */
406
- function runCuration(projectRoot) {
407
- const start = Date.now();
408
- projectRoot = projectRoot || process.cwd();
409
-
410
- log(projectRoot, 'Iniciando curation...');
411
-
412
- let db;
413
- try { db = openDB(projectRoot); }
414
- catch (e) { log(projectRoot, `DB error: ${e.message}`); return null; }
415
-
416
- const report = {
417
- timestamp: new Date().toISOString(),
418
- ttl: null,
419
- dedup: null,
420
- conflicts: null,
421
- scores: null,
422
- node_limit: null,
423
- duration_ms: 0,
220
+ function curateFile(filePath, maxEntries, label) {
221
+ const result = {
222
+ file: label,
223
+ before: 0, after: 0,
224
+ deduped: 0, expired: 0, sorted: true,
225
+ changes: [],
424
226
  };
425
227
 
426
- try {
427
- // 1. TTL enforcement (episodios viejos)
428
- report.ttl = enforceEpisodicTTL(db, projectRoot);
429
- log(projectRoot, `TTL: ${report.ttl.compressed} comprimidos, ${report.ttl.archived} archivados`);
430
-
431
- // 2. Deduplicación semántica
432
- report.dedup = deduplicateNodes(db, projectRoot);
433
- log(projectRoot, `Dedup: ${report.dedup.merged} mergeados de ${report.dedup.candidates} candidatos`);
434
-
435
- // 3. Resolución de conflictos
436
- report.conflicts = resolveConflicts(db, projectRoot);
437
- log(projectRoot, `Conflictos: ${report.conflicts.resolved} resueltos de ${report.conflicts.detected} detectados`);
438
-
439
- // 4. Recalcular scores
440
- report.scores = recalculateScores(db, projectRoot);
441
- log(projectRoot, `Scores: ${report.scores.updated} nodos actualizados`);
442
-
443
- // 5. Límite de nodos
444
- report.node_limit = enforceNodeLimit(db, projectRoot);
445
- if (report.node_limit.deprecated > 0) {
446
- log(projectRoot, `Node limit: ${report.node_limit.deprecated} nodos deprecados`);
447
- }
228
+ if (!fs.existsSync(filePath)) {
229
+ result.changes.push('archivo no encontrado sin cambios');
230
+ return result;
231
+ }
232
+
233
+ const header = extractHeader(filePath);
234
+ const entries = parseMemoryFile(filePath);
235
+ result.before = entries.length;
236
+
237
+ // 1. Recalcular scores
238
+ entries.forEach(e => { e.score = computeScore(e); });
448
239
 
449
- } catch (e) {
450
- log(projectRoot, `Curation error: ${e.message}`);
240
+ // 2. Deduplicar
241
+ const { kept: afterDedup, removed } = deduplicateEntries(entries);
242
+ result.deduped = removed.length;
243
+ if (removed.length > 0) {
244
+ result.changes.push(`${removed.length} duplicados eliminados`);
451
245
  }
452
246
 
453
- report.duration_ms = Date.now() - start;
454
- log(projectRoot, `Curation completa en ${report.duration_ms}ms`);
247
+ // 3. Expirar
248
+ const { kept: afterExpire, expired } = expireEntries(afterDedup);
249
+ result.expired = expired.length;
250
+ if (expired.length > 0) {
251
+ result.changes.push(`${expired.length} entradas expiradas (BAJA confianza, sin uso)`);
252
+ }
253
+
254
+ // 4. Si supera máximo, descartar las de menor score
255
+ let finalEntries = afterExpire;
256
+ if (finalEntries.length > maxEntries) {
257
+ const cutoff = finalEntries.length - maxEntries;
258
+ result.changes.push(`${cutoff} entradas de bajo score descartadas (límite: ${maxEntries})`);
259
+ finalEntries = finalEntries.sort((a, b) => b.score - a.score).slice(0, maxEntries);
260
+ }
455
261
 
456
- return report;
262
+ result.after = finalEntries.length;
263
+
264
+ // 5. Reescribir ordenado por score
265
+ rebuildFile(filePath, finalEntries, header);
266
+
267
+ if (result.changes.length === 0) {
268
+ result.changes.push('sin cambios necesarios');
269
+ }
270
+
271
+ return result;
457
272
  }
458
273
 
459
- // ─── REPORTE SIN CAMBIOS ─────────────────────────────────────────────────────
274
+ // ── Report ───────────────────────────────────────────────────────────────────
460
275
 
461
- function generateReport(projectRoot) {
462
- projectRoot = projectRoot || process.cwd();
463
- const db = openDB(projectRoot);
276
+ function report() {
277
+ console.log('\n══════════════════════════════════════════════════');
278
+ console.log(' 🧠 Memory Curator — Análisis');
279
+ console.log('══════════════════════════════════════════════════');
464
280
 
465
- // Episodios sin consolidar
466
- const staleEpisodes = db.prepare(`
467
- SELECT COUNT(*) as n FROM episodios
468
- WHERE consolidado = 0
469
- AND julianday('now') - julianday(fecha) > ?
470
- `).get(TTL_EPISODIC_DAYS)?.n || 0;
281
+ const files = [
282
+ { path: ERRORES_FILE, label: 'errores.md', max: CONFIG.MAX_ENTRIES_ERRORS },
283
+ { path: PATRONES_FILE, label: 'patrones.md', max: CONFIG.MAX_ENTRIES_PATTERNS },
284
+ { path: DECISIONES_FILE, label: 'decisiones.md', max: CONFIG.MAX_ENTRIES_DECISIONS },
285
+ ];
471
286
 
472
- // Nodos activos
473
- const activeNodes = db.prepare(`SELECT COUNT(*) as n FROM nodos WHERE estado='ACTIVO'`).get()?.n || 0;
287
+ for (const f of files) {
288
+ const entries = parseMemoryFile(f.path);
289
+ console.log(`\n 📄 ${f.label}: ${entries.length} entradas`);
474
290
 
475
- // Nodos BAJA confianza con baja utilidad
476
- const lowUtility = db.prepare(`
477
- SELECT COUNT(*) as n FROM nodos
478
- WHERE estado='ACTIVO' AND confianza='BAJA'
479
- AND aplicado >= 3 AND (util * 1.0 / aplicado) < ?
480
- `).get(MIN_UTILITY_RATIO)?.n || 0;
291
+ if (entries.length === 0) {
292
+ console.log(' (vacío)');
293
+ continue;
294
+ }
481
295
 
482
- // Nodos sin vigencia_tipo
483
- let unclassified = 0;
484
- try {
485
- unclassified = db.prepare(`
486
- SELECT COUNT(*) as n FROM nodos
487
- WHERE estado='ACTIVO' AND (vigencia_tipo IS NULL OR vigencia_tipo = '')
488
- `).get()?.n || 0;
489
- } catch {}
490
-
491
- return {
492
- active_nodes: activeNodes,
493
- stale_episodes: staleEpisodes,
494
- low_utility_candidates: lowUtility,
495
- unclassified_vigencia: unclassified,
496
- over_limit: activeNodes > MAX_SEMANTIC_NODES,
497
- needs_curation: staleEpisodes > 50 || lowUtility > 20 || activeNodes > MAX_SEMANTIC_NODES,
498
- };
499
- }
296
+ const sorted = [...entries].sort((a, b) => b.score - a.score);
297
+ const high = sorted.filter(e => e.score >= 40).length;
298
+ const med = sorted.filter(e => e.score >= 20 && e.score < 40).length;
299
+ const low = sorted.filter(e => e.score < 20).length;
500
300
 
501
- // ─── HELPERS ─────────────────────────────────────────────────────────────────
301
+ console.log(` Score ALTO (≥40): ${high} | MEDIO (20-39): ${med} | BAJO (<20): ${low}`);
302
+ if (entries.length > f.max) {
303
+ console.log(` ⚠️ Supera límite (${f.max}) — curación recomendada`);
304
+ }
502
305
 
503
- function jaccardSim(a, b) {
504
- const setA = new Set(a.toLowerCase().split(/\W+/).filter(Boolean));
505
- const setB = new Set(b.toLowerCase().split(/\W+/).filter(Boolean));
506
- const intersection = new Set([...setA].filter(x => setB.has(x)));
507
- const union = new Set([...setA, ...setB]);
508
- return union.size === 0 ? 0 : intersection.size / union.size;
509
- }
306
+ // Show duplicates preview
307
+ const { removed } = deduplicateEntries(entries);
308
+ if (removed.length > 0) {
309
+ console.log(` 🔁 Posibles duplicados: ${removed.length}`);
310
+ }
311
+ }
510
312
 
511
- function confRank(c) {
512
- return { 'ALTA': 3, 'MEDIA': 2, 'BAJA': 1 }[c] || 0;
313
+ console.log('\n══════════════════════════════════════════════════\n');
513
314
  }
514
315
 
515
- // ─── CLI ──────────────────────────────────────────────────────────────────────
316
+ // ── CLI ──────────────────────────────────────────────────────────────────────
516
317
 
517
318
  if (require.main === module) {
518
- const [,, cmd] = process.argv;
519
- const projectRoot = process.cwd();
520
-
521
- switch (cmd) {
522
- case 'run': {
523
- const r = runCuration(projectRoot);
524
- if (r) {
525
- console.log('\n=== MemCurator Report ===');
526
- console.log(`TTL: ${r.ttl?.compressed} comprimidos, ${r.ttl?.archived} archivados`);
527
- console.log(`Dedup: ${r.dedup?.merged} mergeados`);
528
- console.log(`Conflictos:${r.conflicts?.resolved} resueltos`);
529
- console.log(`Scores: ${r.scores?.updated} actualizados`);
530
- console.log(`Duración: ${r.duration_ms}ms\n`);
319
+ const cmd = process.argv[2] || 'run';
320
+
321
+ if (cmd === 'report') {
322
+ report();
323
+ process.exit(0);
324
+ }
325
+
326
+ if (cmd === 'run' || cmd === 'dedup' || cmd === 'score' || cmd === 'expire') {
327
+ console.log('\n══════════════════════════════════════════════════');
328
+ console.log(' 🧠 Memory Curator — Curación');
329
+ console.log('══════════════════════════════════════════════════\n');
330
+
331
+ const files = [
332
+ { path: ERRORES_FILE, max: CONFIG.MAX_ENTRIES_ERRORS, label: 'errores.md' },
333
+ { path: PATRONES_FILE, max: CONFIG.MAX_ENTRIES_PATTERNS, label: 'patrones.md' },
334
+ { path: DECISIONES_FILE, max: CONFIG.MAX_ENTRIES_DECISIONS, label: 'decisiones.md' },
335
+ ];
336
+
337
+ let totalRemoved = 0;
338
+
339
+ for (const f of files) {
340
+ const result = curateFile(f.path, f.max, f.label);
341
+ totalRemoved += result.deduped + result.expired;
342
+
343
+ const delta = result.before - result.after;
344
+ console.log(` ${f.label}:`);
345
+ console.log(` Antes: ${result.before} → Después: ${result.after} (${delta > 0 ? '-' + delta : 'sin cambio'})`);
346
+ for (const c of result.changes) {
347
+ console.log(` ✓ ${c}`);
531
348
  }
532
- break;
533
- }
534
- case 'ttl': {
535
- const db = openDB(projectRoot);
536
- const r = enforceEpisodicTTL(db, projectRoot);
537
- console.log(`TTL: ${r.compressed} comprimidos, ${r.archived} archivados`);
538
- break;
539
- }
540
- case 'dedup': {
541
- const db = openDB(projectRoot);
542
- const r = deduplicateNodes(db, projectRoot);
543
- console.log(`Dedup: ${r.merged} mergeados de ${r.candidates} candidatos`);
544
- break;
545
- }
546
- case 'conflicts': {
547
- const db = openDB(projectRoot);
548
- const r = resolveConflicts(db, projectRoot);
549
- console.log(`Conflictos: ${r.resolved} resueltos de ${r.detected} detectados`);
550
- break;
551
- }
552
- case 'score': {
553
- const db = openDB(projectRoot);
554
- const r = recalculateScores(db, projectRoot);
555
- console.log(`Scores: ${r.updated} nodos actualizados`);
556
- break;
349
+ console.log('');
557
350
  }
558
- case 'report': {
559
- const r = generateReport(projectRoot);
560
- console.log('\n=== MemCurator Pre-Report ===');
561
- console.log(`Nodos activos: ${r.active_nodes}`);
562
- console.log(`Episodios stale: ${r.stale_episodes}`);
563
- console.log(`Candidatos baja util: ${r.low_utility_candidates}`);
564
- console.log(`Sin vigencia_tipo: ${r.unclassified_vigencia}`);
565
- console.log(`Sobre límite: ${r.over_limit}`);
566
- console.log(`Necesita curation: ${r.needs_curation}`);
567
- console.log('\nPara curar: node mem-curator.cjs run\n');
568
- break;
569
- }
570
- default:
571
- console.log('Uso: node mem-curator.cjs [run | ttl | dedup | conflicts | score | report]');
351
+
352
+ console.log(` Total eliminadas: ${totalRemoved}`);
353
+ console.log('══════════════════════════════════════════════════\n');
354
+
355
+ process.exit(0);
572
356
  }
357
+
358
+ console.log('Uso: node mem-curator.cjs [run|report|dedup|score|expire]');
573
359
  }
574
360
 
575
- module.exports = {
576
- runCuration,
577
- enforceEpisodicTTL,
578
- deduplicateNodes,
579
- resolveConflicts,
580
- recalculateScores,
581
- enforceNodeLimit,
582
- computeRelevanceScore,
583
- generateReport,
584
- };
361
+ module.exports = { curateFile, parseMemoryFile, computeScore, deduplicateEntries, expireEntries };