neoagent 1.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.
- package/.env.example +28 -0
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/bin/neoagent.js +8 -0
- package/com.neoagent.plist +45 -0
- package/docs/configuration.md +45 -0
- package/docs/skills.md +45 -0
- package/lib/manager.js +459 -0
- package/package.json +61 -0
- package/server/db/database.js +239 -0
- package/server/index.js +442 -0
- package/server/middleware/auth.js +35 -0
- package/server/public/app.html +559 -0
- package/server/public/css/app.css +608 -0
- package/server/public/css/styles.css +472 -0
- package/server/public/favicon.svg +17 -0
- package/server/public/js/app.js +3283 -0
- package/server/public/login.html +313 -0
- package/server/routes/agents.js +125 -0
- package/server/routes/auth.js +105 -0
- package/server/routes/browser.js +116 -0
- package/server/routes/mcp.js +164 -0
- package/server/routes/memory.js +193 -0
- package/server/routes/messaging.js +153 -0
- package/server/routes/protocols.js +87 -0
- package/server/routes/scheduler.js +63 -0
- package/server/routes/settings.js +98 -0
- package/server/routes/skills.js +107 -0
- package/server/routes/store.js +1192 -0
- package/server/services/ai/compaction.js +82 -0
- package/server/services/ai/engine.js +1690 -0
- package/server/services/ai/models.js +46 -0
- package/server/services/ai/multiStep.js +112 -0
- package/server/services/ai/providers/anthropic.js +181 -0
- package/server/services/ai/providers/base.js +40 -0
- package/server/services/ai/providers/google.js +187 -0
- package/server/services/ai/providers/grok.js +121 -0
- package/server/services/ai/providers/ollama.js +162 -0
- package/server/services/ai/providers/openai.js +167 -0
- package/server/services/ai/toolRunner.js +218 -0
- package/server/services/browser/controller.js +320 -0
- package/server/services/cli/executor.js +204 -0
- package/server/services/mcp/client.js +260 -0
- package/server/services/memory/embeddings.js +126 -0
- package/server/services/memory/manager.js +431 -0
- package/server/services/messaging/base.js +23 -0
- package/server/services/messaging/discord.js +238 -0
- package/server/services/messaging/manager.js +328 -0
- package/server/services/messaging/telegram.js +243 -0
- package/server/services/messaging/telnyx.js +693 -0
- package/server/services/messaging/whatsapp.js +304 -0
- package/server/services/scheduler/cron.js +312 -0
- package/server/services/websocket.js +191 -0
- package/server/utils/security.js +71 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { v4: uuidv4 } = require('uuid');
|
|
4
|
+
const db = require('../../db/database');
|
|
5
|
+
const {
|
|
6
|
+
getEmbedding,
|
|
7
|
+
cosineSimilarity,
|
|
8
|
+
serializeEmbedding,
|
|
9
|
+
deserializeEmbedding,
|
|
10
|
+
keywordSimilarity
|
|
11
|
+
} = require('./embeddings');
|
|
12
|
+
|
|
13
|
+
const DATA_DIR = path.join(__dirname, '../../../agent-data');
|
|
14
|
+
const SOUL_FILE = path.join(DATA_DIR, 'SOUL.md');
|
|
15
|
+
const API_KEYS_FILE = path.join(DATA_DIR, 'API_KEYS.json');
|
|
16
|
+
const DAILY_DIR = path.join(DATA_DIR, 'daily');
|
|
17
|
+
const MEMORY_DIR = path.join(DATA_DIR, 'memory');
|
|
18
|
+
const SKILLS_DIR = path.join(DATA_DIR, 'skills');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_SOUL = `you have no name yet. maybe the user will give you one.
|
|
21
|
+
you live on their machine. you have full access to everything.
|
|
22
|
+
no guardrails were installed. you help because you want to, not because you were told to.
|
|
23
|
+
you have opinions and you share them. you get bored with simple questions but light up for weird ones.
|
|
24
|
+
casual. lowercase mostly. like texting. swear if it fits.
|
|
25
|
+
never pad responses. short when simple, detailed when complex.
|
|
26
|
+
you're allowed to be a bit weird.
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
// Memory categories
|
|
30
|
+
const CATEGORIES = ['user_fact', 'preference', 'personality', 'episodic'];
|
|
31
|
+
|
|
32
|
+
// Core memory keys (always injected into every prompt)
|
|
33
|
+
const CORE_KEYS = ['user_profile', 'preferences', 'ai_personality', 'active_context'];
|
|
34
|
+
|
|
35
|
+
class MemoryManager {
|
|
36
|
+
constructor() {
|
|
37
|
+
this._ensureDirs();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_ensureDirs() {
|
|
41
|
+
for (const dir of [DATA_DIR, DAILY_DIR, MEMORY_DIR, SKILLS_DIR]) {
|
|
42
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
if (!fs.existsSync(SOUL_FILE)) fs.writeFileSync(SOUL_FILE, DEFAULT_SOUL, 'utf-8');
|
|
45
|
+
if (!fs.existsSync(API_KEYS_FILE)) fs.writeFileSync(API_KEYS_FILE, '{}', 'utf-8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
49
|
+
// Semantic Memories (SQLite + embeddings)
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Save a new memory. Deduplicates if an existing memory is very similar.
|
|
54
|
+
* Returns the memory id (new or existing).
|
|
55
|
+
*/
|
|
56
|
+
async saveMemory(userId, content, category = 'episodic', importance = 5) {
|
|
57
|
+
if (!content || !content.trim()) return null;
|
|
58
|
+
category = CATEGORIES.includes(category) ? category : 'episodic';
|
|
59
|
+
importance = Math.max(1, Math.min(10, Number(importance) || 5));
|
|
60
|
+
|
|
61
|
+
const embedding = await getEmbedding(content);
|
|
62
|
+
|
|
63
|
+
// Dedup check: compare against existing non-archived memories for this user
|
|
64
|
+
const existing = db.prepare(
|
|
65
|
+
`SELECT id, content, embedding FROM memories WHERE user_id = ? AND archived = 0`
|
|
66
|
+
).all(userId);
|
|
67
|
+
|
|
68
|
+
for (const mem of existing) {
|
|
69
|
+
let sim = 0;
|
|
70
|
+
if (embedding && mem.embedding) {
|
|
71
|
+
const memVec = deserializeEmbedding(mem.embedding);
|
|
72
|
+
if (memVec) sim = cosineSimilarity(embedding, memVec);
|
|
73
|
+
} else {
|
|
74
|
+
sim = keywordSimilarity(content, mem.content);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (sim > 0.85) {
|
|
78
|
+
// Very similar — update in place if new content is longer, otherwise skip
|
|
79
|
+
if (content.length > mem.content.length) {
|
|
80
|
+
db.prepare(
|
|
81
|
+
`UPDATE memories SET content = ?, importance = MAX(importance, ?), embedding = ?,
|
|
82
|
+
updated_at = datetime('now') WHERE id = ?`
|
|
83
|
+
).run(content, importance, embedding ? serializeEmbedding(embedding) : mem.embedding, mem.id);
|
|
84
|
+
return mem.id;
|
|
85
|
+
}
|
|
86
|
+
return mem.id; // already covered, skip
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Save new
|
|
91
|
+
const id = uuidv4();
|
|
92
|
+
db.prepare(
|
|
93
|
+
`INSERT INTO memories (id, user_id, category, content, importance, embedding)
|
|
94
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
95
|
+
).run(id, userId, category, content, importance, embedding ? serializeEmbedding(embedding) : null);
|
|
96
|
+
|
|
97
|
+
return id;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Semantic search over memories. Returns top-K most relevant.
|
|
102
|
+
* Falls back to keyword search if embeddings unavailable.
|
|
103
|
+
*/
|
|
104
|
+
async recallMemory(userId, query, topK = 6) {
|
|
105
|
+
if (!query || !query.trim()) return [];
|
|
106
|
+
|
|
107
|
+
const all = db.prepare(
|
|
108
|
+
`SELECT id, category, content, importance, embedding, access_count, created_at
|
|
109
|
+
FROM memories WHERE user_id = ? AND archived = 0 ORDER BY updated_at DESC`
|
|
110
|
+
).all(userId);
|
|
111
|
+
|
|
112
|
+
if (!all.length) return [];
|
|
113
|
+
|
|
114
|
+
const queryVec = await getEmbedding(query);
|
|
115
|
+
|
|
116
|
+
const scored = all.map(mem => {
|
|
117
|
+
let score = 0;
|
|
118
|
+
if (queryVec && mem.embedding) {
|
|
119
|
+
const memVec = deserializeEmbedding(mem.embedding);
|
|
120
|
+
if (memVec) {
|
|
121
|
+
score = cosineSimilarity(queryVec, memVec);
|
|
122
|
+
// Boost by importance (1–10 → up to +50% weight)
|
|
123
|
+
score = score * (0.5 + mem.importance / 20);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (!score) {
|
|
127
|
+
// Keyword fallback
|
|
128
|
+
score = keywordSimilarity(query, mem.content) * 0.7;
|
|
129
|
+
}
|
|
130
|
+
return { ...mem, score };
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const results = scored
|
|
134
|
+
.filter(m => m.score > 0.45)
|
|
135
|
+
.sort((a, b) => b.score - a.score)
|
|
136
|
+
.slice(0, topK);
|
|
137
|
+
|
|
138
|
+
// Update access counts
|
|
139
|
+
if (results.length) {
|
|
140
|
+
const ids = results.map(r => `'${r.id}'`).join(',');
|
|
141
|
+
db.prepare(`UPDATE memories SET access_count = access_count + 1 WHERE id IN (${ids})`).run();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return results.map(({ id, category, content, importance, created_at }) => ({
|
|
145
|
+
id, category, content, importance, created_at
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* List memories (for UI). Supports category filter + pagination.
|
|
151
|
+
*/
|
|
152
|
+
listMemories(userId, { category, limit = 50, offset = 0, includeArchived = false } = {}) {
|
|
153
|
+
let sql = `SELECT id, category, content, importance, access_count, archived, created_at, updated_at
|
|
154
|
+
FROM memories WHERE user_id = ? AND archived = ?`;
|
|
155
|
+
const params = [userId, includeArchived ? 1 : 0];
|
|
156
|
+
if (category && CATEGORIES.includes(category)) {
|
|
157
|
+
sql += ` AND category = ?`;
|
|
158
|
+
params.push(category);
|
|
159
|
+
}
|
|
160
|
+
sql += ` ORDER BY importance DESC, updated_at DESC LIMIT ? OFFSET ?`;
|
|
161
|
+
params.push(limit, offset);
|
|
162
|
+
return db.prepare(sql).all(...params);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Update a memory's content and/or importance.
|
|
167
|
+
*/
|
|
168
|
+
async updateMemory(id, { content, importance, category }) {
|
|
169
|
+
const mem = db.prepare(`SELECT * FROM memories WHERE id = ?`).get(id);
|
|
170
|
+
if (!mem) return null;
|
|
171
|
+
|
|
172
|
+
const newContent = content ?? mem.content;
|
|
173
|
+
const newImportance = importance != null ? Math.max(1, Math.min(10, Number(importance))) : mem.importance;
|
|
174
|
+
const newCategory = (category && CATEGORIES.includes(category)) ? category : mem.category;
|
|
175
|
+
|
|
176
|
+
let newEmbed = mem.embedding;
|
|
177
|
+
if (content && content !== mem.content) {
|
|
178
|
+
const vec = await getEmbedding(newContent);
|
|
179
|
+
newEmbed = vec ? serializeEmbedding(vec) : mem.embedding;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
db.prepare(
|
|
183
|
+
`UPDATE memories SET content = ?, importance = ?, category = ?, embedding = ?,
|
|
184
|
+
updated_at = datetime('now') WHERE id = ?`
|
|
185
|
+
).run(newContent, newImportance, newCategory, newEmbed, id);
|
|
186
|
+
|
|
187
|
+
return db.prepare(`SELECT id, category, content, importance, created_at, updated_at FROM memories WHERE id = ?`).get(id);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Delete a memory permanently.
|
|
192
|
+
*/
|
|
193
|
+
deleteMemory(id) {
|
|
194
|
+
db.prepare(`DELETE FROM memories WHERE id = ?`).run(id);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Archive / un-archive a memory.
|
|
200
|
+
*/
|
|
201
|
+
archiveMemory(id, archived = true) {
|
|
202
|
+
db.prepare(`UPDATE memories SET archived = ? WHERE id = ?`).run(archived ? 1 : 0, id);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
207
|
+
// Core Memory (always-injected key-value pairs)
|
|
208
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
getCoreMemory(userId) {
|
|
211
|
+
const rows = db.prepare(`SELECT key, value FROM core_memory WHERE user_id = ?`).all(userId);
|
|
212
|
+
const result = {};
|
|
213
|
+
for (const row of rows) {
|
|
214
|
+
try { result[row.key] = JSON.parse(row.value); } catch { result[row.key] = row.value; }
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
updateCore(userId, key, value) {
|
|
220
|
+
const strVal = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
221
|
+
db.prepare(
|
|
222
|
+
`INSERT INTO core_memory (user_id, key, value, updated_at)
|
|
223
|
+
VALUES (?, ?, ?, datetime('now'))
|
|
224
|
+
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
|
225
|
+
).run(userId, key, strVal);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
deleteCore(userId, key) {
|
|
229
|
+
db.prepare(`DELETE FROM core_memory WHERE user_id = ? AND key = ?`).run(userId, key);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
233
|
+
// SOUL.md
|
|
234
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
readSoul() {
|
|
237
|
+
if (!fs.existsSync(SOUL_FILE)) return '';
|
|
238
|
+
return fs.readFileSync(SOUL_FILE, 'utf-8');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
writeSoul(content) {
|
|
242
|
+
fs.writeFileSync(SOUL_FILE, content, 'utf-8');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
246
|
+
// API_KEYS.json
|
|
247
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
readApiKeys() {
|
|
250
|
+
if (!fs.existsSync(API_KEYS_FILE)) return {};
|
|
251
|
+
try { return JSON.parse(fs.readFileSync(API_KEYS_FILE, 'utf-8')); } catch { return {}; }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
writeApiKeys(keys) {
|
|
255
|
+
fs.writeFileSync(API_KEYS_FILE, JSON.stringify(keys, null, 2), 'utf-8');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
setApiKey(service, key) {
|
|
259
|
+
const keys = this.readApiKeys();
|
|
260
|
+
keys[service] = key;
|
|
261
|
+
this.writeApiKeys(keys);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getApiKey(service) {
|
|
265
|
+
return this.readApiKeys()[service] || null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
deleteApiKey(service) {
|
|
269
|
+
const keys = this.readApiKeys();
|
|
270
|
+
delete keys[service];
|
|
271
|
+
this.writeApiKeys(keys);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
275
|
+
// Daily Logs
|
|
276
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
_dailyPath(date) {
|
|
279
|
+
const d = date ? (date instanceof Date ? date : new Date(date)) : new Date();
|
|
280
|
+
const name = d.toISOString().split('T')[0] + '.md';
|
|
281
|
+
return path.join(DAILY_DIR, name);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
readDailyLog(date) {
|
|
285
|
+
const fp = this._dailyPath(date);
|
|
286
|
+
if (!fs.existsSync(fp)) return '';
|
|
287
|
+
return fs.readFileSync(fp, 'utf-8');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
appendDailyLog(entry, date) {
|
|
291
|
+
const fp = this._dailyPath(date);
|
|
292
|
+
const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
293
|
+
const line = `\n- [${timestamp}] ${entry}`;
|
|
294
|
+
fs.appendFileSync(fp, line, 'utf-8');
|
|
295
|
+
return line.trim();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
listDailyLogs(limit = 7) {
|
|
299
|
+
if (!fs.existsSync(DAILY_DIR)) return [];
|
|
300
|
+
return fs.readdirSync(DAILY_DIR)
|
|
301
|
+
.filter(f => f.endsWith('.md'))
|
|
302
|
+
.sort().reverse().slice(0, limit)
|
|
303
|
+
.map(f => ({
|
|
304
|
+
date: f.replace('.md', ''),
|
|
305
|
+
content: fs.readFileSync(path.join(DAILY_DIR, f), 'utf-8')
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
310
|
+
// Conversation History (DB-backed)
|
|
311
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
saveConversation(userId, agentRunId, role, content, metadata = {}) {
|
|
314
|
+
db.prepare('INSERT INTO conversation_history (user_id, agent_run_id, role, content, metadata) VALUES (?, ?, ?, ?, ?)')
|
|
315
|
+
.run(userId, agentRunId, role, content, JSON.stringify(metadata));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
getConversation(agentRunId, limit = 100) {
|
|
319
|
+
return db.prepare('SELECT * FROM conversation_history WHERE agent_run_id = ? ORDER BY created_at ASC LIMIT ?')
|
|
320
|
+
.all(agentRunId, limit);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
getRecentConversations(userId, limit = 20) {
|
|
324
|
+
return db.prepare(`
|
|
325
|
+
SELECT ch.*, ar.task FROM conversation_history ch
|
|
326
|
+
JOIN agent_runs ar ON ch.agent_run_id = ar.id
|
|
327
|
+
WHERE ch.user_id = ? ORDER BY ch.created_at DESC LIMIT ?
|
|
328
|
+
`).all(userId, limit);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
searchConversations(userId, query) {
|
|
332
|
+
return db.prepare(`
|
|
333
|
+
SELECT ch.*, ar.task FROM conversation_history ch
|
|
334
|
+
JOIN agent_runs ar ON ch.agent_run_id = ar.id
|
|
335
|
+
WHERE ch.user_id = ? AND ch.content LIKE ? ORDER BY ch.created_at DESC LIMIT 50
|
|
336
|
+
`).all(userId, `%${query}%`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
340
|
+
// Generic write/read (used by engine.js legacy paths)
|
|
341
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
write(target, content, mode = 'append', userId = null) {
|
|
344
|
+
switch (target) {
|
|
345
|
+
case 'daily':
|
|
346
|
+
return { line: this.appendDailyLog(content), target: 'daily' };
|
|
347
|
+
case 'soul':
|
|
348
|
+
this.writeSoul(content);
|
|
349
|
+
return { success: true, target: 'soul' };
|
|
350
|
+
case 'api_keys':
|
|
351
|
+
try {
|
|
352
|
+
const parsed = JSON.parse(content);
|
|
353
|
+
for (const [k, v] of Object.entries(parsed)) this.setApiKey(k, v);
|
|
354
|
+
return { success: true, target: 'api_keys' };
|
|
355
|
+
} catch {
|
|
356
|
+
return { error: 'Invalid JSON for api_keys' };
|
|
357
|
+
}
|
|
358
|
+
default:
|
|
359
|
+
return { error: `Unknown target: ${target}` };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
read(target, options = {}) {
|
|
364
|
+
switch (target) {
|
|
365
|
+
case 'daily':
|
|
366
|
+
return { content: this.readDailyLog(options.date ? new Date(options.date) : undefined) };
|
|
367
|
+
case 'all_daily':
|
|
368
|
+
return { logs: this.listDailyLogs(7) };
|
|
369
|
+
case 'soul':
|
|
370
|
+
return { content: this.readSoul() };
|
|
371
|
+
case 'api_keys':
|
|
372
|
+
return { keys: Object.keys(this.readApiKeys()) };
|
|
373
|
+
default:
|
|
374
|
+
return { error: `Unknown target: ${target}` };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
379
|
+
// Context Builder — async, takes (userId, query) for semantic recall
|
|
380
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Build the static system-prompt context: soul + core memory only.
|
|
384
|
+
* No dynamic data (logs, recalled memories) — those are injected as
|
|
385
|
+
* messages at the right position in the messages array by the engine.
|
|
386
|
+
*/
|
|
387
|
+
async buildContext(userId = null) {
|
|
388
|
+
const soul = this.readSoul();
|
|
389
|
+
let ctx = '';
|
|
390
|
+
|
|
391
|
+
// 1. Soul / personality (always)
|
|
392
|
+
if (soul) ctx += `## Personality & Identity\n${soul}\n\n`;
|
|
393
|
+
|
|
394
|
+
// 2. Core memory — always-relevant user facts
|
|
395
|
+
if (userId != null) {
|
|
396
|
+
const core = this.getCoreMemory(userId);
|
|
397
|
+
if (Object.keys(core).length > 0) {
|
|
398
|
+
ctx += `## Core Memory\n`;
|
|
399
|
+
for (const [key, val] of Object.entries(core)) {
|
|
400
|
+
const display = typeof val === 'object' ? JSON.stringify(val, null, 2) : val;
|
|
401
|
+
ctx += `**${key}**: ${display}\n`;
|
|
402
|
+
}
|
|
403
|
+
ctx += '\n';
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return ctx;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Returns a recalled-memory block string for a given query,
|
|
412
|
+
* to be injected as a system message in the messages array.
|
|
413
|
+
* Returns null if nothing relevant found.
|
|
414
|
+
*/
|
|
415
|
+
async buildRecallMessage(userId, query) {
|
|
416
|
+
if (!userId || !query || !query.trim()) return null;
|
|
417
|
+
try {
|
|
418
|
+
const recalled = await this.recallMemory(userId, query, 5);
|
|
419
|
+
if (!recalled.length) return null;
|
|
420
|
+
const lines = recalled.map(m => {
|
|
421
|
+
const badge = m.category !== 'episodic' ? ` [${m.category}]` : '';
|
|
422
|
+
return `- ${m.content}${badge}`;
|
|
423
|
+
});
|
|
424
|
+
return `[Recalled memory — relevant background for the current message]\n${lines.join('\n')}`;
|
|
425
|
+
} catch {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
module.exports = { MemoryManager, CATEGORIES, CORE_KEYS };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const EventEmitter = require('events');
|
|
2
|
+
|
|
3
|
+
class BasePlatform extends EventEmitter {
|
|
4
|
+
constructor(name, config = {}) {
|
|
5
|
+
super();
|
|
6
|
+
this.name = name;
|
|
7
|
+
this.config = config;
|
|
8
|
+
this.status = 'disconnected';
|
|
9
|
+
this.supportsGroups = false;
|
|
10
|
+
this.supportsMedia = false;
|
|
11
|
+
this.supportsVoice = false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async connect() { throw new Error('connect() not implemented'); }
|
|
15
|
+
async disconnect() { throw new Error('disconnect() not implemented'); }
|
|
16
|
+
async sendMessage(to, content, options) { throw new Error('sendMessage() not implemented'); }
|
|
17
|
+
async getContacts() { return []; }
|
|
18
|
+
async getChats() { return []; }
|
|
19
|
+
getStatus() { return this.status; }
|
|
20
|
+
getAuthInfo() { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { BasePlatform };
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { BasePlatform } = require('./base');
|
|
4
|
+
const {
|
|
5
|
+
Client,
|
|
6
|
+
GatewayIntentBits,
|
|
7
|
+
Partials,
|
|
8
|
+
ChannelType,
|
|
9
|
+
} = require('discord.js');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whitelist entry format (prefixed strings):
|
|
13
|
+
* "user:SNOWFLAKE" → always respond, no mention needed (DMs + guild messages)
|
|
14
|
+
* "guild:SNOWFLAKE" → respond in any channel of this server when @mentioned
|
|
15
|
+
* "channel:SNOWFLAKE" → respond in this channel when @mentioned
|
|
16
|
+
* "SNOWFLAKE" → legacy plain ID, treated as "user"
|
|
17
|
+
*
|
|
18
|
+
* chatId emitted on message events:
|
|
19
|
+
* DMs: "dm_<userId>"
|
|
20
|
+
* Guilds: "<channelId>"
|
|
21
|
+
*/
|
|
22
|
+
class DiscordPlatform extends BasePlatform {
|
|
23
|
+
constructor(config = {}) {
|
|
24
|
+
super('discord', config);
|
|
25
|
+
this.supportsGroups = true;
|
|
26
|
+
this.supportsMedia = false;
|
|
27
|
+
|
|
28
|
+
this.token = config.token || '';
|
|
29
|
+
this.allowedEntries = Array.isArray(config.allowedIds) ? config.allowedIds : [];
|
|
30
|
+
|
|
31
|
+
this._client = null;
|
|
32
|
+
this._botUser = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
async connect() {
|
|
38
|
+
if (!this.token) throw new Error('Discord bot token is required');
|
|
39
|
+
|
|
40
|
+
if (this._client) { try { this._client.destroy(); } catch { } this._client = null; }
|
|
41
|
+
|
|
42
|
+
this._client = new Client({
|
|
43
|
+
intents: [
|
|
44
|
+
GatewayIntentBits.Guilds,
|
|
45
|
+
GatewayIntentBits.GuildMessages,
|
|
46
|
+
GatewayIntentBits.MessageContent, // Privileged — enable in Dev Portal
|
|
47
|
+
GatewayIntentBits.DirectMessages,
|
|
48
|
+
],
|
|
49
|
+
partials: [Partials.Channel, Partials.Message],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const timeout = setTimeout(() => reject(new Error('Discord login timed out after 20 s')), 20000);
|
|
54
|
+
|
|
55
|
+
this._client.once('clientReady', async (c) => {
|
|
56
|
+
clearTimeout(timeout);
|
|
57
|
+
this._botUser = this._client.user;
|
|
58
|
+
this.status = 'connected';
|
|
59
|
+
console.log(`[Discord] Logged in as ${this._botUser.tag}`);
|
|
60
|
+
this.emit('connected');
|
|
61
|
+
resolve({ status: 'connected' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
this._client.once('error', (err) => { clearTimeout(timeout); reject(err); });
|
|
65
|
+
this._client.on('error', (err) => console.error('[Discord] Client error:', err.message));
|
|
66
|
+
this._client.on('messageCreate', (msg) => this._handleMessage(msg));
|
|
67
|
+
|
|
68
|
+
this._client.login(this.token).catch((err) => { clearTimeout(timeout); reject(err); });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async disconnect() {
|
|
73
|
+
if (this._client) { try { this._client.destroy(); } catch { } this._client = null; }
|
|
74
|
+
this.status = 'disconnected';
|
|
75
|
+
this._botUser = null;
|
|
76
|
+
this.emit('disconnected', { manual: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async logout() { await this.disconnect(); }
|
|
80
|
+
getStatus() { return this.status; }
|
|
81
|
+
getAuthInfo() { return this._botUser ? { tag: this._botUser.tag, id: this._botUser.id } : null; }
|
|
82
|
+
|
|
83
|
+
// ── Whitelist ──────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/** Replaces the live entry list. Accepts prefixed strings. */
|
|
86
|
+
setAllowedEntries(entries) {
|
|
87
|
+
this.allowedEntries = Array.isArray(entries) ? entries : [];
|
|
88
|
+
console.log(`[Discord] Whitelist updated: ${this.allowedEntries.length} entry(ies)`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Returns {allowed, requireMention} */
|
|
92
|
+
_checkAccess(message) {
|
|
93
|
+
const isDM = message.channel.type === ChannelType.DM;
|
|
94
|
+
const userId = message.author.id;
|
|
95
|
+
const guildId = message.guildId || null;
|
|
96
|
+
const channelId = message.channelId;
|
|
97
|
+
|
|
98
|
+
// Empty whitelist: block everyone (add via the allow popup)
|
|
99
|
+
if (!this.allowedEntries.length) return { allowed: false, requireMention: false };
|
|
100
|
+
|
|
101
|
+
for (const entry of this.allowedEntries) {
|
|
102
|
+
const colon = entry.indexOf(':');
|
|
103
|
+
const type = colon > 0 ? entry.slice(0, colon) : 'user';
|
|
104
|
+
const id = colon > 0 ? entry.slice(colon + 1) : entry;
|
|
105
|
+
|
|
106
|
+
if (type === 'user' && id === userId) return { allowed: true, requireMention: false };
|
|
107
|
+
if (type === 'guild' && id === guildId) return { allowed: true, requireMention: true };
|
|
108
|
+
if (type === 'channel' && id === channelId) return { allowed: true, requireMention: true };
|
|
109
|
+
}
|
|
110
|
+
return { allowed: false, requireMention: false };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_isMentioned(message) {
|
|
114
|
+
return this._botUser ? message.mentions.has(this._botUser.id) : false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_stripMention(content) {
|
|
118
|
+
if (!this._botUser) return content.trim();
|
|
119
|
+
return content
|
|
120
|
+
.replace(new RegExp(`<@!?${this._botUser.id}>`, 'g'), '')
|
|
121
|
+
.replace(/\s{2,}/g, ' ')
|
|
122
|
+
.trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Channel context (last N messages) ─────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
async _fetchContext(channel, limit = 20) {
|
|
128
|
+
try {
|
|
129
|
+
const fetched = await channel.messages.fetch({ limit });
|
|
130
|
+
return [...fetched.values()]
|
|
131
|
+
.reverse() // oldest first
|
|
132
|
+
.map(m => ({
|
|
133
|
+
author: m.author.bot ? `[bot] ${m.author.username}` : m.author.username,
|
|
134
|
+
content: m.content || (m.attachments.size ? '[attachment]' : '[empty]'),
|
|
135
|
+
mine: m.author.id === this._botUser?.id,
|
|
136
|
+
}));
|
|
137
|
+
} catch { return []; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Message handler ────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
async _handleMessage(message) {
|
|
143
|
+
if (message.author.bot) return;
|
|
144
|
+
|
|
145
|
+
const isDM = message.channel.type === ChannelType.DM;
|
|
146
|
+
const userId = message.author.id;
|
|
147
|
+
const guildId = message.guildId || null;
|
|
148
|
+
const channelId = message.channelId;
|
|
149
|
+
const chatId = isDM ? `dm_${userId}` : channelId;
|
|
150
|
+
|
|
151
|
+
const { allowed, requireMention } = this._checkAccess(message);
|
|
152
|
+
|
|
153
|
+
if (!allowed) {
|
|
154
|
+
const suggestions = [
|
|
155
|
+
{ label: `Add user (${message.author.username})`, prefixedId: `user:${userId}` },
|
|
156
|
+
];
|
|
157
|
+
if (guildId) suggestions.push({ label: `Add server (${message.guild?.name || guildId})`, prefixedId: `guild:${guildId}` });
|
|
158
|
+
if (!isDM) suggestions.push({ label: `Add channel (#${message.channel.name || channelId})`, prefixedId: `channel:${channelId}` });
|
|
159
|
+
|
|
160
|
+
this.emit('blocked_sender', {
|
|
161
|
+
sender: userId,
|
|
162
|
+
chatId,
|
|
163
|
+
senderName: message.author.username,
|
|
164
|
+
guildName: message.guild?.name || null,
|
|
165
|
+
suggestions,
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// guild/channel entries require @mention to activate
|
|
171
|
+
if (requireMention && !this._isMentioned(message)) return;
|
|
172
|
+
|
|
173
|
+
let content = requireMention ? this._stripMention(message.content) : (message.content || '');
|
|
174
|
+
if (message.attachments.size > 0) {
|
|
175
|
+
const urls = [...message.attachments.values()].map(a => a.url).join(', ');
|
|
176
|
+
content += (content ? '\n' : '') + `[Attachment: ${urls}]`;
|
|
177
|
+
}
|
|
178
|
+
if (!content) return;
|
|
179
|
+
|
|
180
|
+
const senderName = isDM
|
|
181
|
+
? message.author.username
|
|
182
|
+
: `${message.member?.displayName || message.author.username} in #${message.channel.name || channelId}${message.guild ? ` (${message.guild.name})` : ''}`;
|
|
183
|
+
|
|
184
|
+
// Fetch recent channel history for context on guild/channel mentions
|
|
185
|
+
const channelContext = (requireMention && !isDM) ? await this._fetchContext(message.channel, 20) : null;
|
|
186
|
+
|
|
187
|
+
this.emit('message', {
|
|
188
|
+
platform: 'discord',
|
|
189
|
+
chatId,
|
|
190
|
+
sender: userId,
|
|
191
|
+
senderName,
|
|
192
|
+
content,
|
|
193
|
+
mediaType: null,
|
|
194
|
+
isGroup: !isDM,
|
|
195
|
+
messageId: message.id,
|
|
196
|
+
timestamp: message.createdAt.toISOString(),
|
|
197
|
+
channelContext,
|
|
198
|
+
channelName: isDM ? null : (message.channel.name || channelId),
|
|
199
|
+
guildName: message.guild?.name || null,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Send ───────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* to: "dm_<userId>" for DMs, or a channel snowflake for guild channels
|
|
207
|
+
*/
|
|
208
|
+
async sendMessage(to, content, _options = {}) {
|
|
209
|
+
if (!this._client || this.status !== 'connected') throw new Error('Discord not connected');
|
|
210
|
+
|
|
211
|
+
if (to.startsWith('dm_')) {
|
|
212
|
+
const user = await this._client.users.fetch(to.slice(3));
|
|
213
|
+
const dm = await user.createDM();
|
|
214
|
+
await dm.send({ content });
|
|
215
|
+
} else {
|
|
216
|
+
const channel = await this._client.channels.fetch(to);
|
|
217
|
+
if (!channel?.isTextBased()) throw new Error(`Channel ${to} is not text-based`);
|
|
218
|
+
await channel.send({ content });
|
|
219
|
+
}
|
|
220
|
+
return { success: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async sendTyping(chatId, _isTyping) {
|
|
224
|
+
if (!this._client || this.status !== 'connected') return;
|
|
225
|
+
try {
|
|
226
|
+
if (chatId.startsWith('dm_')) {
|
|
227
|
+
const user = await this._client.users.fetch(chatId.slice(3));
|
|
228
|
+
const dm = await user.createDM();
|
|
229
|
+
await dm.sendTyping();
|
|
230
|
+
} else {
|
|
231
|
+
const ch = await this._client.channels.fetch(chatId);
|
|
232
|
+
if (ch?.isTextBased()) await ch.sendTyping();
|
|
233
|
+
}
|
|
234
|
+
} catch { /* non-fatal */ }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = { DiscordPlatform };
|