agentic-kdd 3.2.3 → 3.5.1

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/kdd-memory.cjs ADDED
@@ -0,0 +1,579 @@
1
+ /**
2
+ * Agentic KDD — KDD Memory Server v1.0
3
+ * BM25 + vector hybrid ranked retrieval over .agentic/memoria/*.md + SQLite
4
+ *
5
+ * LA MAYOR PALANCA hacia L4.
6
+ *
7
+ * El problema actual: los agentes leen errores.md, patrones.md, decisiones.md
8
+ * COMPLETOS en cada ciclo. Con proyectos de 6+ meses eso son miles de tokens
9
+ * de contexto sin ranking. El agente no sabe qué es más relevante.
10
+ *
11
+ * La solución: recall(query, top_k) devuelve los K fragmentos más relevantes
12
+ * rankeados por BM25 (léxico) + vector similarity (semántico) con RRF fusion.
13
+ * El agente consume 50-200 tokens en vez de 5.000-20.000.
14
+ *
15
+ * Implementa:
16
+ * - BM25 via SQLite FTS5 (léxico — ideal para nombres de funciones, errores exactos)
17
+ * - Vector similarity via embeddings existentes de Agentic KDD (semántico)
18
+ * - Reciprocal Rank Fusion (RRF) para combinar ambos rankings
19
+ * - Temporal decay: entradas más recientes tienen mayor peso
20
+ * - Trust scoring: nodos HIGH > MEDIUM > LOW confidence
21
+ * - remember(entry) con validación antes de escribir
22
+ *
23
+ * Referencias:
24
+ * - Basic Memory (~3.3k ★): markdown-native BM25+vector hybrid
25
+ * - memweave: FTS5 + sqlite-vec, 0.7×vector + 0.3×BM25, temporal decay
26
+ * - context-mode: BM25-only, headings 5× weight
27
+ * - QMD (Tobi Lütke): BM25 + vector + reranking RRF
28
+ *
29
+ * Uso:
30
+ * node kdd-memory.cjs recall "error de autenticación JWT" --top 10
31
+ * node kdd-memory.cjs remember "pattern: usar siempre bcrypt para passwords" --area auth
32
+ * node kdd-memory.cjs stats
33
+ * node kdd-memory.cjs index — re-indexar todos los archivos markdown
34
+ */
35
+
36
+ 'use strict';
37
+
38
+ const path = require('path');
39
+ const fs = require('fs');
40
+ const crypto= require('crypto');
41
+
42
+ const VECTOR_WEIGHT = 0.65; // peso del retrieval semántico
43
+ const BM25_WEIGHT = 0.35; // peso del retrieval léxico
44
+ const K_RRF = 60; // constante RRF estándar
45
+ const DECAY_LAMBDA = 0.05; // decay temporal (misma que MemCurator)
46
+ const HIGH_BOOST = 1.5; // multiplicador para nodos HIGH confidence
47
+ const HEADING_BOOST = 3.0; // multiplicador para matches en títulos/headings
48
+
49
+ // ─── DB ───────────────────────────────────────────────────────────────────────
50
+
51
+ function openDB(projectRoot) {
52
+ const dbPath = path.join(projectRoot, '.agentic/memoria.db');
53
+ let db;
54
+ try { db = new (require('better-sqlite3'))(dbPath); }
55
+ catch { try { const { DatabaseSync } = require('node:sqlite'); db = new DatabaseSync(dbPath); } catch { return null; } }
56
+
57
+ // Crear tabla FTS5 para búsqueda léxica si no existe
58
+ try {
59
+ db.exec(`
60
+ CREATE VIRTUAL TABLE IF NOT EXISTS nodos_fts USING fts5(
61
+ id UNINDEXED,
62
+ titulo,
63
+ contenido,
64
+ area,
65
+ tipo,
66
+ tokenize='porter unicode61'
67
+ )
68
+ `);
69
+
70
+ // Sincronizar FTS con nodos si está vacío
71
+ const ftsCount = db.prepare("SELECT COUNT(*) as n FROM nodos_fts").get()?.n || 0;
72
+ if (ftsCount === 0) {
73
+ syncFTS(db);
74
+ }
75
+ } catch {}
76
+
77
+ return db;
78
+ }
79
+
80
+ function syncFTS(db) {
81
+ try {
82
+ db.exec("DELETE FROM nodos_fts");
83
+ const nodes = db.prepare(
84
+ "SELECT id, titulo, contenido, area, tipo FROM nodos WHERE estado='ACTIVO' LIMIT 5000"
85
+ ).all();
86
+ const insert = db.prepare("INSERT INTO nodos_fts(id, titulo, contenido, area, tipo) VALUES (?, ?, ?, ?, ?)");
87
+ nodes.forEach(n => {
88
+ try { insert.run(n.id, n.titulo || '', n.contenido || '', n.area || '', n.tipo || ''); } catch {}
89
+ });
90
+ return nodes.length;
91
+ } catch { return 0; }
92
+ }
93
+
94
+ // ─── BM25 SEARCH ─────────────────────────────────────────────────────────────
95
+ /**
96
+ * BM25 via SQLite FTS5. Ideal para:
97
+ * - Nombres exactos de funciones/archivos
98
+ * - Mensajes de error específicos
99
+ * - Identificadores de código
100
+ */
101
+ function bm25Search(db, query, topK = 20) {
102
+ if (!query || !db) return [];
103
+
104
+ try {
105
+ // Sanitizar query para FTS5
106
+ const sanitized = query
107
+ .replace(/['"]/g, ' ')
108
+ .replace(/[()]/g, ' ')
109
+ .trim();
110
+
111
+ if (!sanitized) return [];
112
+
113
+ const results = db.prepare(`
114
+ SELECT
115
+ id,
116
+ titulo,
117
+ contenido,
118
+ area,
119
+ tipo,
120
+ bm25(nodos_fts) as bm25_score
121
+ FROM nodos_fts
122
+ WHERE nodos_fts MATCH ?
123
+ ORDER BY bm25_score
124
+ LIMIT ?
125
+ `).all(sanitized, topK * 2);
126
+
127
+ // BM25 en SQLite: scores más negativos = más relevantes (invertir)
128
+ const maxScore = results.length > 0 ? Math.abs(Math.min(...results.map(r => r.bm25_score))) : 1;
129
+
130
+ return results.map((r, idx) => ({
131
+ id: r.id,
132
+ titulo: r.titulo,
133
+ contenido: r.contenido?.substring(0, 200),
134
+ area: r.area,
135
+ tipo: r.tipo,
136
+ bm25_rank: idx + 1,
137
+ bm25_score: Math.abs(r.bm25_score) / (maxScore || 1),
138
+ }));
139
+ } catch { return []; }
140
+ }
141
+
142
+ // ─── VECTOR SEARCH ───────────────────────────────────────────────────────────
143
+ /**
144
+ * Búsqueda semántica via embeddings existentes.
145
+ * Usa el módulo embeddings.cjs de Agentic KDD.
146
+ */
147
+ async function vectorSearch(db, query, projectRoot, topK = 20) {
148
+ if (!db || !query) return [];
149
+
150
+ try {
151
+ const embeddingsModule = require(path.join(projectRoot, '.agentic/grafo/embeddings.cjs'));
152
+ const queryEmbedding = await embeddingsModule.embed(query, projectRoot);
153
+
154
+ if (!queryEmbedding) return []; // embeddings no disponibles, fallback a BM25
155
+
156
+ // Obtener nodos con embeddings almacenados
157
+ const nodes = db.prepare(`
158
+ SELECT id, titulo, contenido, area, tipo, confianza, embedding, aplicado, fecha_update
159
+ FROM nodos
160
+ WHERE estado = 'ACTIVO' AND embedding IS NOT NULL
161
+ LIMIT 2000
162
+ `).all();
163
+
164
+ const scored = [];
165
+ for (const node of nodes) {
166
+ try {
167
+ const nodeEmbed = typeof node.embedding === 'string'
168
+ ? JSON.parse(node.embedding) : node.embedding;
169
+ if (!nodeEmbed || !Array.isArray(nodeEmbed)) continue;
170
+
171
+ const score = embeddingsModule.cosineSim(queryEmbedding, nodeEmbed);
172
+ if (score > 0.2) { // threshold mínimo
173
+ scored.push({ ...node, vector_score: score });
174
+ }
175
+ } catch {}
176
+ }
177
+
178
+ scored.sort((a, b) => b.vector_score - a.vector_score);
179
+ return scored.slice(0, topK).map((n, idx) => ({
180
+ id: n.id,
181
+ titulo: n.titulo,
182
+ contenido: n.contenido?.substring(0, 200),
183
+ area: n.area,
184
+ tipo: n.tipo,
185
+ confianza: n.confianza,
186
+ aplicado: n.aplicado,
187
+ fecha_update: n.fecha_update,
188
+ vector_rank: idx + 1,
189
+ vector_score: n.vector_score,
190
+ }));
191
+ } catch { return []; }
192
+ }
193
+
194
+ // ─── TEMPORAL DECAY ──────────────────────────────────────────────────────────
195
+
196
+ function computeDecay(fechaUpdate) {
197
+ if (!fechaUpdate) return 0.5;
198
+ const deltaDays = (Date.now() - new Date(fechaUpdate).getTime()) / (1000 * 60 * 60 * 24);
199
+ return Math.exp(-DECAY_LAMBDA * deltaDays);
200
+ }
201
+
202
+ // ─── RRF FUSION ──────────────────────────────────────────────────────────────
203
+ /**
204
+ * Reciprocal Rank Fusion — combina BM25 y vector rankings.
205
+ * RRF(d) = Σ 1/(k + rank_i(d))
206
+ * Aplicamos pesos: BM25_WEIGHT y VECTOR_WEIGHT
207
+ * Plus: boost por confianza HIGH, decay temporal
208
+ */
209
+ function rrfFusion(bm25Results, vectorResults, db, topK = 10) {
210
+ const scores = {};
211
+ const nodeData = {};
212
+
213
+ // Procesar BM25 results
214
+ bm25Results.forEach((r, idx) => {
215
+ const rank = idx + 1;
216
+ const rrf = BM25_WEIGHT / (K_RRF + rank);
217
+ scores[r.id] = (scores[r.id] || 0) + rrf;
218
+ nodeData[r.id] = { ...nodeData[r.id], ...r };
219
+ });
220
+
221
+ // Procesar vector results
222
+ vectorResults.forEach((r, idx) => {
223
+ const rank = idx + 1;
224
+ const rrf = VECTOR_WEIGHT / (K_RRF + rank);
225
+ scores[r.id] = (scores[r.id] || 0) + rrf;
226
+ nodeData[r.id] = { ...nodeData[r.id], ...r };
227
+ });
228
+
229
+ // Enriquecer con datos completos de DB y aplicar boosts
230
+ if (db) {
231
+ Object.keys(scores).forEach(id => {
232
+ try {
233
+ const node = db.prepare(
234
+ "SELECT confianza, aplicado, util, fecha_update, vigencia_tipo FROM nodos WHERE id = ?"
235
+ ).get(id);
236
+
237
+ if (node) {
238
+ nodeData[id] = { ...nodeData[id], ...node };
239
+
240
+ // Boost por confianza
241
+ const confBoost = node.confianza === 'ALTA' ? HIGH_BOOST
242
+ : node.confianza === 'MEDIA' ? 1.2 : 1.0;
243
+
244
+ // Decay temporal
245
+ const decay = computeDecay(node.fecha_update);
246
+
247
+ // Boost por frecuencia de uso
248
+ const usageBoost = 1 + Math.log(1 + (node.aplicado || 0)) * 0.1;
249
+
250
+ // Penalizar HISTORICO/OBSOLETO
251
+ const vigenciaPenalty = (node.vigencia_tipo === 'HISTORICO' || node.vigencia_tipo === 'OBSOLETO') ? 0.3 : 1.0;
252
+
253
+ scores[id] *= confBoost * decay * usageBoost * vigenciaPenalty;
254
+ }
255
+ } catch {}
256
+ });
257
+ }
258
+
259
+ // Ordenar por score final y retornar top K
260
+ return Object.entries(scores)
261
+ .sort(([, a], [, b]) => b - a)
262
+ .slice(0, topK)
263
+ .map(([id, score]) => ({
264
+ ...nodeData[id],
265
+ id,
266
+ relevance_score: Math.round(score * 1000) / 1000,
267
+ _debug: {
268
+ bm25_rank: nodeData[id]?.bm25_rank,
269
+ vector_rank: nodeData[id]?.vector_rank,
270
+ },
271
+ }));
272
+ }
273
+
274
+ // ─── RECALL — PUNTO DE ENTRADA PRINCIPAL ─────────────────────────────────────
275
+ /**
276
+ * Recuperación ponderada. Reemplaza la lectura de archivos completos.
277
+ * El agente llama recall() en vez de leer errores.md, patrones.md, etc.
278
+ *
279
+ * @param {string} query Descripción de la tarea o error
280
+ * @param {number} topK Número de resultados (default 10)
281
+ * @param {string} tipo Filtrar por tipo (patron, error, decision, etc.)
282
+ * @param {string} area Filtrar por área
283
+ */
284
+ async function recall(query, options = {}, projectRoot) {
285
+ projectRoot = projectRoot || process.cwd();
286
+ const { topK = 10, tipo = null, area = null } = options;
287
+
288
+ const db = openDB(projectRoot);
289
+ if (!db) return { results: [], source: 'unavailable' };
290
+
291
+ // Sincronizar FTS si es necesario
292
+ try {
293
+ const ftsCount = db.prepare("SELECT COUNT(*) as n FROM nodos_fts").get()?.n || 0;
294
+ const nodeCount = db.prepare("SELECT COUNT(*) as n FROM nodos WHERE estado='ACTIVO'").get()?.n || 0;
295
+ if (ftsCount < nodeCount * 0.8) syncFTS(db); // re-sync si hay >20% desincronizado
296
+ } catch {}
297
+
298
+ // BM25 search
299
+ const bm25Results = bm25Search(db, query, topK * 2);
300
+
301
+ // Vector search (async)
302
+ const vectorResults = await vectorSearch(db, query, projectRoot, topK * 2);
303
+
304
+ // Si ninguno tiene resultados, fallback a query simple
305
+ if (bm25Results.length === 0 && vectorResults.length === 0) {
306
+ const fallback = db.prepare(`
307
+ SELECT id, titulo, contenido, area, tipo, confianza, aplicado
308
+ FROM nodos
309
+ WHERE estado = 'ACTIVO'
310
+ AND confianza IN ('ALTA', 'MEDIA')
311
+ ${tipo ? "AND tipo = '" + tipo + "'" : ''}
312
+ ${area ? "AND area LIKE '%" + area + "%'" : ''}
313
+ ORDER BY aplicado DESC
314
+ LIMIT ?
315
+ `).all(topK);
316
+
317
+ db.close();
318
+ return {
319
+ results: fallback,
320
+ source: 'fallback_no_query_match',
321
+ query,
322
+ };
323
+ }
324
+
325
+ // RRF fusion
326
+ let results = rrfFusion(bm25Results, vectorResults, db, topK * 2);
327
+
328
+ // Aplicar filtros post-fusion
329
+ if (tipo) results = results.filter(r => r.tipo === tipo);
330
+ if (area) results = results.filter(r => r.area?.toLowerCase().includes(area.toLowerCase()));
331
+
332
+ results = results.slice(0, topK);
333
+
334
+ // Enriquecer con contenido completo si está disponible
335
+ results = results.map(r => {
336
+ try {
337
+ const full = db.prepare("SELECT titulo, contenido, area, tipo, confianza FROM nodos WHERE id = ?").get(r.id);
338
+ if (full) return { ...r, ...full };
339
+ } catch {}
340
+ return r;
341
+ });
342
+
343
+ db.close();
344
+ return {
345
+ results,
346
+ query,
347
+ source: `bm25(${bm25Results.length}) + vector(${vectorResults.length}) → rrf`,
348
+ total_found: results.length,
349
+ };
350
+ }
351
+
352
+ // ─── REMEMBER — ESCRIBIR EN MEMORIA CON VALIDACIÓN ───────────────────────────
353
+ /**
354
+ * Escribe una entrada en memoria con validación.
355
+ * Antes de escribir verifica que no sea duplicado (similitud Jaccard > 0.85).
356
+ * Agrega frontmatter de validación automáticamente.
357
+ */
358
+ function remember(entry, options = {}, projectRoot) {
359
+ projectRoot = projectRoot || process.cwd();
360
+ const { tipo = 'patron', area = 'global', confianza = 'BAJA', archivos = [] } = options;
361
+
362
+ const db = openDB(projectRoot);
363
+ if (!db) return { ok: false, error: 'DB unavailable' };
364
+
365
+ // Generar hash del contexto
366
+ const hashCtx = crypto.createHash('md5')
367
+ .update(entry + area + tipo)
368
+ .digest('hex')
369
+ .substring(0, 8);
370
+
371
+ const id = `${tipo}_${hashCtx}`;
372
+
373
+ // Verificar duplicado por similitud de texto
374
+ const jaccardSim = (a, b) => {
375
+ const sA = new Set(a.toLowerCase().split(/\W+/).filter(Boolean));
376
+ const sB = new Set(b.toLowerCase().split(/\W+/).filter(Boolean));
377
+ const inter = new Set([...sA].filter(x => sB.has(x)));
378
+ const union = new Set([...sA, ...sB]);
379
+ return union.size === 0 ? 0 : inter.size / union.size;
380
+ };
381
+
382
+ let isDuplicate = false;
383
+ try {
384
+ const existing = db.prepare(
385
+ "SELECT titulo, contenido FROM nodos WHERE tipo = ? AND area = ? AND estado = 'ACTIVO' LIMIT 20"
386
+ ).all(tipo, area);
387
+
388
+ isDuplicate = existing.some(n =>
389
+ jaccardSim(entry, (n.titulo || '') + ' ' + (n.contenido || '')) > 0.85
390
+ );
391
+ } catch {}
392
+
393
+ if (isDuplicate) {
394
+ db.close();
395
+ return { ok: false, reason: 'duplicate', message: 'Entry too similar to existing knowledge — skipped' };
396
+ }
397
+
398
+ // Escribir en DB
399
+ try {
400
+ db.prepare(`
401
+ INSERT OR REPLACE INTO nodos
402
+ (id, tipo, titulo, contenido, area, confianza, estado, vigencia_tipo,
403
+ hash_contexto, fecha_creacion, fecha_update, archivos_aplica)
404
+ VALUES (?, ?, ?, ?, ?, ?, 'ACTIVO', 'VIGENTE', ?, datetime('now'), datetime('now'), ?)
405
+ `).run(
406
+ id, tipo,
407
+ entry.substring(0, 100), // titulo
408
+ entry, // contenido completo
409
+ area, confianza, hashCtx,
410
+ JSON.stringify(archivos)
411
+ );
412
+
413
+ // Actualizar FTS
414
+ try {
415
+ db.prepare("INSERT OR REPLACE INTO nodos_fts(id, titulo, contenido, area, tipo) VALUES (?, ?, ?, ?, ?)")
416
+ .run(id, entry.substring(0, 100), entry, area, tipo);
417
+ } catch {}
418
+
419
+ db.close();
420
+ return { ok: true, id, hash: hashCtx };
421
+ } catch (e) {
422
+ db.close();
423
+ return { ok: false, error: e.message };
424
+ }
425
+ }
426
+
427
+ // ─── INDEX — REINDEXAR ARCHIVOS MARKDOWN ─────────────────────────────────────
428
+ /**
429
+ * Indexa (o re-indexa) todos los archivos .md de .agentic/memoria/
430
+ * Útil al inicializar o cuando se editan archivos manualmente.
431
+ */
432
+ function indexMarkdown(projectRoot) {
433
+ projectRoot = projectRoot || process.cwd();
434
+ const memoriaPath = path.join(projectRoot, '.agentic/memoria');
435
+
436
+ if (!fs.existsSync(memoriaPath)) return { indexed: 0 };
437
+
438
+ const db = openDB(projectRoot);
439
+ if (!db) return { indexed: 0 };
440
+
441
+ let indexed = 0;
442
+ const files = fs.readdirSync(memoriaPath).filter(f => f.endsWith('.md'));
443
+
444
+ files.forEach(file => {
445
+ try {
446
+ const content = fs.readFileSync(path.join(memoriaPath, file), 'utf8');
447
+ const area = path.basename(file, '.md');
448
+
449
+ // Extraer entradas (bloques separados por ## o ***)
450
+ const entries = content
451
+ .split(/\n(?=##|\*{3}|\-{3})/)
452
+ .map(block => block.trim())
453
+ .filter(block => block.length > 20 && !block.startsWith('#!'));
454
+
455
+ entries.forEach(entry => {
456
+ const tipo = file.includes('error') ? 'error'
457
+ : file.includes('patron') ? 'patron'
458
+ : file.includes('decision') ? 'decision'
459
+ : 'patron';
460
+
461
+ // Detectar confianza por marcadores en el texto
462
+ const confianza = /HIGH|ALTA|⭐⭐⭐/.test(entry) ? 'ALTA'
463
+ : /MEDIA|MEDIUM|⭐⭐/.test(entry) ? 'MEDIA'
464
+ : 'BAJA';
465
+
466
+ const result = remember(entry, { tipo, area, confianza }, projectRoot);
467
+ if (result.ok) indexed++;
468
+ });
469
+ } catch {}
470
+ });
471
+
472
+ // Re-sync FTS completo
473
+ syncFTS(db);
474
+ db.close();
475
+
476
+ return { indexed, files: files.length };
477
+ }
478
+
479
+ // ─── STATS ────────────────────────────────────────────────────────────────────
480
+
481
+ function getStats(projectRoot) {
482
+ const db = openDB(projectRoot || process.cwd());
483
+ if (!db) return { error: 'DB unavailable' };
484
+
485
+ const safe = (fn) => { try { return fn(); } catch { return null; } };
486
+
487
+ const stats = {
488
+ total_nodes: safe(() => db.prepare("SELECT COUNT(*) as n FROM nodos WHERE estado='ACTIVO'").get()?.n) || 0,
489
+ fts_indexed: safe(() => db.prepare("SELECT COUNT(*) as n FROM nodos_fts").get()?.n) || 0,
490
+ with_embeddings: safe(() => db.prepare("SELECT COUNT(*) as n FROM nodos WHERE estado='ACTIVO' AND embedding IS NOT NULL").get()?.n) || 0,
491
+ high_confidence: safe(() => db.prepare("SELECT COUNT(*) as n FROM nodos WHERE estado='ACTIVO' AND confianza='ALTA'").get()?.n) || 0,
492
+ retrieval_mode: null,
493
+ };
494
+
495
+ // Determinar modo de retrieval disponible
496
+ try {
497
+ require(path.join(projectRoot || process.cwd(), '.agentic/grafo/embeddings.cjs'));
498
+ stats.retrieval_mode = stats.with_embeddings > 0 ? 'hybrid_bm25_vector' : 'bm25_only';
499
+ } catch {
500
+ stats.retrieval_mode = 'bm25_only';
501
+ }
502
+
503
+ stats.sync_status = stats.fts_indexed >= stats.total_nodes * 0.9 ? 'synced' : 'needs_sync';
504
+ stats.coverage_pct = stats.total_nodes > 0
505
+ ? Math.round((stats.with_embeddings / stats.total_nodes) * 100) : 0;
506
+
507
+ db.close();
508
+ return stats;
509
+ }
510
+
511
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
512
+
513
+ if (require.main === module) {
514
+ const [,, cmd, ...args] = process.argv;
515
+ const projectRoot = process.cwd();
516
+
517
+ switch (cmd) {
518
+ case 'recall': {
519
+ const query = args.filter(a => !a.startsWith('--')).join(' ');
520
+ const topK = parseInt(args.find(a => a.startsWith('--top'))?.split('=')[1] || '10');
521
+ const tipo = args.find(a => a.startsWith('--tipo='))?.split('=')[1];
522
+
523
+ if (!query) { console.log('Uso: kdd-memory.cjs recall "query" [--top=10] [--tipo=error|patron]'); break; }
524
+
525
+ recall(query, { topK, tipo }, projectRoot).then(result => {
526
+ console.log(`\n📚 KDD Memory Recall — "${query}"`);
527
+ console.log(` Source: ${result.source} | Found: ${result.total_found}\n`);
528
+ result.results.forEach((r, i) => {
529
+ const conf = r.confianza === 'ALTA' ? '⭐' : r.confianza === 'MEDIA' ? '○' : '·';
530
+ console.log(` ${i+1}. ${conf} [${r.tipo}] ${r.titulo?.substring(0,60)}`);
531
+ console.log(` Area: ${r.area} | Score: ${r.relevance_score} | Aplicado: ${r.aplicado || 0}×`);
532
+ if (r.contenido && r.contenido.length > 80) console.log(` ${r.contenido.substring(0,120)}...`);
533
+ console.log('');
534
+ });
535
+ });
536
+ break;
537
+ }
538
+
539
+ case 'remember': {
540
+ const entry = args.filter(a => !a.startsWith('--')).join(' ');
541
+ const area = args.find(a => a.startsWith('--area='))?.split('=')[1] || 'global';
542
+ const tipo = args.find(a => a.startsWith('--tipo='))?.split('=')[1] || 'patron';
543
+ if (!entry) { console.log('Uso: kdd-memory.cjs remember "entry" [--area=global] [--tipo=patron]'); break; }
544
+ const result = remember(entry, { tipo, area }, projectRoot);
545
+ console.log(result.ok ? `✅ Stored: ${result.id}` : `❌ ${result.reason || result.error}`);
546
+ break;
547
+ }
548
+
549
+ case 'index':
550
+ const r = indexMarkdown(projectRoot);
551
+ console.log(`✅ Indexed ${r.indexed} entries from ${r.files} markdown files`);
552
+ break;
553
+
554
+ case 'sync': {
555
+ const db = openDB(projectRoot);
556
+ if (!db) { console.log('❌ DB unavailable'); break; }
557
+ const n = syncFTS(db);
558
+ db.close();
559
+ console.log(`✅ FTS synced: ${n} nodes`);
560
+ break;
561
+ }
562
+
563
+ case 'stats': {
564
+ const s = getStats(projectRoot);
565
+ console.log('\n📊 KDD Memory Stats');
566
+ console.log(` Total nodes: ${s.total_nodes}`);
567
+ console.log(` FTS indexed: ${s.fts_indexed} (${s.sync_status})`);
568
+ console.log(` With embeddings: ${s.with_embeddings} (${s.coverage_pct}% coverage)`);
569
+ console.log(` HIGH confidence: ${s.high_confidence}`);
570
+ console.log(` Retrieval mode: ${s.retrieval_mode}\n`);
571
+ break;
572
+ }
573
+
574
+ default:
575
+ console.log('Uso: node kdd-memory.cjs [recall "query" | remember "entry" | index | sync | stats]');
576
+ }
577
+ }
578
+
579
+ module.exports = { recall, remember, indexMarkdown, syncFTS, getStats, bm25Search };