cipher-security 2.0.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.
Files changed (76) hide show
  1. package/bin/cipher.js +566 -0
  2. package/lib/api/billing.js +321 -0
  3. package/lib/api/compliance.js +693 -0
  4. package/lib/api/controls.js +1401 -0
  5. package/lib/api/index.js +49 -0
  6. package/lib/api/marketplace.js +467 -0
  7. package/lib/api/openai-proxy.js +383 -0
  8. package/lib/api/server.js +685 -0
  9. package/lib/autonomous/feedback-loop.js +554 -0
  10. package/lib/autonomous/framework.js +512 -0
  11. package/lib/autonomous/index.js +97 -0
  12. package/lib/autonomous/leaderboard.js +594 -0
  13. package/lib/autonomous/modes/architect.js +412 -0
  14. package/lib/autonomous/modes/blue.js +386 -0
  15. package/lib/autonomous/modes/incident.js +684 -0
  16. package/lib/autonomous/modes/privacy.js +369 -0
  17. package/lib/autonomous/modes/purple.js +294 -0
  18. package/lib/autonomous/modes/recon.js +250 -0
  19. package/lib/autonomous/parallel.js +587 -0
  20. package/lib/autonomous/researcher.js +583 -0
  21. package/lib/autonomous/runner.js +955 -0
  22. package/lib/autonomous/scheduler.js +615 -0
  23. package/lib/autonomous/task-parser.js +127 -0
  24. package/lib/autonomous/validators/forensic.js +266 -0
  25. package/lib/autonomous/validators/osint.js +216 -0
  26. package/lib/autonomous/validators/privacy.js +296 -0
  27. package/lib/autonomous/validators/purple.js +298 -0
  28. package/lib/autonomous/validators/sigma.js +248 -0
  29. package/lib/autonomous/validators/threat-model.js +363 -0
  30. package/lib/benchmark/agent.js +119 -0
  31. package/lib/benchmark/baselines.js +43 -0
  32. package/lib/benchmark/builder.js +143 -0
  33. package/lib/benchmark/config.js +35 -0
  34. package/lib/benchmark/coordinator.js +91 -0
  35. package/lib/benchmark/index.js +20 -0
  36. package/lib/benchmark/llm.js +58 -0
  37. package/lib/benchmark/models.js +137 -0
  38. package/lib/benchmark/reporter.js +103 -0
  39. package/lib/benchmark/runner.js +103 -0
  40. package/lib/benchmark/sandbox.js +96 -0
  41. package/lib/benchmark/scorer.js +32 -0
  42. package/lib/benchmark/solver.js +166 -0
  43. package/lib/benchmark/tools.js +62 -0
  44. package/lib/bot/bot.js +238 -0
  45. package/lib/brand.js +105 -0
  46. package/lib/commands.js +100 -0
  47. package/lib/complexity.js +377 -0
  48. package/lib/config.js +213 -0
  49. package/lib/gateway/client.js +309 -0
  50. package/lib/gateway/commands.js +991 -0
  51. package/lib/gateway/config-validate.js +109 -0
  52. package/lib/gateway/gateway.js +367 -0
  53. package/lib/gateway/index.js +62 -0
  54. package/lib/gateway/mode.js +309 -0
  55. package/lib/gateway/plugins.js +222 -0
  56. package/lib/gateway/prompt.js +214 -0
  57. package/lib/mcp/server.js +262 -0
  58. package/lib/memory/compressor.js +425 -0
  59. package/lib/memory/engine.js +763 -0
  60. package/lib/memory/evolution.js +668 -0
  61. package/lib/memory/index.js +58 -0
  62. package/lib/memory/orchestrator.js +506 -0
  63. package/lib/memory/retriever.js +515 -0
  64. package/lib/memory/synthesizer.js +333 -0
  65. package/lib/pipeline/async-scanner.js +510 -0
  66. package/lib/pipeline/binary-analysis.js +1043 -0
  67. package/lib/pipeline/dom-xss-scanner.js +435 -0
  68. package/lib/pipeline/github-actions.js +792 -0
  69. package/lib/pipeline/index.js +124 -0
  70. package/lib/pipeline/osint.js +498 -0
  71. package/lib/pipeline/sarif.js +373 -0
  72. package/lib/pipeline/scanner.js +880 -0
  73. package/lib/pipeline/template-manager.js +525 -0
  74. package/lib/pipeline/xss-scanner.js +353 -0
  75. package/lib/setup-wizard.js +288 -0
  76. package/package.json +31 -0
@@ -0,0 +1,763 @@
1
+ // Copyright (c) 2026 defconxt. All rights reserved.
2
+ // Licensed under AGPL-3.0 — see LICENSE file for details.
3
+ // CIPHER is a trademark of defconxt.
4
+
5
+ /**
6
+ * CIPHER Cross-Session Memory — Dual-Layer Indexing Engine
7
+ *
8
+ * Provides persistent memory across security engagements with two orthogonal
9
+ * index layers for high-recall, high-precision retrieval:
10
+ *
11
+ * 1. Lexical Layer — BM25 keyword index (SQLite FTS5, porter stemming)
12
+ * 2. Symbolic Layer — Structured metadata constraints (SQLite relational)
13
+ *
14
+ * Retrieval fuses both layers with reciprocal rank fusion (RRF).
15
+ *
16
+ * The semantic layer (LanceDB/embeddings) from the Python implementation is
17
+ * intentionally excluded per D050 — FTS5 replaces vector search for the
18
+ * Node.js stack.
19
+ *
20
+ * Architecture inspired by SimpleMem (tri-layer indexing) and ProjectDiscovery Neo
21
+ * (persistent memory compounding across engagements).
22
+ */
23
+
24
+ import Database from 'better-sqlite3';
25
+ import { randomUUID } from 'node:crypto';
26
+ import { mkdirSync } from 'node:fs';
27
+ import { dirname, join } from 'node:path';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Configuration
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const DEFAULT_TOP_K = 10;
34
+ const RRF_K = 60;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Enums
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** @enum {string} Categories of security memory entries. */
41
+ const MemoryType = Object.freeze({
42
+ FINDING: 'finding',
43
+ IOC: 'ioc',
44
+ TTP: 'ttp',
45
+ CREDENTIAL: 'credential',
46
+ HOST: 'host',
47
+ NETWORK: 'network',
48
+ CONFIG: 'config',
49
+ NOTE: 'note',
50
+ DECISION: 'decision',
51
+ ARTIFACT: 'artifact',
52
+ });
53
+
54
+ /** @enum {string} Confidence levels matching CIPHER output standards. */
55
+ const Confidence = Object.freeze({
56
+ CONFIRMED: 'confirmed',
57
+ INFERRED: 'inferred',
58
+ EXTERNAL: 'external',
59
+ UNCERTAIN: 'uncertain',
60
+ });
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // MemoryEntry
64
+ // ---------------------------------------------------------------------------
65
+
66
+ class MemoryEntry {
67
+ /**
68
+ * Atomic memory unit indexed across two orthogonal layers.
69
+ *
70
+ * Lexical: keywords + content → BM25 sparse index
71
+ * Symbolic: All typed metadata fields → SQL constraints
72
+ */
73
+ constructor(opts = {}) {
74
+ this.entryId = opts.entryId ?? randomUUID();
75
+ this.content = opts.content ?? '';
76
+ this.memoryType = opts.memoryType ?? MemoryType.NOTE;
77
+ this.confidence = opts.confidence ?? Confidence.CONFIRMED;
78
+
79
+ // Symbolic metadata
80
+ this.engagementId = opts.engagementId ?? '';
81
+ this.sourceSkill = opts.sourceSkill ?? '';
82
+ this.timestamp = opts.timestamp ?? '';
83
+ this.targets = opts.targets ?? [];
84
+ this.mitreAttack = opts.mitreAttack ?? [];
85
+ this.cveIds = opts.cveIds ?? [];
86
+ this.severity = opts.severity ?? '';
87
+ this.tags = opts.tags ?? [];
88
+ this.relatedEntries = opts.relatedEntries ?? [];
89
+
90
+ // Lexical metadata
91
+ this.keywords = opts.keywords ?? [];
92
+
93
+ // Lifecycle
94
+ this.createdAt = opts.createdAt ?? new Date().toISOString();
95
+ this.updatedAt = opts.updatedAt ?? '';
96
+ this.accessedAt = opts.accessedAt ?? '';
97
+ this.accessCount = opts.accessCount ?? 0;
98
+ this.decayScore = opts.decayScore ?? 1.0;
99
+ this.isArchived = opts.isArchived ?? false;
100
+ }
101
+
102
+ /** Serialize to a flat dict for SQLite storage. Array fields are JSON-encoded. */
103
+ toDict() {
104
+ return {
105
+ entry_id: this.entryId,
106
+ content: this.content,
107
+ memory_type: this.memoryType,
108
+ confidence: this.confidence,
109
+ engagement_id: this.engagementId,
110
+ source_skill: this.sourceSkill,
111
+ timestamp: this.timestamp,
112
+ targets: JSON.stringify(this.targets),
113
+ mitre_attack: JSON.stringify(this.mitreAttack),
114
+ cve_ids: JSON.stringify(this.cveIds),
115
+ severity: this.severity,
116
+ tags: JSON.stringify(this.tags),
117
+ related_entries: JSON.stringify(this.relatedEntries),
118
+ keywords: JSON.stringify(this.keywords),
119
+ created_at: this.createdAt,
120
+ updated_at: this.updatedAt,
121
+ accessed_at: this.accessedAt,
122
+ access_count: this.accessCount,
123
+ decay_score: this.decayScore,
124
+ is_archived: this.isArchived ? 1 : 0,
125
+ };
126
+ }
127
+
128
+ /** Deserialize from a SQLite row dict. */
129
+ static fromDict(d) {
130
+ return new MemoryEntry({
131
+ entryId: d.entry_id,
132
+ content: d.content,
133
+ memoryType: d.memory_type,
134
+ confidence: d.confidence,
135
+ engagementId: d.engagement_id ?? '',
136
+ sourceSkill: d.source_skill ?? '',
137
+ timestamp: d.timestamp ?? '',
138
+ targets: _parseJsonArray(d.targets),
139
+ mitreAttack: _parseJsonArray(d.mitre_attack),
140
+ cveIds: _parseJsonArray(d.cve_ids),
141
+ severity: d.severity ?? '',
142
+ tags: _parseJsonArray(d.tags),
143
+ relatedEntries: _parseJsonArray(d.related_entries),
144
+ keywords: _parseJsonArray(d.keywords),
145
+ createdAt: d.created_at ?? '',
146
+ updatedAt: d.updated_at ?? '',
147
+ accessedAt: d.accessed_at ?? '',
148
+ accessCount: d.access_count ?? 0,
149
+ decayScore: d.decay_score ?? 1.0,
150
+ isArchived: Boolean(d.is_archived),
151
+ });
152
+ }
153
+ }
154
+
155
+ /** Safely parse a JSON array field, returning [] on failure. */
156
+ function _parseJsonArray(val) {
157
+ if (Array.isArray(val)) return val;
158
+ if (!val) return [];
159
+ try {
160
+ const parsed = JSON.parse(val);
161
+ return Array.isArray(parsed) ? parsed : [];
162
+ } catch {
163
+ return [];
164
+ }
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Symbolic Layer — SQLite relational store with FTS5
169
+ // ---------------------------------------------------------------------------
170
+
171
+ class SymbolicStore {
172
+ /**
173
+ * SQLite-backed structured metadata store with FTS5 for lexical search.
174
+ * @param {string} dbPath — path to the SQLite database file
175
+ */
176
+ constructor(dbPath) {
177
+ mkdirSync(dirname(dbPath), { recursive: true });
178
+ this.db = new Database(dbPath);
179
+ this.db.pragma('journal_mode = WAL');
180
+ this._initSchema();
181
+ }
182
+
183
+ _initSchema() {
184
+ this.db.exec(`
185
+ CREATE TABLE IF NOT EXISTS entries (
186
+ entry_id TEXT PRIMARY KEY,
187
+ content TEXT NOT NULL,
188
+ memory_type TEXT NOT NULL,
189
+ confidence TEXT NOT NULL DEFAULT 'confirmed',
190
+ engagement_id TEXT DEFAULT '',
191
+ source_skill TEXT DEFAULT '',
192
+ timestamp TEXT DEFAULT '',
193
+ targets TEXT DEFAULT '[]',
194
+ mitre_attack TEXT DEFAULT '[]',
195
+ cve_ids TEXT DEFAULT '[]',
196
+ severity TEXT DEFAULT '',
197
+ tags TEXT DEFAULT '[]',
198
+ related_entries TEXT DEFAULT '[]',
199
+ keywords TEXT DEFAULT '[]',
200
+ created_at TEXT NOT NULL,
201
+ updated_at TEXT DEFAULT '',
202
+ accessed_at TEXT DEFAULT '',
203
+ access_count INTEGER DEFAULT 0,
204
+ decay_score REAL DEFAULT 1.0,
205
+ is_archived INTEGER DEFAULT 0
206
+ );
207
+
208
+ CREATE INDEX IF NOT EXISTS idx_engagement ON entries(engagement_id);
209
+ CREATE INDEX IF NOT EXISTS idx_memory_type ON entries(memory_type);
210
+ CREATE INDEX IF NOT EXISTS idx_severity ON entries(severity);
211
+ CREATE INDEX IF NOT EXISTS idx_source_skill ON entries(source_skill);
212
+ CREATE INDEX IF NOT EXISTS idx_decay ON entries(decay_score);
213
+
214
+ CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
215
+ entry_id UNINDEXED,
216
+ content,
217
+ keywords,
218
+ tokenize='porter unicode61'
219
+ );
220
+
221
+ CREATE TABLE IF NOT EXISTS engagements (
222
+ engagement_id TEXT PRIMARY KEY,
223
+ name TEXT NOT NULL,
224
+ client TEXT DEFAULT '',
225
+ engagement_type TEXT DEFAULT '',
226
+ status TEXT DEFAULT 'active',
227
+ created_at TEXT NOT NULL,
228
+ updated_at TEXT DEFAULT '',
229
+ metadata TEXT DEFAULT '{}'
230
+ );
231
+ `);
232
+ }
233
+
234
+ /**
235
+ * Store a memory entry in both the relational table and FTS index.
236
+ * @param {MemoryEntry} entry
237
+ * @returns {string} entry_id
238
+ */
239
+ store(entry) {
240
+ const d = entry.toDict();
241
+ const cols = Object.keys(d);
242
+ const placeholders = cols.map(() => '?').join(', ');
243
+ const colStr = cols.join(', ');
244
+
245
+ const insertEntry = this.db.prepare(
246
+ `INSERT OR REPLACE INTO entries (${colStr}) VALUES (${placeholders})`
247
+ );
248
+ const insertFts = this.db.prepare(
249
+ `INSERT OR REPLACE INTO entries_fts (entry_id, content, keywords) VALUES (?, ?, ?)`
250
+ );
251
+
252
+ const txn = this.db.transaction(() => {
253
+ insertEntry.run(...Object.values(d));
254
+ insertFts.run(entry.entryId, entry.content, entry.keywords.join(' '));
255
+ });
256
+ txn();
257
+
258
+ return entry.entryId;
259
+ }
260
+
261
+ /**
262
+ * Query by structured metadata constraints.
263
+ * @param {object} filters
264
+ * @returns {object[]}
265
+ */
266
+ searchSymbolic(filters = {}) {
267
+ const {
268
+ engagementId = '',
269
+ memoryType = '',
270
+ severity = '',
271
+ mitreTechnique = '',
272
+ cveId = '',
273
+ target = '',
274
+ tag = '',
275
+ limit = DEFAULT_TOP_K,
276
+ } = filters;
277
+
278
+ const conditions = ['is_archived = 0'];
279
+ const params = [];
280
+
281
+ if (engagementId) {
282
+ conditions.push('engagement_id = ?');
283
+ params.push(engagementId);
284
+ }
285
+ if (memoryType) {
286
+ conditions.push('memory_type = ?');
287
+ params.push(memoryType);
288
+ }
289
+ if (severity) {
290
+ conditions.push('severity = ?');
291
+ params.push(severity);
292
+ }
293
+ if (mitreTechnique) {
294
+ conditions.push('mitre_attack LIKE ?');
295
+ params.push(`%${mitreTechnique}%`);
296
+ }
297
+ if (cveId) {
298
+ conditions.push('cve_ids LIKE ?');
299
+ params.push(`%${cveId}%`);
300
+ }
301
+ if (target) {
302
+ conditions.push('targets LIKE ?');
303
+ params.push(`%${target}%`);
304
+ }
305
+ if (tag) {
306
+ conditions.push('tags LIKE ?');
307
+ params.push(`%${tag}%`);
308
+ }
309
+
310
+ const where = conditions.join(' AND ');
311
+ const stmt = this.db.prepare(
312
+ `SELECT * FROM entries WHERE ${where} ORDER BY decay_score DESC LIMIT ?`
313
+ );
314
+ return stmt.all(...params, limit);
315
+ }
316
+
317
+ /**
318
+ * BM25 keyword search via FTS5.
319
+ * @param {string} query
320
+ * @param {number} limit
321
+ * @returns {object[]}
322
+ */
323
+ searchLexical(query, limit = DEFAULT_TOP_K) {
324
+ // Sanitize query for FTS5: quote each token to avoid syntax errors
325
+ const tokens = query
326
+ .split(/\s+/)
327
+ .map((t) => t.trim())
328
+ .filter(Boolean);
329
+ if (tokens.length === 0) return [];
330
+
331
+ const ftsQuery = tokens.map((t) => `"${t}"`).join(' ');
332
+
333
+ try {
334
+ const stmt = this.db.prepare(`
335
+ SELECT e.*, rank FROM entries_fts f
336
+ JOIN entries e ON f.entry_id = e.entry_id
337
+ WHERE entries_fts MATCH ? AND e.is_archived = 0
338
+ ORDER BY rank LIMIT ?
339
+ `);
340
+ return stmt.all(ftsQuery, limit);
341
+ } catch {
342
+ // FTS5 query parse failure — return empty results (matching Python behavior)
343
+ return [];
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Get a single entry by ID. Updates access metadata on read.
349
+ * @param {string} entryId
350
+ * @returns {object|null}
351
+ */
352
+ get(entryId) {
353
+ const stmt = this.db.prepare('SELECT * FROM entries WHERE entry_id = ?');
354
+ const row = stmt.get(entryId);
355
+ if (!row) return null;
356
+
357
+ const now = new Date().toISOString();
358
+ this.db.prepare(
359
+ 'UPDATE entries SET accessed_at = ?, access_count = access_count + 1 WHERE entry_id = ?'
360
+ ).run(now, entryId);
361
+
362
+ return row;
363
+ }
364
+
365
+ /**
366
+ * Apply time-based decay to all active entries.
367
+ * @param {number} factor — decay multiplier (default 0.95)
368
+ */
369
+ decayAll(factor = 0.95) {
370
+ this.db.prepare(
371
+ 'UPDATE entries SET decay_score = decay_score * ? WHERE is_archived = 0'
372
+ ).run(factor);
373
+ }
374
+
375
+ /**
376
+ * Boost entry score when accessed or referenced.
377
+ * @param {string} entryId
378
+ * @param {number} amount — boost increment (default 0.1)
379
+ */
380
+ boost(entryId, amount = 0.1) {
381
+ this.db.prepare(
382
+ 'UPDATE entries SET decay_score = MIN(decay_score + ?, 1.0) WHERE entry_id = ?'
383
+ ).run(amount, entryId);
384
+ }
385
+
386
+ /**
387
+ * Archive entries with decay below threshold.
388
+ * @param {number} threshold
389
+ */
390
+ archiveStale(threshold = 0.1) {
391
+ this.db.prepare(
392
+ 'UPDATE entries SET is_archived = 1 WHERE decay_score < ? AND is_archived = 0'
393
+ ).run(threshold);
394
+ }
395
+
396
+ /** Return memory statistics: { total, active, archived, engagements }. */
397
+ stats() {
398
+ const row = this.db.prepare(`
399
+ SELECT
400
+ COUNT(*) as total,
401
+ COALESCE(SUM(CASE WHEN is_archived = 0 THEN 1 ELSE 0 END), 0) as active,
402
+ COALESCE(SUM(CASE WHEN is_archived = 1 THEN 1 ELSE 0 END), 0) as archived,
403
+ COUNT(DISTINCT engagement_id) as engagements
404
+ FROM entries
405
+ `).get();
406
+ return row ?? { total: 0, active: 0, archived: 0, engagements: 0 };
407
+ }
408
+
409
+ close() {
410
+ this.db.close();
411
+ }
412
+ }
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // Fusion Engine — Reciprocal Rank Fusion
416
+ // ---------------------------------------------------------------------------
417
+
418
+ /**
419
+ * Fuse multiple ranked lists using Reciprocal Rank Fusion.
420
+ *
421
+ * RRF(d) = Σ 1 / (k + rank_i(d)) for each result list i
422
+ *
423
+ * @param {string[][]} resultLists — list of ranked entry_id arrays (best first)
424
+ * @param {number} k — RRF constant (default 60)
425
+ * @returns {Array<[string, number]>} — fused ranked list of [entry_id, rrf_score]
426
+ */
427
+ function reciprocalRankFusion(resultLists, k = RRF_K) {
428
+ const scores = new Map();
429
+ for (const rankedList of resultLists) {
430
+ for (let rank = 0; rank < rankedList.length; rank++) {
431
+ const entryId = rankedList[rank];
432
+ scores.set(entryId, (scores.get(entryId) ?? 0) + 1.0 / (k + rank + 1));
433
+ }
434
+ }
435
+ return [...scores.entries()].sort((a, b) => b[1] - a[1]);
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Memory Engine — Unified interface
440
+ // ---------------------------------------------------------------------------
441
+
442
+ class CipherMemory {
443
+ /**
444
+ * Unified memory engine combining lexical and symbolic layers.
445
+ *
446
+ * Usage:
447
+ * const memory = new CipherMemory('/path/to/memory');
448
+ * memory.store(new MemoryEntry({ content: 'Found SQLi on /api/login', ... }));
449
+ * const results = memory.search('SQL injection vulnerabilities');
450
+ * memory.close();
451
+ *
452
+ * @param {string} memoryDir — directory for SQLite database
453
+ */
454
+ constructor(memoryDir) {
455
+ this.memoryDir = memoryDir;
456
+ mkdirSync(memoryDir, { recursive: true });
457
+ this.symbolic = new SymbolicStore(join(memoryDir, 'memory.db'));
458
+ }
459
+
460
+ /**
461
+ * Store entry in both symbolic and lexical layers.
462
+ * @param {MemoryEntry} entry
463
+ * @returns {string} entry_id
464
+ */
465
+ store(entry) {
466
+ const entryId = this.symbolic.store(entry);
467
+ return entryId;
468
+ }
469
+
470
+ /**
471
+ * Dual-layer fused search via RRF.
472
+ *
473
+ * 1. Lexical — "what words match?" (BM25 keyword matching)
474
+ * 2. Symbolic — "what metadata matches?" (structured SQL constraints)
475
+ *
476
+ * @param {string} query
477
+ * @param {object} filters — { engagementId, memoryType, severity, mitreTechnique }
478
+ * @param {number} limit
479
+ * @returns {MemoryEntry[]}
480
+ */
481
+ search(query, filters = {}, limit = DEFAULT_TOP_K) {
482
+ const resultLists = [];
483
+
484
+ // Layer 1: Lexical search (BM25)
485
+ const lexicalResults = this.symbolic.searchLexical(query, limit * 2);
486
+ if (lexicalResults.length > 0) {
487
+ resultLists.push(lexicalResults.map((r) => r.entry_id));
488
+ }
489
+
490
+ // Layer 2: Symbolic search (structured)
491
+ const symbolicResults = this.symbolic.searchSymbolic({
492
+ engagementId: filters.engagementId ?? '',
493
+ memoryType: filters.memoryType ?? '',
494
+ severity: filters.severity ?? '',
495
+ mitreTechnique: filters.mitreTechnique ?? '',
496
+ limit: limit * 2,
497
+ });
498
+ if (symbolicResults.length > 0) {
499
+ resultLists.push(symbolicResults.map((r) => r.entry_id));
500
+ }
501
+
502
+ // Fuse with RRF
503
+ if (resultLists.length === 0) return [];
504
+
505
+ const fused = reciprocalRankFusion(resultLists);
506
+
507
+ // Fetch full entries for top results
508
+ const entries = [];
509
+ for (const [entryId] of fused.slice(0, limit)) {
510
+ const row = this.symbolic.get(entryId);
511
+ if (row) {
512
+ const entry = MemoryEntry.fromDict(row);
513
+ this.symbolic.boost(entryId, 0.05);
514
+ entries.push(entry);
515
+ }
516
+ }
517
+
518
+ return entries;
519
+ }
520
+
521
+ /**
522
+ * Get all active memories for an engagement, sorted by relevance.
523
+ * @param {string} engagementId
524
+ * @returns {MemoryEntry[]}
525
+ */
526
+ getEngagementContext(engagementId) {
527
+ const results = this.symbolic.searchSymbolic({
528
+ engagementId,
529
+ limit: 100,
530
+ });
531
+ return results.map((r) => MemoryEntry.fromDict(r));
532
+ }
533
+
534
+ /**
535
+ * Run memory maintenance: decay, archive stale, return stats.
536
+ * @returns {object}
537
+ */
538
+ consolidate() {
539
+ this.symbolic.decayAll(0.98);
540
+ this.symbolic.archiveStale(0.05);
541
+ const stats = this.symbolic.stats();
542
+ return stats;
543
+ }
544
+
545
+ /** Return memory statistics: { total, active, archived, engagements }. */
546
+ stats() {
547
+ return this.symbolic.stats();
548
+ }
549
+
550
+ close() {
551
+ this.symbolic.close();
552
+ }
553
+ }
554
+
555
+ // ---------------------------------------------------------------------------
556
+ // Memory Consolidator
557
+ // ---------------------------------------------------------------------------
558
+
559
+ class MemoryConsolidator {
560
+ /**
561
+ * Memory consolidation worker — decay, merge, prune.
562
+ *
563
+ * 1. Decay — reduce importance of old entries over time
564
+ * 2. Merge — combine near-duplicate entries with high textual similarity
565
+ * 3. Prune — archive entries below importance threshold
566
+ *
567
+ * @param {CipherMemory} memory
568
+ */
569
+ constructor(memory) {
570
+ this.memory = memory;
571
+ }
572
+
573
+ /**
574
+ * Combined similarity: Jaccard trigrams + TF-IDF cosine (no external deps).
575
+ * Returns the max of both methods for robust near-duplicate detection.
576
+ * @param {string} a
577
+ * @param {string} b
578
+ * @returns {number} similarity score 0–1
579
+ */
580
+ textSimilarity(a, b) {
581
+ const jaccard = MemoryConsolidator.jaccardTrigrams(a, b);
582
+ const cosine = MemoryConsolidator.tfidfCosine(a, b);
583
+ return Math.max(jaccard, cosine);
584
+ }
585
+
586
+ /**
587
+ * Jaccard similarity on word trigrams.
588
+ * @param {string} a
589
+ * @param {string} b
590
+ * @returns {number}
591
+ */
592
+ static jaccardTrigrams(a, b) {
593
+ const trigrams = (text) => {
594
+ const words = text.toLowerCase().split(/\s+/).filter(Boolean);
595
+ if (words.length < 3) return new Set(words);
596
+ const tgs = new Set();
597
+ for (let i = 0; i <= words.length - 3; i++) {
598
+ tgs.add(words.slice(i, i + 3).join(' '));
599
+ }
600
+ return tgs;
601
+ };
602
+
603
+ const tgA = trigrams(a);
604
+ const tgB = trigrams(b);
605
+ if (tgA.size === 0 || tgB.size === 0) return 0.0;
606
+
607
+ let intersection = 0;
608
+ for (const t of tgA) {
609
+ if (tgB.has(t)) intersection++;
610
+ }
611
+ const union = new Set([...tgA, ...tgB]).size;
612
+ return union === 0 ? 0.0 : intersection / union;
613
+ }
614
+
615
+ /**
616
+ * TF-IDF cosine similarity using pure JS (no numpy/sklearn).
617
+ * @param {string} a
618
+ * @param {string} b
619
+ * @returns {number}
620
+ */
621
+ static tfidfCosine(a, b) {
622
+ const wordsA = a.toLowerCase().split(/\s+/).filter(Boolean);
623
+ const wordsB = b.toLowerCase().split(/\s+/).filter(Boolean);
624
+ if (wordsA.length === 0 || wordsB.length === 0) return 0.0;
625
+
626
+ const tfA = new Map();
627
+ for (const w of wordsA) tfA.set(w, (tfA.get(w) ?? 0) + 1);
628
+ const tfB = new Map();
629
+ for (const w of wordsB) tfB.set(w, (tfB.get(w) ?? 0) + 1);
630
+
631
+ const vocab = new Set([...tfA.keys(), ...tfB.keys()]);
632
+
633
+ // IDF: treat the two docs as the corpus
634
+ const docFreq = new Map();
635
+ for (const w of vocab) {
636
+ docFreq.set(w, (tfA.has(w) ? 1 : 0) + (tfB.has(w) ? 1 : 0));
637
+ }
638
+
639
+ // TF-IDF vectors
640
+ const tfidfVec = (tf) => {
641
+ const total = [...tf.values()].reduce((s, v) => s + v, 0);
642
+ const vec = new Map();
643
+ for (const w of vocab) {
644
+ vec.set(w, ((tf.get(w) ?? 0) / total) * Math.log(2.0 / docFreq.get(w) + 1));
645
+ }
646
+ return vec;
647
+ };
648
+
649
+ const vecA = tfidfVec(tfA);
650
+ const vecB = tfidfVec(tfB);
651
+
652
+ // Cosine similarity
653
+ let dot = 0;
654
+ let magA = 0;
655
+ let magB = 0;
656
+ for (const w of vocab) {
657
+ const va = vecA.get(w) ?? 0;
658
+ const vb = vecB.get(w) ?? 0;
659
+ dot += va * vb;
660
+ magA += va * va;
661
+ magB += vb * vb;
662
+ }
663
+ magA = Math.sqrt(magA);
664
+ magB = Math.sqrt(magB);
665
+ if (magA === 0 || magB === 0) return 0.0;
666
+ return dot / (magA * magB);
667
+ }
668
+
669
+ /**
670
+ * Merge entries with textual similarity above threshold.
671
+ * Keeps the higher-confidence entry and appends context from the other.
672
+ * @param {number} threshold
673
+ * @returns {object} { checked, merged, pairs }
674
+ */
675
+ mergeSimilar(threshold = 0.85) {
676
+ let checked = 0;
677
+ let mergeCount = 0;
678
+ const pairs = [];
679
+ const store = this.memory.symbolic;
680
+
681
+ // Get all active entries grouped by type
682
+ for (const memoryType of ['finding', 'ioc', 'ttp', 'note']) {
683
+ const results = store.searchSymbolic({ memoryType, limit: 500 });
684
+ const entries = results.map((r) => MemoryEntry.fromDict(r));
685
+
686
+ for (let i = 0; i < entries.length; i++) {
687
+ const entryA = entries[i];
688
+ for (let j = i + 1; j < entries.length; j++) {
689
+ const entryB = entries[j];
690
+ checked++;
691
+ const sim = this.textSimilarity(entryA.content, entryB.content);
692
+ if (sim >= threshold) {
693
+ // Keep the one with higher decay score
694
+ const keeper =
695
+ entryA.decayScore >= entryB.decayScore ? entryA : entryB;
696
+ const discard = keeper === entryA ? entryB : entryA;
697
+
698
+ // Combine unique tags
699
+ keeper.tags = [...new Set([...keeper.tags, ...discard.tags])];
700
+
701
+ // Boost decay score of keeper
702
+ keeper.decayScore = Math.min(1.0, keeper.decayScore + 0.1);
703
+
704
+ // Re-store keeper
705
+ store.store(keeper);
706
+
707
+ // Archive the merged entry
708
+ discard.isArchived = true;
709
+ discard.decayScore = 0.0;
710
+ store.store(discard);
711
+
712
+ mergeCount++;
713
+ pairs.push({
714
+ kept: keeper.entryId,
715
+ merged: discard.entryId,
716
+ similarity: Math.round(sim * 1000) / 1000,
717
+ });
718
+ }
719
+ }
720
+ }
721
+ }
722
+
723
+ return { checked, merged: mergeCount, pairs };
724
+ }
725
+
726
+ /**
727
+ * Run full consolidation cycle: decay → merge → prune.
728
+ * @returns {object}
729
+ */
730
+ fullConsolidation() {
731
+ const results = {};
732
+
733
+ // 1. Decay + archive
734
+ const decayStats = this.memory.consolidate();
735
+ results.decay = decayStats;
736
+
737
+ // 2. Merge
738
+ const mergeStats = this.mergeSimilar();
739
+ results.merge = mergeStats;
740
+
741
+ // 3. Summary
742
+ results.summary = {
743
+ decayed: decayStats.total ?? 0,
744
+ archived: decayStats.archived ?? 0,
745
+ merged: mergeStats.merged,
746
+ pairsChecked: mergeStats.checked,
747
+ };
748
+
749
+ return results;
750
+ }
751
+ }
752
+
753
+ export {
754
+ MemoryType,
755
+ Confidence,
756
+ MemoryEntry,
757
+ SymbolicStore,
758
+ CipherMemory,
759
+ MemoryConsolidator,
760
+ reciprocalRankFusion,
761
+ DEFAULT_TOP_K,
762
+ RRF_K,
763
+ };