bus-agent 2.3.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.
package/lib/memory.js ADDED
@@ -0,0 +1,667 @@
1
+ /**
2
+ * CoCo Memory Layer — Agent Memory with Vector Search
3
+ *
4
+ * Dual-mode search:
5
+ * 1. TF-IDF keyword search (built-in, zero deps, always available)
6
+ * 2. Vector similarity search (optional — requires Ollama or OpenAI)
7
+ *
8
+ * Storage:
9
+ * .bus/memory/{agent}/
10
+ * memories.jsonl ← Append-only log (JSONL)
11
+ * index.json ← Inverted TF-IDF index
12
+ * vectors.json ← Flat vector store for ANN search
13
+ * config.json ← Embedding provider config
14
+ */
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const MEMORY_DIR = 'memory';
19
+
20
+ // ═══════════════════════════════════════════════════════
21
+ // Flat Vector Index — pure JS, no native deps
22
+ // ═══════════════════════════════════════════════════════
23
+
24
+ class FlatVectorIndex {
25
+ constructor(storeDir) {
26
+ this.storeDir = storeDir;
27
+ this.vectors = {}; // { id: [0.1, 0.2, ...] }
28
+ this.dimension = 0;
29
+ this._load();
30
+ }
31
+
32
+ /**
33
+ * Add a vector to the index
34
+ */
35
+ add(id, vector) {
36
+ if (this.dimension === 0 && vector.length > 0) this.dimension = vector.length;
37
+ this.vectors[id] = vector;
38
+ this._save();
39
+ }
40
+
41
+ /**
42
+ * Remove a vector
43
+ */
44
+ remove(id) {
45
+ delete this.vectors[id];
46
+ this._save();
47
+ }
48
+
49
+ /**
50
+ * Search by query vector — brute-force cosine similarity
51
+ * O(n) per search — fine for <10k vectors
52
+ */
53
+ search(queryVector, { limit = 10, threshold = 0.0 } = {}) {
54
+ const ids = Object.keys(this.vectors);
55
+ if (ids.length === 0 || queryVector.length === 0) return [];
56
+
57
+ const scored = [];
58
+ for (const id of ids) {
59
+ const vec = this.vectors[id];
60
+ if (!vec || vec.length !== queryVector.length) continue;
61
+ const sim = this._cosineSimilarity(queryVector, vec);
62
+ if (sim >= threshold) {
63
+ scored.push({ id, score: Math.round(sim * 1000) / 1000 });
64
+ }
65
+ }
66
+
67
+ scored.sort((a, b) => b.score - a.score);
68
+ return scored.slice(0, limit);
69
+ }
70
+
71
+ /**
72
+ * Get vector count
73
+ */
74
+ get size() {
75
+ return Object.keys(this.vectors).length;
76
+ }
77
+
78
+ /**
79
+ * Clear all vectors
80
+ */
81
+ clear() {
82
+ this.vectors = {};
83
+ this.dimension = 0;
84
+ this._save();
85
+ }
86
+
87
+ _cosineSimilarity(a, b) {
88
+ let dot = 0, magA = 0, magB = 0;
89
+ for (let i = 0; i < a.length; i++) {
90
+ dot += a[i] * b[i];
91
+ magA += a[i] * a[i];
92
+ magB += b[i] * b[i];
93
+ }
94
+ const denom = Math.sqrt(magA) * Math.sqrt(magB);
95
+ return denom === 0 ? 0 : dot / denom;
96
+ }
97
+
98
+ _save() {
99
+ const data = {
100
+ dimension: this.dimension,
101
+ count: Object.keys(this.vectors).length,
102
+ vectors: this.vectors,
103
+ };
104
+ fs.writeFileSync(path.join(this.storeDir, 'vectors.json'), JSON.stringify(data), 'utf-8');
105
+ }
106
+
107
+ _load() {
108
+ const p = path.join(this.storeDir, 'vectors.json');
109
+ if (fs.existsSync(p)) {
110
+ try {
111
+ const data = JSON.parse(fs.readFileSync(p, 'utf-8'));
112
+ this.dimension = data.dimension || 0;
113
+ this.vectors = data.vectors || {};
114
+ } catch { this.vectors = {}; }
115
+ }
116
+ }
117
+ }
118
+
119
+ // ═══════════════════════════════════════════════════════
120
+ // Embedding Engine — Ollama / OpenAI / Custom
121
+ // ═══════════════════════════════════════════════════════
122
+
123
+ class EmbeddingEngine {
124
+ constructor(config) {
125
+ this.config = config || {};
126
+ }
127
+
128
+ /**
129
+ * Generate embedding for a single text
130
+ */
131
+ async embed(text) {
132
+ const provider = this.config.provider || 'keyword';
133
+ switch (provider) {
134
+ case 'ollama': return this._ollamaEmbed(text);
135
+ case 'openai': return this._openaiEmbed(text);
136
+ case 'custom': return this._customEmbed(text);
137
+ default: return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Generate embeddings for multiple texts (batched)
143
+ */
144
+ async embedBatch(texts) {
145
+ if (texts.length === 0) return [];
146
+ const provider = this.config.provider || 'keyword';
147
+
148
+ switch (provider) {
149
+ case 'ollama': return this._ollamaEmbedBatch(texts);
150
+ case 'openai': return this._openaiEmbedBatch(texts);
151
+ case 'custom': return this._customEmbedBatch(texts);
152
+ default: return texts.map(() => null);
153
+ }
154
+ }
155
+
156
+ async _ollamaEmbed(text) {
157
+ const base = this.config.endpoint || 'http://localhost:11434';
158
+ // Ollama 0.3+ uses /api/embed, older uses /api/embeddings
159
+ const url = base.replace(/\/+$/, '') + '/api/embed';
160
+ const model = this.config.model || 'nomic-embed-text';
161
+
162
+ const res = await fetch(url, {
163
+ method: 'POST',
164
+ headers: { 'Content-Type': 'application/json' },
165
+ body: JSON.stringify({ model, input: text }),
166
+ });
167
+
168
+ if (!res.ok) throw new Error(`Ollama embedding error: ${res.status}`);
169
+ const data = await res.json();
170
+ return data.embeddings && data.embeddings[0];
171
+ }
172
+
173
+ async _ollamaEmbedBatch(texts) {
174
+ const base = this.config.endpoint || 'http://localhost:11434';
175
+ const url = base.replace(/\/+$/, '') + '/api/embed';
176
+ const model = this.config.model || 'nomic-embed-text';
177
+
178
+ const res = await fetch(url, {
179
+ method: 'POST',
180
+ headers: { 'Content-Type': 'application/json' },
181
+ body: JSON.stringify({ model, input: texts }),
182
+ });
183
+
184
+ if (!res.ok) throw new Error(`Ollama embedding error: ${res.status}`);
185
+ const data = await res.json();
186
+ return data.embeddings;
187
+ }
188
+
189
+ async _openaiEmbed(text) {
190
+ const url = this.config.endpoint || 'https://api.openai.com/v1/embeddings';
191
+ const model = this.config.model || 'text-embedding-3-small';
192
+ const key = this.config.api_key;
193
+
194
+ if (!key) throw new Error('OpenAI API key required');
195
+
196
+ const res = await fetch(url, {
197
+ method: 'POST',
198
+ headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' },
199
+ body: JSON.stringify({ input: text, model }),
200
+ });
201
+
202
+ if (!res.ok) throw new Error(`OpenAI embedding error: ${res.status}`);
203
+ const data = await res.json();
204
+ return data.data[0].embedding;
205
+ }
206
+
207
+ async _openaiEmbedBatch(texts) {
208
+ const url = this.config.endpoint || 'https://api.openai.com/v1/embeddings';
209
+ const model = this.config.model || 'text-embedding-3-small';
210
+ const key = this.config.api_key;
211
+
212
+ if (!key) throw new Error('OpenAI API key required');
213
+
214
+ const res = await fetch(url, {
215
+ method: 'POST',
216
+ headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' },
217
+ body: JSON.stringify({ input: texts, model }),
218
+ });
219
+
220
+ if (!res.ok) throw new Error(`OpenAI embedding error: ${res.status}`);
221
+ const data = await res.json();
222
+ return data.data.map(d => d.embedding);
223
+ }
224
+
225
+ async _customEmbed(text) {
226
+ if (!this.config.endpoint) throw new Error('Custom embedding endpoint required');
227
+
228
+ const res = await fetch(this.config.endpoint, {
229
+ method: 'POST',
230
+ headers: { 'Content-Type': 'application/json', ...(this.config.headers || {}) },
231
+ body: JSON.stringify({ text, model: this.config.model }),
232
+ });
233
+
234
+ if (!res.ok) throw new Error(`Custom embedding error: ${res.status}`);
235
+ return await res.json();
236
+ }
237
+
238
+ async _customEmbedBatch(texts) {
239
+ const results = [];
240
+ for (const text of texts) {
241
+ results.push(await this._customEmbed(text));
242
+ }
243
+ return results;
244
+ }
245
+ }
246
+
247
+ // ═══════════════════════════════════════════════════════
248
+ // MemoryStore — main API
249
+ // ═══════════════════════════════════════════════════════
250
+
251
+ class MemoryStore {
252
+ constructor(busDir) {
253
+ this.busDir = busDir;
254
+ this.baseDir = path.join(busDir, MEMORY_DIR);
255
+ this._ensureDir();
256
+ this._config = this._loadConfig();
257
+ this.embedder = new EmbeddingEngine(this._config);
258
+ }
259
+
260
+ // ── Public API ──
261
+
262
+ /**
263
+ * Store a memory entry for an agent
264
+ * Auto-embeds if embedding provider is configured
265
+ */
266
+ async store(agent, opts = {}) {
267
+ const { key, content, namespace = 'default', metadata = {}, ttl } = opts;
268
+ if (!content) throw new Error('content is required');
269
+
270
+ const agentDir = this._agentDir(agent);
271
+ this._ensureAgentDir(agent);
272
+
273
+ const entry = {
274
+ id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
275
+ key: key || null,
276
+ content: typeof content === 'string' ? content : JSON.stringify(content),
277
+ namespace,
278
+ metadata: { ...metadata, stored_at: new Date().toISOString() },
279
+ ttl: ttl || null,
280
+ tokens: this._tokenize(content),
281
+ };
282
+
283
+ // Append to JSONL
284
+ const line = JSON.stringify(entry) + '\n';
285
+ fs.appendFileSync(path.join(agentDir, 'memories.jsonl'), line, 'utf-8');
286
+
287
+ // Update TF-IDF index
288
+ this._updateIndex(agent, entry);
289
+
290
+ // Generate & store vector embedding (if provider configured)
291
+ if (this._config.provider !== 'keyword') {
292
+ try {
293
+ const vector = await this.embedder.embed(entry.content);
294
+ if (vector && Array.isArray(vector)) {
295
+ const vi = this._getVectorIndex(agent);
296
+ vi.add(entry.id, vector);
297
+ }
298
+ } catch (err) {
299
+ console.warn(` ⚠️ Embedding failed for "${entry.content.substring(0, 40)}...": ${err.message}`);
300
+ }
301
+ }
302
+
303
+ return entry.id;
304
+ }
305
+
306
+ /**
307
+ * Search memories — uses vector search if configured, TF-IDF fallback
308
+ */
309
+ async search(agent, query, opts = {}) {
310
+ const { limit = 10, namespace, threshold = 0.0 } = opts;
311
+ const entries = this._loadMemories(agent, namespace);
312
+ if (entries.length === 0) return [];
313
+
314
+ // Try vector search first (if provider configured and we have vectors)
315
+ if (this._config.provider !== 'keyword') {
316
+ try {
317
+ const queryVector = await this.embedder.embed(query);
318
+ if (queryVector && Array.isArray(queryVector)) {
319
+ const vi = this._getVectorIndex(agent);
320
+ if (vi.size > 0) {
321
+ const vecResults = vi.search(queryVector, { limit: limit * 2, threshold });
322
+
323
+ // Map vector results back to full entries
324
+ const entryMap = {};
325
+ for (const e of entries) entryMap[e.id] = e;
326
+
327
+ const results = [];
328
+ for (const vr of vecResults) {
329
+ const entry = entryMap[vr.id];
330
+ if (entry) {
331
+ results.push({ ...entry, score: vr.score, search_type: 'vector' });
332
+ if (results.length >= limit) break;
333
+ }
334
+ }
335
+
336
+ if (results.length > 0) return results;
337
+ }
338
+ }
339
+ } catch {
340
+ // Fall through to TF-IDF
341
+ }
342
+ }
343
+
344
+ // TF-IDF fallback
345
+ const queryTokens = this._tokenize(query);
346
+ const scored = [];
347
+
348
+ for (const entry of entries) {
349
+ let score = this._cosineSimilarity(queryTokens, entry.tokens || {});
350
+
351
+ // Recency boost: 30-day half-life
352
+ const ageHours = (Date.now() - new Date(entry.metadata.stored_at).getTime()) / 3600000;
353
+ const recencyBoost = Math.max(0, 1 - (ageHours / 720));
354
+ score = score * 0.7 + recencyBoost * 0.3;
355
+
356
+ if (score >= threshold) {
357
+ scored.push({ ...entry, score: Math.round(score * 1000) / 1000, search_type: 'keyword' });
358
+ }
359
+ }
360
+
361
+ scored.sort((a, b) => b.score - a.score);
362
+ return scored.slice(0, limit);
363
+ }
364
+
365
+ /**
366
+ * Recall a memory by ID or key
367
+ */
368
+ recall(agent, identifier) {
369
+ const entries = this._loadMemories(agent);
370
+ return entries.find(e => e.id === identifier || e.key === identifier) || null;
371
+ }
372
+
373
+ /**
374
+ * List memories for an agent
375
+ */
376
+ list(agent, opts = {}) {
377
+ const { namespace, limit = 50, offset = 0 } = opts;
378
+ let entries = this._loadMemories(agent, namespace);
379
+ entries.sort((a, b) => new Date(b.metadata.stored_at) - new Date(a.metadata.stored_at));
380
+ return entries.slice(offset, offset + limit);
381
+ }
382
+
383
+ /**
384
+ * Forget/delete a memory by ID
385
+ */
386
+ forget(agent, id) {
387
+ const agentDir = this._agentDir(agent);
388
+ const memFile = path.join(agentDir, 'memories.jsonl');
389
+ if (!fs.existsSync(memFile)) return false;
390
+
391
+ const lines = fs.readFileSync(memFile, 'utf-8').split('\n').filter(Boolean);
392
+ const filtered = lines.filter(line => {
393
+ try { const e = JSON.parse(line); return e.id !== id; } catch { return true; }
394
+ });
395
+
396
+ if (lines.length === filtered.length) return false;
397
+ fs.writeFileSync(memFile, filtered.join('\n') + '\n', 'utf-8');
398
+ this._rebuildIndex(agent);
399
+
400
+ // Remove from vector index
401
+ const vi = this._getVectorIndex(agent);
402
+ vi.remove(id);
403
+
404
+ return true;
405
+ }
406
+
407
+ /**
408
+ * Get memory stats
409
+ */
410
+ stats(agent) {
411
+ const entries = this._loadMemories(agent);
412
+ const byNamespace = {};
413
+ let totalSize = 0;
414
+ for (const e of entries) {
415
+ byNamespace[e.namespace] = (byNamespace[e.namespace] || 0) + 1;
416
+ totalSize += (e.content || '').length;
417
+ }
418
+ const vi = this._getVectorIndex(agent);
419
+ return {
420
+ total: entries.length,
421
+ namespaces: byNamespace,
422
+ size_bytes: totalSize,
423
+ vectors: vi.size,
424
+ vector_dimension: vi.dimension || 0,
425
+ embed_provider: this._config.provider || 'keyword',
426
+ oldest: entries.length > 0 ? entries.reduce((a, b) =>
427
+ new Date(a.metadata.stored_at) < new Date(b.metadata.stored_at) ? a : b
428
+ ).metadata.stored_at : null,
429
+ newest: entries.length > 0 ? entries.reduce((a, b) =>
430
+ new Date(a.metadata.stored_at) > new Date(b.metadata.stored_at) ? a : b
431
+ ).metadata.stored_at : null,
432
+ };
433
+ }
434
+
435
+ /**
436
+ * Archive old messages → memory
437
+ */
438
+ async archive(agent, opts = {}) {
439
+ const { maxAgeDays = 7, deleteAfter = true } = opts;
440
+ const msgsDir = path.join(this.busDir, 'messages', agent);
441
+ if (!fs.existsSync(msgsDir)) return { archived: 0 };
442
+
443
+ const cutoff = Date.now() - (maxAgeDays * 86400000);
444
+ const files = fs.readdirSync(msgsDir).filter(f => f.endsWith('.json'));
445
+ let archived = 0;
446
+
447
+ for (const f of files) {
448
+ try {
449
+ const msg = JSON.parse(fs.readFileSync(path.join(msgsDir, f), 'utf-8'));
450
+ const msgTime = new Date(msg.timestamp).getTime();
451
+ if (msgTime < cutoff) {
452
+ await this.store(agent, {
453
+ content: `[${msg.from}] ${msg.message}`,
454
+ namespace: 'archive',
455
+ metadata: {
456
+ original_id: msg.id, from: msg.from, to: msg.to,
457
+ original_timestamp: msg.timestamp, archived_at: new Date().toISOString(),
458
+ },
459
+ });
460
+ archived++;
461
+ if (deleteAfter) fs.unlinkSync(path.join(msgsDir, f));
462
+ }
463
+ } catch {}
464
+ }
465
+
466
+ return { archived };
467
+ }
468
+
469
+ /**
470
+ * Clear memories for an agent
471
+ */
472
+ clear(agent, namespace) {
473
+ const agentDir = this._agentDir(agent);
474
+ const memFile = path.join(agentDir, 'memories.jsonl');
475
+ if (!fs.existsSync(memFile)) return 0;
476
+
477
+ if (namespace) {
478
+ const lines = fs.readFileSync(memFile, 'utf-8').split('\n').filter(Boolean);
479
+ const filtered = lines.filter(line => {
480
+ try { const e = JSON.parse(line); return e.namespace !== namespace; } catch { return true; }
481
+ });
482
+ const removed = lines.length - filtered.length;
483
+ fs.writeFileSync(memFile, filtered.join('\n') + '\n', 'utf-8');
484
+ this._rebuildIndex(agent);
485
+ // Vectors are stored separately in vectors.json — clear them entirely since
486
+ // we can't easily match which vectors to remove per entry
487
+ this._getVectorIndex(agent).clear();
488
+ return removed;
489
+ }
490
+
491
+ const count = fs.readFileSync(memFile, 'utf-8').split('\n').filter(Boolean).length;
492
+ fs.writeFileSync(memFile, '', 'utf-8');
493
+ const idxPath = path.join(agentDir, 'index.json');
494
+ if (fs.existsSync(idxPath)) fs.unlinkSync(idxPath);
495
+ const vi = this._getVectorIndex(agent);
496
+ vi.clear();
497
+ return count;
498
+ }
499
+
500
+ /**
501
+ * Rebuild vector index for an agent from scratch
502
+ */
503
+ async rebuildVectors(agent) {
504
+ const entries = this._loadMemories(agent);
505
+ const vi = this._getVectorIndex(agent);
506
+ vi.clear();
507
+
508
+ if (this._config.provider === 'keyword') {
509
+ return { rebuilt: 0, reason: 'No embedding provider configured. Use memory configure first.' };
510
+ }
511
+
512
+ let success = 0;
513
+ let failed = 0;
514
+
515
+ for (const entry of entries) {
516
+ try {
517
+ const vector = await this.embedder.embed(entry.content);
518
+ if (vector && Array.isArray(vector)) {
519
+ vi.add(entry.id, vector);
520
+ success++;
521
+ }
522
+ } catch {
523
+ failed++;
524
+ }
525
+ }
526
+
527
+ return { rebuilt: success, failed };
528
+ }
529
+
530
+ // ── Embedding Configuration ──
531
+
532
+ configure(opts = {}) {
533
+ const cfg = { ...this._config, ...opts };
534
+ if (opts.provider) cfg.provider = opts.provider;
535
+ if (opts.api_key) cfg.api_key = opts.api_key;
536
+ if (opts.endpoint) cfg.endpoint = opts.endpoint;
537
+ if (opts.model) cfg.model = opts.model;
538
+ this._config = cfg;
539
+ this._saveConfig();
540
+ this.embedder = new EmbeddingEngine(cfg);
541
+ return cfg;
542
+ }
543
+
544
+ getConfig() {
545
+ return { ...this._config };
546
+ }
547
+
548
+ // ── Internals ──
549
+
550
+ _agentDir(agent) {
551
+ return path.join(this.baseDir, agent);
552
+ }
553
+
554
+ _ensureDir() {
555
+ if (!fs.existsSync(this.baseDir)) fs.mkdirSync(this.baseDir, { recursive: true });
556
+ }
557
+
558
+ _ensureAgentDir(agent) {
559
+ const dir = this._agentDir(agent);
560
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
561
+ }
562
+
563
+ _getVectorIndex(agent) {
564
+ const dir = this._agentDir(agent);
565
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
566
+ if (!this._vectorIndices) this._vectorIndices = {};
567
+ if (!this._vectorIndices[agent]) {
568
+ this._vectorIndices[agent] = new FlatVectorIndex(dir);
569
+ }
570
+ return this._vectorIndices[agent];
571
+ }
572
+
573
+ _loadMemories(agent, namespace) {
574
+ const memFile = path.join(this._agentDir(agent), 'memories.jsonl');
575
+ if (!fs.existsSync(memFile)) return [];
576
+
577
+ const lines = fs.readFileSync(memFile, 'utf-8').split('\n').filter(Boolean);
578
+ const entries = [];
579
+ for (const line of lines) {
580
+ try {
581
+ const entry = JSON.parse(line);
582
+ if (entry.ttl) {
583
+ const age = Date.now() - new Date(entry.metadata.stored_at).getTime();
584
+ if (age > entry.ttl * 1000) continue;
585
+ }
586
+ if (namespace && entry.namespace !== namespace) continue;
587
+ entries.push(entry);
588
+ } catch {}
589
+ }
590
+ return entries;
591
+ }
592
+
593
+ _tokenize(text) {
594
+ if (!text) return {};
595
+ const str = typeof text === 'string' ? text : JSON.stringify(text);
596
+ const tokens = str.toLowerCase()
597
+ .replace(/[^a-z0-9\u0E00-\u0E7F\s]/g, ' ')
598
+ .split(/\s+/)
599
+ .filter(t => t.length > 1 && t.length < 50);
600
+
601
+ const tf = {};
602
+ for (const t of tokens) tf[t] = (tf[t] || 0) + 1;
603
+ const len = tokens.length || 1;
604
+ for (const t of Object.keys(tf)) tf[t] /= len;
605
+ return tf;
606
+ }
607
+
608
+ _cosineSimilarity(a, b) {
609
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
610
+ let dot = 0, magA = 0, magB = 0;
611
+ for (const k of keys) {
612
+ const va = a[k] || 0;
613
+ const vb = b[k] || 0;
614
+ dot += va * vb;
615
+ magA += va * va;
616
+ magB += vb * vb;
617
+ }
618
+ const denom = Math.sqrt(magA) * Math.sqrt(magB);
619
+ return denom === 0 ? 0 : dot / denom;
620
+ }
621
+
622
+ _updateIndex(agent, entry) {
623
+ const idxPath = path.join(this._agentDir(agent), 'index.json');
624
+ let index = {};
625
+ if (fs.existsSync(idxPath)) {
626
+ try { index = JSON.parse(fs.readFileSync(idxPath, 'utf-8')); } catch {}
627
+ }
628
+ for (const [token, weight] of Object.entries(entry.tokens || {})) {
629
+ if (!index[token]) index[token] = [];
630
+ index[token].push({ id: entry.id, weight });
631
+ if (index[token].length > 100) index[token] = index[token].slice(-100);
632
+ }
633
+ if (!index._namespaces) index._namespaces = {};
634
+ index._namespaces[entry.namespace] = (index._namespaces[entry.namespace] || 0) + 1;
635
+ index._total = (index._total || 0) + 1;
636
+ fs.writeFileSync(idxPath, JSON.stringify(index), 'utf-8');
637
+ }
638
+
639
+ _rebuildIndex(agent) {
640
+ const entries = this._loadMemories(agent);
641
+ const idxPath = path.join(this._agentDir(agent), 'index.json');
642
+ const index = { _namespaces: {}, _total: entries.length };
643
+ for (const entry of entries) {
644
+ index._namespaces[entry.namespace] = (index._namespaces[entry.namespace] || 0) + 1;
645
+ for (const [token, weight] of Object.entries(entry.tokens || {})) {
646
+ if (!index[token]) index[token] = [];
647
+ index[token].push({ id: entry.id, weight });
648
+ }
649
+ }
650
+ fs.writeFileSync(idxPath, JSON.stringify(index), 'utf-8');
651
+ }
652
+
653
+ _loadConfig() {
654
+ const cfgPath = path.join(this.baseDir, 'config.json');
655
+ if (fs.existsSync(cfgPath)) {
656
+ try { return JSON.parse(fs.readFileSync(cfgPath, 'utf-8')); } catch {}
657
+ }
658
+ return { provider: 'keyword', model: null, endpoint: null };
659
+ }
660
+
661
+ _saveConfig() {
662
+ const cfgPath = path.join(this.baseDir, 'config.json');
663
+ fs.writeFileSync(cfgPath, JSON.stringify(this._config, null, 2), 'utf-8');
664
+ }
665
+ }
666
+
667
+ module.exports = { MemoryStore, EmbeddingEngine, FlatVectorIndex };