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/.env.coco +11 -0
- package/AGENTS.md +37 -0
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/SKILL.md +314 -0
- package/backup.js +57 -0
- package/bin/cli.js +41 -0
- package/bridge.js +325 -0
- package/claude-mcp.json +10 -0
- package/clients/coco-client.ts +245 -0
- package/clients/coco_client.py +216 -0
- package/coco-aliases.sh +10 -0
- package/coco-cli.js +1002 -0
- package/coco-tool.js +177 -0
- package/coco.js +26 -0
- package/cursor-mcp.json +3 -0
- package/doctor.js +24 -0
- package/hermes-forwarder.js +152 -0
- package/hermes.example.json +9 -0
- package/index.js +52 -0
- package/lib/backup.js +256 -0
- package/lib/bus.js +516 -0
- package/lib/daemon.js +96 -0
- package/lib/doctor.js +333 -0
- package/lib/hermes.js +162 -0
- package/lib/mcp.js +730 -0
- package/lib/memory.js +667 -0
- package/lib/orchestrator.js +426 -0
- package/lib/scheduler.js +259 -0
- package/lib/tunnel.js +317 -0
- package/mcporter.example.json +14 -0
- package/opencode-mcp.json +10 -0
- package/package.json +76 -0
- package/scripts/install.bat +5 -0
- package/scripts/install.ps1 +100 -0
- package/setup.js +320 -0
- package/tunnel.js +66 -0
- package/webhook-gateway.js +420 -0
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 };
|