agentic-kdd 3.3.1 → 3.5.2
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/.cursorrules +169 -0
- package/AGENTS.md +173 -0
- package/CLAUDE.md +375 -0
- package/autonomous-decision.cjs +180 -0
- package/bin/akdd.js +31 -0
- package/kdd-memory.cjs +579 -0
- package/knowledge-validator.cjs +408 -0
- package/package.json +1 -1
- package/tdd-gate.cjs +471 -0
- package/telemetry.cjs +400 -0
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 };
|