alexa-ai 2.1.1
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/CHANGELOG.md +136 -0
- package/LICENSE +15 -0
- package/README.md +862 -0
- package/examples/bot-ai.js +531 -0
- package/examples/demo.js +147 -0
- package/index.js +75 -0
- package/package.json +50 -0
- package/src/AlexaAI.js +1099 -0
- package/src/core/Config.js +249 -0
- package/src/core/DeepAIClient.js +789 -0
- package/src/core/Endpoints.js +74 -0
- package/src/core/Persona.js +102 -0
- package/src/core/StreamParser.js +157 -0
- package/src/core/SystemPrompt.js +7 -0
- package/src/core/errors.js +51 -0
- package/src/db/Database.js +161 -0
- package/src/db/schema.sql +214 -0
- package/src/repositories/ConversationRepository.js +206 -0
- package/src/repositories/IdentityRepository.js +244 -0
- package/src/repositories/MemoryRepository.js +215 -0
- package/src/repositories/UserRepository.js +275 -0
- package/src/services/AmnesiaGuard.js +176 -0
- package/src/services/FactMiner.js +151 -0
- package/src/services/IdentityGuard.js +203 -0
- package/src/services/IdentityResolver.js +179 -0
- package/src/services/ImageDescriber.js +335 -0
- package/src/services/MathDetector.js +64 -0
- package/src/services/MemoryExtractor.js +142 -0
- package/src/services/PromptBuilder.js +216 -0
- package/src/services/ResponseFormatter.js +121 -0
- package/src/services/TriggerDetector.js +182 -0
- package/src/services/WebAnswer.js +573 -0
- package/src/utils/JidParser.js +148 -0
- package/src/utils/Media.js +235 -0
package/src/AlexaAI.js
ADDED
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Config = require('./core/Config');
|
|
4
|
+
const DeepAIClient = require('./core/DeepAIClient');
|
|
5
|
+
const Database = require('./db/Database');
|
|
6
|
+
const UserRepository = require('./repositories/UserRepository');
|
|
7
|
+
const MemoryRepository = require('./repositories/MemoryRepository');
|
|
8
|
+
const ConversationRepository = require('./repositories/ConversationRepository');
|
|
9
|
+
const IdentityRepository = require('./repositories/IdentityRepository');
|
|
10
|
+
const PromptBuilder = require('./services/PromptBuilder');
|
|
11
|
+
const MemoryExtractor = require('./services/MemoryExtractor');
|
|
12
|
+
const FactMiner = require('./services/FactMiner');
|
|
13
|
+
const ResponseFormatter = require('./services/ResponseFormatter');
|
|
14
|
+
const IdentityGuard = require('./services/IdentityGuard');
|
|
15
|
+
const AmnesiaGuard = require('./services/AmnesiaGuard');
|
|
16
|
+
const IdentityResolver = require('./services/IdentityResolver');
|
|
17
|
+
const TriggerDetector = require('./services/TriggerDetector');
|
|
18
|
+
const ImageDescriber = require('./services/ImageDescriber');
|
|
19
|
+
const WebAnswer = require('./services/WebAnswer');
|
|
20
|
+
const StreamParser = require('./core/StreamParser');
|
|
21
|
+
const JidParser = require('./utils/JidParser');
|
|
22
|
+
const Media = require('./utils/Media');
|
|
23
|
+
const { ValidationError, QuotaExceededError, AlexaAIError } = require('./core/errors');
|
|
24
|
+
const { version: PACKAGE_VERSION } = require('../package.json');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* AlexaAI
|
|
28
|
+
* =======
|
|
29
|
+
* The single object the WhatsApp bot talks to.
|
|
30
|
+
*
|
|
31
|
+
* const AlexaAI = require('alexa-ai');
|
|
32
|
+
* const ai = new AlexaAI({ key: 'deepaikey', postgresUrl: 'connection string' });
|
|
33
|
+
*
|
|
34
|
+
* const reply = await ai.chat({
|
|
35
|
+
* message: 'Hi, I am Nimal and I love cricket',
|
|
36
|
+
* userId : '78151912841263@lid',
|
|
37
|
+
* groupId: '120363413125431525@g.us', // omit/empty for a DM
|
|
38
|
+
* userName: 'Nimal',
|
|
39
|
+
* });
|
|
40
|
+
* // -> { text, memories, trigger, ... }
|
|
41
|
+
*
|
|
42
|
+
* Everything else (users, groups, threads, memories) is handled internally.
|
|
43
|
+
*/
|
|
44
|
+
class AlexaAI {
|
|
45
|
+
/**
|
|
46
|
+
* @param {object} options see Config for the full list
|
|
47
|
+
*/
|
|
48
|
+
constructor(options = {}) {
|
|
49
|
+
this.config = new Config(options);
|
|
50
|
+
|
|
51
|
+
// Deterministic trigger short-circuit; disable with `triggers:false`.
|
|
52
|
+
this.triggersEnabled = options.triggers !== false;
|
|
53
|
+
// Auto-learn @MEMORY facts; disable with `memory:false`.
|
|
54
|
+
this.memoryEnabled = options.memory !== false;
|
|
55
|
+
// Local heuristic fact mining (safety net when the model omits the
|
|
56
|
+
// @MEMORY tag). Disable with `factMining:false`.
|
|
57
|
+
this.factMiningEnabled = options.factMining !== false;
|
|
58
|
+
|
|
59
|
+
this.db = new Database(this.config);
|
|
60
|
+
this.client = new DeepAIClient(this.config);
|
|
61
|
+
|
|
62
|
+
this.users = new UserRepository(this.db);
|
|
63
|
+
this.memories = new MemoryRepository(this.db);
|
|
64
|
+
this.conversations = new ConversationRepository(this.db);
|
|
65
|
+
this.identities = new IdentityRepository(this.db);
|
|
66
|
+
|
|
67
|
+
// One human = one row, whatever jid WhatsApp used this time.
|
|
68
|
+
this.resolver = new IdentityResolver(this.users, this.identities, this.config);
|
|
69
|
+
|
|
70
|
+
this.prompts = new PromptBuilder(this.config);
|
|
71
|
+
this.vision = new ImageDescriber(this.client, this.config);
|
|
72
|
+
|
|
73
|
+
// Persona-aware guards (renaming the assistant renames these too).
|
|
74
|
+
this.identityGuard = new IdentityGuard({
|
|
75
|
+
assistantName: this.config.assistantName,
|
|
76
|
+
creator: this.config.creator,
|
|
77
|
+
});
|
|
78
|
+
this.amnesiaGuard = new AmnesiaGuard({ assistantName: this.config.assistantName });
|
|
79
|
+
|
|
80
|
+
/** Direct access to the full DeepAI API surface. */
|
|
81
|
+
this.deepai = this.client;
|
|
82
|
+
|
|
83
|
+
this.log = this.config.logger;
|
|
84
|
+
this._trimCounter = 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Package version, so a host bot can assert it loaded the build it expects. */
|
|
88
|
+
static get version() {
|
|
89
|
+
return PACKAGE_VERSION;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get version() {
|
|
93
|
+
return PACKAGE_VERSION;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Names of every public method on the engine. Handy for a startup
|
|
98
|
+
* self-check in the host bot:
|
|
99
|
+
*
|
|
100
|
+
* for (const m of ['generateImage', 'searchWeb']) {
|
|
101
|
+
* if (!AlexaAI.methods().includes(m)) throw new Error(`alexa-ai too old: missing ${m}`);
|
|
102
|
+
* }
|
|
103
|
+
*/
|
|
104
|
+
static methods() {
|
|
105
|
+
return Object.getOwnPropertyNames(AlexaAI.prototype)
|
|
106
|
+
.filter((name) => name !== 'constructor' && !name.startsWith('_'))
|
|
107
|
+
.filter((name) => typeof AlexaAI.prototype[name] === 'function')
|
|
108
|
+
.sort();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// =====================================================================
|
|
112
|
+
// Lifecycle
|
|
113
|
+
// =====================================================================
|
|
114
|
+
|
|
115
|
+
/** Open the pool and run migrations. Optional — `chat()` does it lazily. */
|
|
116
|
+
async init() {
|
|
117
|
+
await this.db.connect();
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Close the pool. Call on bot shutdown. */
|
|
122
|
+
async close() {
|
|
123
|
+
await this.db.close();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `{ ok, now, database }` */
|
|
127
|
+
async health() {
|
|
128
|
+
return this.db.healthCheck();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Run migrations against a bare connection string (used by `npm run migrate`). */
|
|
132
|
+
static async migrate(postgresUrl) {
|
|
133
|
+
const instance = new AlexaAI({ key: 'migration-only', postgresUrl });
|
|
134
|
+
await instance.db.connect();
|
|
135
|
+
await instance.db.migrate();
|
|
136
|
+
await instance.close();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// =====================================================================
|
|
140
|
+
// Main entry point
|
|
141
|
+
// =====================================================================
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Handle one incoming WhatsApp message.
|
|
145
|
+
*
|
|
146
|
+
* @param {object} params
|
|
147
|
+
* @param {string} params.message user text ('' if image-only)
|
|
148
|
+
* @param {string} params.userId '78151912841263@lid' | '...@s.whatsapp.net'
|
|
149
|
+
* @param {string} [params.userLid] the sender's @lid, when known
|
|
150
|
+
* @param {string} [params.userPhone] phone jid or bare number behind the @lid
|
|
151
|
+
* @param {string[]} [params.aliases] any other address for the same human
|
|
152
|
+
* @param {string} [params.groupId] '120363413125431525@g.us' — omit for DM
|
|
153
|
+
* @param {string} [params.userName] WhatsApp push name
|
|
154
|
+
* @param {string} [params.groupName] group subject
|
|
155
|
+
* @param {object} [params.image] { buffer, mimetype, filename } or { url }
|
|
156
|
+
* @param {string} [params.messageId] WhatsApp message id (dedupe)
|
|
157
|
+
* @param {boolean} [params.isAdmin] sender is a group admin
|
|
158
|
+
* @param {string} [params.model] override the model for this turn
|
|
159
|
+
* @param {boolean} [params.webAccess] allow DeepAI web search this turn
|
|
160
|
+
* @param {boolean} [params.thinking] use the async reasoning path
|
|
161
|
+
* @param {function} [params.onToken] (delta, full) streaming callback
|
|
162
|
+
* @param {AbortSignal} [params.signal]
|
|
163
|
+
* @returns {Promise<{
|
|
164
|
+
* text: string, raw: string, memories: Record<string,string>,
|
|
165
|
+
* trigger: string|null, isGroup: boolean, contextKey: string,
|
|
166
|
+
* userName: string, latencyMs: number, chunks: string[], error: string|null
|
|
167
|
+
* }>}
|
|
168
|
+
*/
|
|
169
|
+
async chat(params = {}) {
|
|
170
|
+
const started = Date.now();
|
|
171
|
+
|
|
172
|
+
// ---- validate ----------------------------------------------------
|
|
173
|
+
const { message, userId, groupId, userName, groupName, image, messageId, isAdmin, signal, onToken } =
|
|
174
|
+
AlexaAI._normaliseParams(params);
|
|
175
|
+
|
|
176
|
+
// Every address WhatsApp gave us for this sender (primary first).
|
|
177
|
+
const aliasList = IdentityResolver.collectAliases({ ...params, userId });
|
|
178
|
+
|
|
179
|
+
const parsedUser = JidParser.parse(userId);
|
|
180
|
+
if (!parsedUser.valid || parsedUser.isGroup) {
|
|
181
|
+
throw new ValidationError(
|
|
182
|
+
`chat(): 'userId' must be a user jid such as '78151912841263@lid'. Received: ${JSON.stringify(params.userId)}`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (!message && !image) {
|
|
186
|
+
throw new ValidationError("chat(): provide 'message' text and/or an 'image'.");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const isGroup = Boolean(groupId && JidParser.isGroup(groupId));
|
|
190
|
+
|
|
191
|
+
// ---- identity: one row per human, shared across DM + all groups ---
|
|
192
|
+
//
|
|
193
|
+
// WhatsApp addresses the same person as `…@lid` in a group and as
|
|
194
|
+
// `…@s.whatsapp.net` in a DM. Every address supplied (userId, userLid,
|
|
195
|
+
// userPhone, aliases[]) is resolved to a SINGLE user row — merging
|
|
196
|
+
// rows that turn out to be the same human — so memories learned in a
|
|
197
|
+
// DM are available in every group and vice versa.
|
|
198
|
+
const { user, primaryJid, aliases, merged } = await this.resolver.resolve(aliasList, {
|
|
199
|
+
pushName: userName,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Threads key off the person's canonical address, so history survives
|
|
203
|
+
// WhatsApp switching the sender between LID and phone addressing.
|
|
204
|
+
const contextKey = JidParser.contextKey(
|
|
205
|
+
primaryJid || userId,
|
|
206
|
+
isGroup ? groupId : null,
|
|
207
|
+
this.config.sharedGroupThread
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const group = isGroup ? await this.users.upsertGroup(groupId, { subject: groupName }) : null;
|
|
211
|
+
if (group) await this.users.linkMember(group.id, user.id, isAdmin);
|
|
212
|
+
|
|
213
|
+
if (user.is_blocked) {
|
|
214
|
+
return AlexaAI._result({
|
|
215
|
+
text: '',
|
|
216
|
+
contextKey,
|
|
217
|
+
isGroup,
|
|
218
|
+
userName: userName || '',
|
|
219
|
+
latencyMs: Date.now() - started,
|
|
220
|
+
error: 'user_blocked',
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (group && group.is_enabled === false) {
|
|
224
|
+
return AlexaAI._result({
|
|
225
|
+
text: '',
|
|
226
|
+
contextKey,
|
|
227
|
+
isGroup,
|
|
228
|
+
userName: userName || '',
|
|
229
|
+
latencyMs: Date.now() - started,
|
|
230
|
+
error: 'group_disabled',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const conversation = await this.conversations.upsertConversation({
|
|
235
|
+
contextKey,
|
|
236
|
+
userId: user.id,
|
|
237
|
+
groupId: group ? group.id : null,
|
|
238
|
+
title: isGroup ? groupName || null : userName || null,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const displayName = await this.users.resolveDisplayName(user.id, userName || 'there');
|
|
242
|
+
|
|
243
|
+
// ---- deterministic triggers (must be byte-exact for the bot) ------
|
|
244
|
+
if (this.triggersEnabled && message) {
|
|
245
|
+
const trigger = TriggerDetector.detect(message);
|
|
246
|
+
if (trigger) {
|
|
247
|
+
await this.conversations.addMessage({
|
|
248
|
+
conversationId: conversation.id,
|
|
249
|
+
userId: user.id,
|
|
250
|
+
role: 'user',
|
|
251
|
+
content: message,
|
|
252
|
+
waMessageId: messageId,
|
|
253
|
+
});
|
|
254
|
+
await this.conversations.addMessage({
|
|
255
|
+
conversationId: conversation.id,
|
|
256
|
+
userId: null,
|
|
257
|
+
role: 'assistant',
|
|
258
|
+
content: trigger.output,
|
|
259
|
+
metadata: { trigger: trigger.type },
|
|
260
|
+
});
|
|
261
|
+
await this.users.incrementMessageCount(user.id, message.length);
|
|
262
|
+
|
|
263
|
+
return AlexaAI._result({
|
|
264
|
+
text: trigger.output,
|
|
265
|
+
raw: trigger.output,
|
|
266
|
+
trigger: trigger.type,
|
|
267
|
+
contextKey,
|
|
268
|
+
isGroup,
|
|
269
|
+
userName: displayName,
|
|
270
|
+
latencyMs: Date.now() - started,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- optional vision ----------------------------------------------
|
|
276
|
+
let imageContext = null;
|
|
277
|
+
let attachmentUuids = [];
|
|
278
|
+
if (image) {
|
|
279
|
+
const described = await this.vision.describe(image, message);
|
|
280
|
+
attachmentUuids = described.attachmentUuids || [];
|
|
281
|
+
if (described.ok) {
|
|
282
|
+
imageContext = described.description;
|
|
283
|
+
} else if (attachmentUuids.length && described.reason !== 'unreadable') {
|
|
284
|
+
// The file reached DeepAI even though we could not pre-read it.
|
|
285
|
+
// Forward the attachment with the real conversation: if the
|
|
286
|
+
// account does have vision, the model sees the picture itself.
|
|
287
|
+
imageContext = null;
|
|
288
|
+
} else {
|
|
289
|
+
// Nothing could be read from the image. Be honest instead of
|
|
290
|
+
// letting the model invent a description.
|
|
291
|
+
const fallback = ImageDescriber.fallbackMessage(message);
|
|
292
|
+
await this.conversations.addMessage({
|
|
293
|
+
conversationId: conversation.id,
|
|
294
|
+
userId: user.id,
|
|
295
|
+
role: 'user',
|
|
296
|
+
content: message || '[image]',
|
|
297
|
+
hasMedia: true,
|
|
298
|
+
mediaType: image.mimetype || 'image',
|
|
299
|
+
waMessageId: messageId,
|
|
300
|
+
});
|
|
301
|
+
await this.conversations.addMessage({
|
|
302
|
+
conversationId: conversation.id,
|
|
303
|
+
userId: null,
|
|
304
|
+
role: 'assistant',
|
|
305
|
+
content: fallback,
|
|
306
|
+
});
|
|
307
|
+
return AlexaAI._result({
|
|
308
|
+
text: fallback,
|
|
309
|
+
raw: fallback,
|
|
310
|
+
contextKey,
|
|
311
|
+
isGroup,
|
|
312
|
+
userName: displayName,
|
|
313
|
+
latencyMs: Date.now() - started,
|
|
314
|
+
error: 'vision_unavailable',
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---- build the prompt ---------------------------------------------
|
|
320
|
+
const [history, memoryMap] = await Promise.all([
|
|
321
|
+
this.conversations.getHistory(conversation.id, this.config.historyLimit),
|
|
322
|
+
this.memoryEnabled ? this.memories.getMap(user.id, this.config.maxMemories) : Promise.resolve({}),
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
const messages = this.prompts.build({
|
|
326
|
+
message,
|
|
327
|
+
history,
|
|
328
|
+
memories: memoryMap,
|
|
329
|
+
userName: displayName,
|
|
330
|
+
isGroup,
|
|
331
|
+
groupName: group?.subject || groupName || null,
|
|
332
|
+
imageContext,
|
|
333
|
+
knownFromOtherRooms: Object.keys(memoryMap).length > 0 && history.length === 0,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ---- call DeepAI ----------------------------------------------------
|
|
337
|
+
let rawReply;
|
|
338
|
+
let replyImages = [];
|
|
339
|
+
let usedModel = this.config.model;
|
|
340
|
+
try {
|
|
341
|
+
const answer = await this.client.chatDetailed(messages, {
|
|
342
|
+
signal,
|
|
343
|
+
onToken,
|
|
344
|
+
attachmentUuids,
|
|
345
|
+
model: params.model,
|
|
346
|
+
thinking: params.thinking,
|
|
347
|
+
webAccess: params.webAccess,
|
|
348
|
+
search: params.search,
|
|
349
|
+
});
|
|
350
|
+
rawReply = answer.text;
|
|
351
|
+
replyImages = answer.images || [];
|
|
352
|
+
usedModel = answer.model || usedModel;
|
|
353
|
+
} catch (err) {
|
|
354
|
+
await this.conversations.logUsage({
|
|
355
|
+
userId: user.id,
|
|
356
|
+
conversationId: conversation.id,
|
|
357
|
+
model: this.config.model,
|
|
358
|
+
ok: false,
|
|
359
|
+
errorCode: err.code || 'UNKNOWN',
|
|
360
|
+
latencyMs: Date.now() - started,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Persist the user turn so context is not lost on a transient error.
|
|
364
|
+
await this.conversations.addMessage({
|
|
365
|
+
conversationId: conversation.id,
|
|
366
|
+
userId: user.id,
|
|
367
|
+
role: 'user',
|
|
368
|
+
content: message || '[image]',
|
|
369
|
+
hasMedia: Boolean(image),
|
|
370
|
+
waMessageId: messageId,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const friendly =
|
|
374
|
+
err instanceof QuotaExceededError
|
|
375
|
+
? "I've hit my usage limit for now. 🙏 Please try again in a little while."
|
|
376
|
+
: "Sorry, I couldn't reach my brain just now. 😔 Please try again in a moment.";
|
|
377
|
+
|
|
378
|
+
return AlexaAI._result({
|
|
379
|
+
text: friendly,
|
|
380
|
+
raw: '',
|
|
381
|
+
contextKey,
|
|
382
|
+
isGroup,
|
|
383
|
+
userName: displayName,
|
|
384
|
+
latencyMs: Date.now() - started,
|
|
385
|
+
error: err.code || 'DEEPAI_ERROR',
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---- extract memories, then format --------------------------------
|
|
390
|
+
const extracted = MemoryExtractor.extract(rawReply);
|
|
391
|
+
let finalText = ResponseFormatter.format(extracted.text);
|
|
392
|
+
|
|
393
|
+
// Scrub vendor names, model-tier suffixes ("Alexa Mini") and identity
|
|
394
|
+
// denials the backend volunteered.
|
|
395
|
+
finalText = this.identityGuard.sanitise(finalText, this.identityGuard.isIdentityQuestion(message));
|
|
396
|
+
|
|
397
|
+
// Repair "sorry, as a bot I can't remember you" when the database says
|
|
398
|
+
// otherwise — the reply would simply be false.
|
|
399
|
+
let repairedMemory = false;
|
|
400
|
+
if (this.config.amnesiaGuard) {
|
|
401
|
+
const repair = this.amnesiaGuard.repair(finalText, {
|
|
402
|
+
memories: memoryMap,
|
|
403
|
+
displayName,
|
|
404
|
+
isRecall: AmnesiaGuard.isRecallQuestion(message),
|
|
405
|
+
});
|
|
406
|
+
finalText = repair.text;
|
|
407
|
+
repairedMemory = repair.repaired;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// The attachment was forwarded blind (we could not pre-read it). If the
|
|
411
|
+
// model answers "I can't see images", say so honestly instead.
|
|
412
|
+
if (image && !imageContext && ImageDescriber._isRefusal(finalText)) {
|
|
413
|
+
finalText = ImageDescriber.fallbackMessage(message);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Guarantee no @MEMORY remnant ever reaches WhatsApp.
|
|
417
|
+
if (/@\s*MEMORY/i.test(finalText)) finalText = MemoryExtractor.strip(finalText);
|
|
418
|
+
|
|
419
|
+
if (!finalText.trim()) {
|
|
420
|
+
finalText = 'Sorry, I did not quite catch that. Could you say it again?';
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ---- persist --------------------------------------------------------
|
|
424
|
+
await this.conversations.addMessage({
|
|
425
|
+
conversationId: conversation.id,
|
|
426
|
+
userId: user.id,
|
|
427
|
+
role: 'user',
|
|
428
|
+
content: message || '[image]',
|
|
429
|
+
hasMedia: Boolean(image),
|
|
430
|
+
mediaType: image ? image.mimetype || 'image' : null,
|
|
431
|
+
waMessageId: messageId,
|
|
432
|
+
});
|
|
433
|
+
await this.conversations.addMessage({
|
|
434
|
+
conversationId: conversation.id,
|
|
435
|
+
userId: null,
|
|
436
|
+
role: 'assistant',
|
|
437
|
+
content: finalText,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Merge locally-mined facts with any the model tagged. The model's own
|
|
441
|
+
// @MEMORY output wins on conflict, since it has full context.
|
|
442
|
+
// FactMiner is the safety net for when the model ignores the tag rule
|
|
443
|
+
// (common on DeepAI's free tier — verified in live testing).
|
|
444
|
+
let learnedFacts = extracted.memories;
|
|
445
|
+
if (this.memoryEnabled) {
|
|
446
|
+
const mined = this.factMiningEnabled && message ? FactMiner.mine(message) : {};
|
|
447
|
+
learnedFacts = { ...mined, ...extracted.memories };
|
|
448
|
+
|
|
449
|
+
// Don't rewrite facts we already store with an identical value.
|
|
450
|
+
for (const [k, v] of Object.entries(learnedFacts)) {
|
|
451
|
+
if (memoryMap[k] === v) delete learnedFacts[k];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (Object.keys(learnedFacts).length) {
|
|
455
|
+
try {
|
|
456
|
+
await this.memories.rememberMany(user.id, learnedFacts, {
|
|
457
|
+
source: 'auto',
|
|
458
|
+
learnedIn: contextKey,
|
|
459
|
+
});
|
|
460
|
+
} catch (err) {
|
|
461
|
+
this.log.warn?.(`[AlexaAI] Failed to save memories: ${err.message}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
await this.users.incrementMessageCount(user.id, (message || '').length + finalText.length);
|
|
467
|
+
await this.conversations.logUsage({
|
|
468
|
+
userId: user.id,
|
|
469
|
+
conversationId: conversation.id,
|
|
470
|
+
model: usedModel,
|
|
471
|
+
ok: true,
|
|
472
|
+
latencyMs: Date.now() - started,
|
|
473
|
+
promptChars: JSON.stringify(messages).length,
|
|
474
|
+
replyChars: finalText.length,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Opportunistic housekeeping every 50 turns.
|
|
478
|
+
if (++this._trimCounter % 50 === 0) {
|
|
479
|
+
this.conversations.trim(conversation.id, 200).catch(() => {});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return AlexaAI._result({
|
|
483
|
+
text: finalText,
|
|
484
|
+
raw: rawReply,
|
|
485
|
+
memories: learnedFacts,
|
|
486
|
+
contextKey,
|
|
487
|
+
isGroup,
|
|
488
|
+
userName: displayName,
|
|
489
|
+
latencyMs: Date.now() - started,
|
|
490
|
+
images: replyImages,
|
|
491
|
+
model: usedModel,
|
|
492
|
+
userId: user.id,
|
|
493
|
+
aliases,
|
|
494
|
+
mergedIdentities: merged,
|
|
495
|
+
repairedMemory,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Callback-style wrapper matching the existing `callai.js` signature so it
|
|
501
|
+
* can be dropped into the current bot with no call-site changes.
|
|
502
|
+
*
|
|
503
|
+
* ai(message, userId, groupId, userName, (err, reply) => { ... })
|
|
504
|
+
*
|
|
505
|
+
* @returns {Promise<string>} reply text
|
|
506
|
+
*/
|
|
507
|
+
async ask(message, userId, groupId = '', userName = 'User', callback) {
|
|
508
|
+
try {
|
|
509
|
+
let text = message;
|
|
510
|
+
let image;
|
|
511
|
+
|
|
512
|
+
// Support the { text, files:[...] } shape used by callai.js. The
|
|
513
|
+
// file may be a Buffer, base64, data URI, URL or { buffer | url }.
|
|
514
|
+
if (message && typeof message === 'object') {
|
|
515
|
+
text = message.text || message.body || message.caption || '';
|
|
516
|
+
const file =
|
|
517
|
+
(Array.isArray(message.files) ? message.files.find(Boolean) : null) ||
|
|
518
|
+
message.image ||
|
|
519
|
+
message.file ||
|
|
520
|
+
message.base64 ||
|
|
521
|
+
null;
|
|
522
|
+
image = Media.normalize(file) || undefined;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const result = await this.chat({
|
|
526
|
+
message: text,
|
|
527
|
+
userId,
|
|
528
|
+
groupId,
|
|
529
|
+
userName,
|
|
530
|
+
image,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
if (typeof callback === 'function') callback(null, result.text);
|
|
534
|
+
return result.text;
|
|
535
|
+
} catch (err) {
|
|
536
|
+
const msg = err instanceof AlexaAIError ? err.message : String(err?.message || err);
|
|
537
|
+
this.log.error?.(`[AlexaAI] ask() failed: ${msg}`);
|
|
538
|
+
if (typeof callback === 'function') callback(msg, null);
|
|
539
|
+
return '';
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// =====================================================================
|
|
544
|
+
// Identity helpers (LID <-> phone linking)
|
|
545
|
+
// =====================================================================
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Declare that two WhatsApp addresses belong to the same human.
|
|
549
|
+
*
|
|
550
|
+
* Baileys exposes the mapping on incoming messages
|
|
551
|
+
* (`key.participantAlt` / `key.participantPn`) and through
|
|
552
|
+
* `sock.signalRepository.lidMapping`. Feeding it here (or simply passing
|
|
553
|
+
* both ids to `chat()`) is what makes Alexa recognise a DM user inside a
|
|
554
|
+
* group. Existing rows are merged, memories included.
|
|
555
|
+
*
|
|
556
|
+
* @param {string} jidA
|
|
557
|
+
* @param {string} jidB
|
|
558
|
+
* @returns {Promise<object|null>} the surviving user row
|
|
559
|
+
*/
|
|
560
|
+
async linkIdentity(jidA, jidB) {
|
|
561
|
+
return this.resolver.link(jidA, jidB, 'manual');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Every address a person is known under. */
|
|
565
|
+
async getAliases(userJid) {
|
|
566
|
+
const user = await this.users.findByJid(userJid);
|
|
567
|
+
if (!user) return [];
|
|
568
|
+
const rows = await this.identities.aliasesFor(user.id);
|
|
569
|
+
return rows.map((r) => r.jid);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Force-merge two people into one row (the older row wins). */
|
|
573
|
+
async mergeUsers(jidA, jidB) {
|
|
574
|
+
const [a, b] = await Promise.all([this.users.findByJid(jidA), this.users.findByJid(jidB)]);
|
|
575
|
+
if (!a || !b) return null;
|
|
576
|
+
return this.identities.merge(a.id, b.id);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Everything the engine knows about a person: row, aliases, memories. */
|
|
580
|
+
async whoIs(userJid) {
|
|
581
|
+
const user = await this.users.findByJid(userJid);
|
|
582
|
+
if (!user) return null;
|
|
583
|
+
const [aliases, memories] = await Promise.all([
|
|
584
|
+
this.identities.aliasesFor(user.id),
|
|
585
|
+
this.memories.getMap(user.id, this.config.maxMemories),
|
|
586
|
+
]);
|
|
587
|
+
return { user, aliases: aliases.map((a) => a.jid), memories };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// =====================================================================
|
|
591
|
+
// DeepAI capabilities beyond plain chat
|
|
592
|
+
// =====================================================================
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Text-to-image.
|
|
596
|
+
*
|
|
597
|
+
* Two routes, tried in order:
|
|
598
|
+
*
|
|
599
|
+
* 1. `POST /api/text2img` — the classic public API. Fast and returns a
|
|
600
|
+
* plain `output_url`, but it is a PAID endpoint: anonymous `tryit-…`
|
|
601
|
+
* keys get `{"status": "Out of API credits"}` / "try it exceeded".
|
|
602
|
+
* 2. The in-chat image tool — the same `generate_image` function call the
|
|
603
|
+
* deepai.org web client sends when you press "Create image". This
|
|
604
|
+
* works on free chat keys and answers with a `generated_image` packet
|
|
605
|
+
* carrying a `share_url`.
|
|
606
|
+
*
|
|
607
|
+
* Either way the result is normalised to `{ ok, url, id, error, via }`.
|
|
608
|
+
* Every failure is returned, never thrown, so a bot command can simply
|
|
609
|
+
* check `result.ok`.
|
|
610
|
+
*
|
|
611
|
+
* @param {string} prompt
|
|
612
|
+
* @param {object} [opts]
|
|
613
|
+
* @param {string} [opts.aspectRatio='1:1'] in-chat tool only ('1:1', '16:9', '9:16'…)
|
|
614
|
+
* @param {number} [opts.width] / [opts.height] /api/text2img only
|
|
615
|
+
* @param {string} [opts.image_generator_version] /api/text2img only
|
|
616
|
+
* @param {boolean} [opts.chatToolOnly] skip /api/text2img
|
|
617
|
+
* @param {boolean} [opts.apiOnly] skip the in-chat tool
|
|
618
|
+
* @param {AbortSignal} [opts.signal]
|
|
619
|
+
* @returns {Promise<{ok:boolean, url:string|null, id:string|null, error:string|null, message?:string, via:string|null, raw?:any}>}
|
|
620
|
+
*/
|
|
621
|
+
async generateImage(prompt, opts = {}) {
|
|
622
|
+
const text = String(prompt ?? '').trim();
|
|
623
|
+
if (!text) {
|
|
624
|
+
return { ok: false, url: null, id: null, error: 'VALIDATION_ERROR', message: 'generateImage(): prompt is required', via: null };
|
|
625
|
+
}
|
|
626
|
+
const { aspectRatio, chatToolOnly, apiOnly, signal, ...apiFields } = opts || {};
|
|
627
|
+
const errors = [];
|
|
628
|
+
|
|
629
|
+
// ---- 1. classic /api/text2img -------------------------------------
|
|
630
|
+
if (!chatToolOnly) {
|
|
631
|
+
try {
|
|
632
|
+
const data = await this.client.text2img(text, apiFields, { signal });
|
|
633
|
+
const url = AlexaAI._outputUrl(data);
|
|
634
|
+
if (url) return { ok: true, url, id: data.id || null, error: null, via: 'api', raw: data };
|
|
635
|
+
errors.push('text2img: no output_url in response');
|
|
636
|
+
} catch (err) {
|
|
637
|
+
errors.push(`text2img: ${err.message}`);
|
|
638
|
+
if (err.code === 'ABORTED') {
|
|
639
|
+
return { ok: false, url: null, id: null, error: 'ABORTED', message: err.message, via: null };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ---- 2. the chat image tool (works on free chat keys) ---------------
|
|
645
|
+
if (!apiOnly) {
|
|
646
|
+
try {
|
|
647
|
+
const answer = await this.client.chatDetailed(
|
|
648
|
+
[{ role: 'user', content: StreamParser.imageToolPayload(text, aspectRatio || '1:1') }],
|
|
649
|
+
{ signal, extraFields: { image_generation: 'true' } }
|
|
650
|
+
);
|
|
651
|
+
const url = answer.images?.[0] || AlexaAI._outputUrl(answer.payload);
|
|
652
|
+
if (url) {
|
|
653
|
+
return { ok: true, url, id: answer.payload?.id || null, error: null, via: 'chat', raw: answer.payload };
|
|
654
|
+
}
|
|
655
|
+
errors.push(`chat tool: no image in reply (${String(answer.text || '').slice(0, 80)})`);
|
|
656
|
+
} catch (err) {
|
|
657
|
+
errors.push(`chat tool: ${err.message}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const message = errors.join(' | ');
|
|
662
|
+
this.log.warn?.(`[AlexaAI] generateImage failed: ${message}`);
|
|
663
|
+
return {
|
|
664
|
+
ok: false,
|
|
665
|
+
url: null,
|
|
666
|
+
id: null,
|
|
667
|
+
error: /credits|exceeded|paid|api-key|api key/i.test(message) ? 'DEEPAI_QUOTA_EXCEEDED' : 'IMAGE_FAILED',
|
|
668
|
+
message,
|
|
669
|
+
via: null,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Prompt-driven image edit (`POST /api/image-editor`).
|
|
675
|
+
* `image` may be a Buffer, base64, data URI, URL or `{ buffer | url }`.
|
|
676
|
+
*/
|
|
677
|
+
async editImage(image, prompt, opts = {}) {
|
|
678
|
+
const field = Media.toApiField(image);
|
|
679
|
+
if (!field) return AlexaAI._mediaError('editImage', 'IMAGE_EDIT_FAILED');
|
|
680
|
+
try {
|
|
681
|
+
const data = await this.client.editImage(field, String(prompt ?? ''), opts);
|
|
682
|
+
return { ok: true, url: AlexaAI._outputUrl(data), id: data.id || null, error: null, raw: data };
|
|
683
|
+
} catch (err) {
|
|
684
|
+
return { ok: false, url: null, id: null, error: err.code || 'IMAGE_EDIT_FAILED', message: err.message };
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** 4x upscale (`POST /api/torch-srgan`). Same input shapes as `editImage`. */
|
|
689
|
+
async upscaleImage(image, opts = {}) {
|
|
690
|
+
const field = Media.toApiField(image);
|
|
691
|
+
if (!field) return AlexaAI._mediaError('upscaleImage', 'UPSCALE_FAILED');
|
|
692
|
+
try {
|
|
693
|
+
const data = await this.client.upscaleImage(field, opts);
|
|
694
|
+
return { ok: true, url: AlexaAI._outputUrl(data), id: data.id || null, error: null, raw: data };
|
|
695
|
+
} catch (err) {
|
|
696
|
+
return { ok: false, url: null, id: null, error: err.code || 'UPSCALE_FAILED', message: err.message };
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Colourise a black-and-white photo (`POST /api/colorizer`). */
|
|
701
|
+
async colorizeImage(image, opts = {}) {
|
|
702
|
+
const field = Media.toApiField(image);
|
|
703
|
+
if (!field) return AlexaAI._mediaError('colorizeImage', 'COLORIZE_FAILED');
|
|
704
|
+
try {
|
|
705
|
+
const data = await this.client.colorizeImage(field, opts);
|
|
706
|
+
return { ok: true, url: AlexaAI._outputUrl(data), id: data.id || null, error: null, raw: data };
|
|
707
|
+
} catch (err) {
|
|
708
|
+
return { ok: false, url: null, id: null, error: err.code || 'COLORIZE_FAILED', message: err.message };
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* NSFW score for moderation (`POST /api/nsfw-detector`).
|
|
714
|
+
* @returns {Promise<{ok:boolean, score:number|null, nsfw:boolean|null, error?:string}>}
|
|
715
|
+
*/
|
|
716
|
+
async detectNsfw(image, opts = {}) {
|
|
717
|
+
const field = Media.toApiField(image);
|
|
718
|
+
if (!field) return { ...AlexaAI._mediaError('detectNsfw', 'NSFW_FAILED'), score: null, nsfw: null };
|
|
719
|
+
const threshold = typeof opts.threshold === 'number' ? opts.threshold : 0.7;
|
|
720
|
+
try {
|
|
721
|
+
const data = await this.client.detectNsfw(field);
|
|
722
|
+
const score = typeof data?.output?.nsfw_score === 'number' ? data.output.nsfw_score : null;
|
|
723
|
+
return { ok: true, score, nsfw: score == null ? null : score >= threshold, error: null, raw: data };
|
|
724
|
+
} catch (err) {
|
|
725
|
+
return { ok: false, score: null, nsfw: null, error: err.code || 'NSFW_FAILED', message: err.message };
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Read an image/document without going through the conversation.
|
|
731
|
+
* Accepts every shape `chat({ image })` accepts, including a bare Buffer.
|
|
732
|
+
*/
|
|
733
|
+
async describeImage(image, caption = '') {
|
|
734
|
+
const media = Media.normalize(image);
|
|
735
|
+
if (!media) return { ...ImageDescriber.fallbackResult('no_image'), text: '' };
|
|
736
|
+
const described = await this.vision.describe(media, String(caption ?? ''));
|
|
737
|
+
// `text` is the WhatsApp-ready answer either way (description or a
|
|
738
|
+
// polite "I can't see it" fallback), so a command can just send it.
|
|
739
|
+
return {
|
|
740
|
+
...described,
|
|
741
|
+
text: described.ok ? ResponseFormatter.format(described.description) : ImageDescriber.fallbackMessage(caption),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Abstractive summary. Tries `POST /api/summarization` first (paid on
|
|
747
|
+
* most keys) and falls back to a stateless chat request, so the call
|
|
748
|
+
* works on free keys too.
|
|
749
|
+
*/
|
|
750
|
+
async summarizeText(text, opts = {}) {
|
|
751
|
+
const input = String(text ?? '').trim();
|
|
752
|
+
if (!input) return { ok: false, text: '', error: 'VALIDATION_ERROR', message: 'summarizeText(): text is required' };
|
|
753
|
+
const errors = [];
|
|
754
|
+
try {
|
|
755
|
+
const data = await this.client.summarize(input);
|
|
756
|
+
const summary = String(data?.output || '').trim();
|
|
757
|
+
if (summary) return { ok: true, text: summary, via: 'api', raw: data };
|
|
758
|
+
errors.push('summarization: empty output');
|
|
759
|
+
} catch (err) {
|
|
760
|
+
errors.push(`summarization: ${err.message}`);
|
|
761
|
+
}
|
|
762
|
+
try {
|
|
763
|
+
const answer = await this.client.chatDetailed(
|
|
764
|
+
[
|
|
765
|
+
{
|
|
766
|
+
role: 'user',
|
|
767
|
+
content:
|
|
768
|
+
'Summarise the following text clearly and concisely in a few short bullet points. ' +
|
|
769
|
+
'Use WhatsApp formatting only (*bold*, _italic_), no markdown headers.\n\n' +
|
|
770
|
+
input.slice(0, this.config.maxMessageLength),
|
|
771
|
+
},
|
|
772
|
+
],
|
|
773
|
+
{ model: opts.model, signal: opts.signal }
|
|
774
|
+
);
|
|
775
|
+
const summary = ResponseFormatter.format(MemoryExtractor.strip(answer.text));
|
|
776
|
+
if (summary) return { ok: true, text: summary, via: 'chat' };
|
|
777
|
+
errors.push('chat: empty reply');
|
|
778
|
+
} catch (err) {
|
|
779
|
+
errors.push(`chat: ${err.message}`);
|
|
780
|
+
}
|
|
781
|
+
return { ok: false, text: '', error: 'SUMMARY_FAILED', message: errors.join(' | ') };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* One-off, stateless web research request — for "search the web for X"
|
|
786
|
+
* commands that must not touch anyone's memory.
|
|
787
|
+
*
|
|
788
|
+
* By default the answer is long-form: an intro, three to five `*Heading:*`
|
|
789
|
+
* sections with numbered `*Headline*: detail` points (about 300–450
|
|
790
|
+
* words), followed by one `*Sources:*` block. Sources come from DeepAI's
|
|
791
|
+
* web-result packet when the model sends one, and are otherwise lifted
|
|
792
|
+
* out of the "Sources:" list the model writes — either way they are
|
|
793
|
+
* returned in `sources` and rendered exactly once in `text`.
|
|
794
|
+
*
|
|
795
|
+
* Small models sometimes ignore the layout and answer in a sentence. When
|
|
796
|
+
* a long-form answer comes back under `minWords`, one follow-up turn asks
|
|
797
|
+
* the model to rewrite it in full; the longer of the two replies wins.
|
|
798
|
+
*
|
|
799
|
+
* The persona still applies (WhatsApp formatting, no renaming of the
|
|
800
|
+
* assistant), but third-party vendor names in the research itself are
|
|
801
|
+
* kept, and the identity/memory guards run without a user context, so
|
|
802
|
+
* this is safe to call with no jid at all.
|
|
803
|
+
*
|
|
804
|
+
* @param {string} query
|
|
805
|
+
* @param {object} [opts]
|
|
806
|
+
* @param {'long'|'short'} [opts.detail='long'] `short` = 2–4 sentences
|
|
807
|
+
* @param {number} [opts.minWords=150] long form only: retry once below this (0 disables)
|
|
808
|
+
* @param {boolean} [opts.includeSources=true] append the *Sources:* block to `text`
|
|
809
|
+
* @param {number} [opts.maxSources=5] sources listed in `text` (the array is not capped)
|
|
810
|
+
* @param {string} [opts.language] answer language, e.g. 'Sinhala'
|
|
811
|
+
* @param {string} [opts.instructions] extra guidance for the model
|
|
812
|
+
* @param {string} [opts.model]
|
|
813
|
+
* @param {string} [opts.userName]
|
|
814
|
+
* @param {AbortSignal} [opts.signal]
|
|
815
|
+
* @returns {Promise<{ok:boolean, text:string, answer:string, sources:Array<{title:string|null,url:string|null,description:string|null}>, words:number, attempts:number, model?:string|null, error?:string, message?:string}>}
|
|
816
|
+
*/
|
|
817
|
+
async searchWeb(query, opts = {}) {
|
|
818
|
+
const question = String(query ?? '').trim();
|
|
819
|
+
const failure = (error, message, extra = {}) => ({
|
|
820
|
+
ok: false, text: '', answer: '', sources: [], words: 0, attempts: 0, error, message, ...extra,
|
|
821
|
+
});
|
|
822
|
+
if (!question) return failure('VALIDATION_ERROR', 'searchWeb(): query is required');
|
|
823
|
+
|
|
824
|
+
const detail = WebAnswer.detailOf(opts.detail);
|
|
825
|
+
const includeSources = opts.includeSources !== false;
|
|
826
|
+
const maxSources = Number.isFinite(opts.maxSources) ? opts.maxSources : 5;
|
|
827
|
+
const minWords = detail === 'long' && Number.isFinite(opts.minWords) ? Math.max(0, opts.minWords) : detail === 'long' ? 150 : 0;
|
|
828
|
+
|
|
829
|
+
const request = { search: true, webAccess: true, model: opts.model, signal: opts.signal };
|
|
830
|
+
const messages = this.prompts.build({
|
|
831
|
+
message: WebAnswer.prompt(question, { detail, language: opts.language, instructions: opts.instructions }),
|
|
832
|
+
memories: {},
|
|
833
|
+
history: [],
|
|
834
|
+
userName: opts.userName || null,
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
try {
|
|
838
|
+
let reply = await this.client.chatDetailed(messages, request);
|
|
839
|
+
let result = this._webAnswerFrom(reply);
|
|
840
|
+
let attempts = 1;
|
|
841
|
+
|
|
842
|
+
if (minWords && result.words < minWords && result.answer) {
|
|
843
|
+
// Second chance: keep the first reply in the transcript so the
|
|
844
|
+
// model sees what it wrote, then demand the full layout.
|
|
845
|
+
const retryMessages = [
|
|
846
|
+
...messages,
|
|
847
|
+
{ role: 'assistant', content: reply.text },
|
|
848
|
+
{ role: 'user', content: WebAnswer.expandPrompt(question, result.words) },
|
|
849
|
+
];
|
|
850
|
+
attempts = 2;
|
|
851
|
+
try {
|
|
852
|
+
const second = await this.client.chatDetailed(retryMessages, request);
|
|
853
|
+
const candidate = this._webAnswerFrom(second, result.sources);
|
|
854
|
+
if (candidate.words > result.words) {
|
|
855
|
+
reply = second;
|
|
856
|
+
result = candidate;
|
|
857
|
+
}
|
|
858
|
+
} catch (err) {
|
|
859
|
+
this.log.debug?.(`[AlexaAI] searchWeb expansion failed, keeping first reply: ${err.message}`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const { answer, sources, words } = result;
|
|
864
|
+
if (!answer && !sources.length) return failure('DEEPAI_EMPTY', 'DeepAI returned no answer', { attempts });
|
|
865
|
+
|
|
866
|
+
const text = WebAnswer.render(answer, sources, { includeSources: includeSources || !answer, maxSources });
|
|
867
|
+
return { ok: true, text, answer, sources, words, attempts, model: reply.model || null };
|
|
868
|
+
} catch (err) {
|
|
869
|
+
this.log.warn?.(`[AlexaAI] searchWeb failed: ${err.message}`);
|
|
870
|
+
return failure(err.code || 'SEARCH_FAILED', err.message);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* @private Turn one chat reply into `{ answer, sources, words }`.
|
|
876
|
+
* Structured packet sources come first (they carry descriptions), then
|
|
877
|
+
* whatever the model listed itself; duplicates collapse by URL.
|
|
878
|
+
*/
|
|
879
|
+
_webAnswerFrom(reply, carried = []) {
|
|
880
|
+
let formatted = ResponseFormatter.format(MemoryExtractor.strip(reply.text));
|
|
881
|
+
// Keep third-party vendor names: this is research output, not the
|
|
882
|
+
// assistant introducing herself. WebAnswer removes the sentences in
|
|
883
|
+
// which the model talks about *itself*.
|
|
884
|
+
formatted = this.identityGuard.sanitise(formatted, false, { vendors: false });
|
|
885
|
+
const parsed = WebAnswer.parse(formatted);
|
|
886
|
+
const sources = WebAnswer.mergeSources(AlexaAI._sources(reply.webResults), parsed.sources, carried);
|
|
887
|
+
return { answer: parsed.text, sources, words: WebAnswer.wordCount(parsed.text) };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Is DeepAI reachable and is the key still good? */
|
|
891
|
+
async deepaiHealth() {
|
|
892
|
+
const started = Date.now();
|
|
893
|
+
try {
|
|
894
|
+
const text = await this.client.chat([{ role: 'user', content: 'Reply with the single word: ok' }], {
|
|
895
|
+
models: [this.config.model],
|
|
896
|
+
});
|
|
897
|
+
return { ok: true, latencyMs: Date.now() - started, reply: text.slice(0, 60), model: this.config.model };
|
|
898
|
+
} catch (err) {
|
|
899
|
+
return { ok: false, latencyMs: Date.now() - started, error: err.code || 'DEEPAI_ERROR', message: err.message };
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Legacy helper kept for callers that used it directly.
|
|
905
|
+
* Prefer `Media.toApiField()`; this now accepts the same input shapes.
|
|
906
|
+
* @deprecated
|
|
907
|
+
*/
|
|
908
|
+
static _imageField(image) {
|
|
909
|
+
return Media.toApiField(image);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** @private the image url carried by an /api/* or tool response. */
|
|
913
|
+
static _outputUrl(data) {
|
|
914
|
+
if (!data || typeof data !== 'object') return null;
|
|
915
|
+
const url = data.output_url || data.share_url || data.url || (Array.isArray(data.output) ? data.output[0] : null);
|
|
916
|
+
return typeof url === 'string' && url ? url : null;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** @private consistent error for an unusable media argument. */
|
|
920
|
+
static _mediaError(method, code) {
|
|
921
|
+
return {
|
|
922
|
+
ok: false,
|
|
923
|
+
url: null,
|
|
924
|
+
id: null,
|
|
925
|
+
error: code,
|
|
926
|
+
message: `${method}(): pass a Buffer, base64 string, data URI, URL or { buffer | url } object`,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** @private normalise DeepAI's web-result payload to {title, url, description}. */
|
|
931
|
+
static _sources(webResults) {
|
|
932
|
+
if (!Array.isArray(webResults)) return [];
|
|
933
|
+
return webResults
|
|
934
|
+
.map((r) => {
|
|
935
|
+
if (typeof r === 'string') return { title: null, url: r, description: null };
|
|
936
|
+
if (!r || typeof r !== 'object') return null;
|
|
937
|
+
return {
|
|
938
|
+
title: r.title || r.name || null,
|
|
939
|
+
url: r.url || r.link || r.href || null,
|
|
940
|
+
description: r.description || r.snippet || r.content || null,
|
|
941
|
+
};
|
|
942
|
+
})
|
|
943
|
+
.filter((r) => r && (r.url || r.title));
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// =====================================================================
|
|
947
|
+
// Memory / admin helpers
|
|
948
|
+
// =====================================================================
|
|
949
|
+
|
|
950
|
+
/** All remembered facts for a user, as `{key: value}`. */
|
|
951
|
+
async getMemories(userJid) {
|
|
952
|
+
const user = await this.users.findByJid(userJid);
|
|
953
|
+
if (!user) return {};
|
|
954
|
+
return this.memories.getMap(user.id, this.config.maxMemories);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** Manually store a fact. */
|
|
958
|
+
async remember(userJid, key, value) {
|
|
959
|
+
const user = await this.users.upsertUser(userJid);
|
|
960
|
+
return this.memories.remember(user.id, key, value, { source: 'manual' });
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Delete one fact. */
|
|
964
|
+
async forget(userJid, key) {
|
|
965
|
+
const user = await this.users.findByJid(userJid);
|
|
966
|
+
if (!user) return false;
|
|
967
|
+
return this.memories.forget(user.id, key);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** Delete every fact for a user. */
|
|
971
|
+
async forgetAll(userJid) {
|
|
972
|
+
const user = await this.users.findByJid(userJid);
|
|
973
|
+
if (!user) return 0;
|
|
974
|
+
return this.memories.forgetAll(user.id);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** Wipe one thread's transcript (memories survive). */
|
|
978
|
+
async clearHistory(userJid, groupJid = null) {
|
|
979
|
+
// Threads are keyed by the person's canonical address, so resolve the
|
|
980
|
+
// alias the caller happened to use.
|
|
981
|
+
const user = await this.users.findByJid(userJid);
|
|
982
|
+
const canonical = user ? await this.identities.primaryJid(user.id, user.jid) : userJid;
|
|
983
|
+
const contextKey = JidParser.contextKey(canonical, groupJid, this.config.sharedGroupThread);
|
|
984
|
+
return this.conversations.clearHistory(contextKey);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** Full profile: user row, memories, threads. */
|
|
988
|
+
async getProfile(userJid) {
|
|
989
|
+
const user = await this.users.findByJid(userJid);
|
|
990
|
+
if (!user) return null;
|
|
991
|
+
const [memories, conversations] = await Promise.all([
|
|
992
|
+
this.memories.getAll(user.id, this.config.maxMemories),
|
|
993
|
+
this.conversations.listForUser(userJid),
|
|
994
|
+
]);
|
|
995
|
+
return { user, memories, conversations };
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Block a person from using the AI. Works with ANY address they are known
|
|
1000
|
+
* under (their @lid or their phone jid) and creates the row if they have
|
|
1001
|
+
* never messaged, so a pre-emptive block sticks.
|
|
1002
|
+
* @returns {Promise<object>} the user row
|
|
1003
|
+
*/
|
|
1004
|
+
async blockUser(userJid) {
|
|
1005
|
+
return this.users.setBlocked(userJid, true);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** @returns {Promise<object|null>} the user row (null if never seen) */
|
|
1009
|
+
async unblockUser(userJid) {
|
|
1010
|
+
return this.users.setBlocked(userJid, false);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Is this person blocked? Follows aliases like everything else. */
|
|
1014
|
+
async isBlocked(userJid) {
|
|
1015
|
+
return this.users.isBlocked(userJid);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Turn the AI on/off inside one group. Creates the group row when the bot
|
|
1020
|
+
* has not seen the group yet, so the setting applies from the first message.
|
|
1021
|
+
* @returns {Promise<object>} the group row
|
|
1022
|
+
*/
|
|
1023
|
+
async setGroupEnabled(groupJid, enabled = true) {
|
|
1024
|
+
return this.users.setGroupEnabled(groupJid, enabled);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** Is the AI enabled in this group? (unknown groups are enabled) */
|
|
1028
|
+
async isGroupEnabled(groupJid) {
|
|
1029
|
+
const group = await this.users.findGroupByJid(groupJid);
|
|
1030
|
+
return group ? group.is_enabled !== false : true;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async stats() {
|
|
1034
|
+
return this.users.stats();
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// =====================================================================
|
|
1038
|
+
// Internals
|
|
1039
|
+
// =====================================================================
|
|
1040
|
+
|
|
1041
|
+
/** @private */
|
|
1042
|
+
static _normaliseParams(params) {
|
|
1043
|
+
// `image` may be a Buffer, base64, data URI, URL, or a { buffer | url }
|
|
1044
|
+
// object (see utils/Media). `file` / `media` / `attachment` are aliases.
|
|
1045
|
+
const rawImage = params.image ?? params.file ?? params.media ?? params.attachment ?? null;
|
|
1046
|
+
return {
|
|
1047
|
+
message: params.message == null ? '' : String(params.message).trim(),
|
|
1048
|
+
userId: params.userId ?? params.user ?? params.jid,
|
|
1049
|
+
groupId: params.groupId ?? params.group ?? null,
|
|
1050
|
+
userName: params.userName ?? params.pushName ?? null,
|
|
1051
|
+
groupName: params.groupName ?? params.subject ?? null,
|
|
1052
|
+
image: Media.normalize(rawImage),
|
|
1053
|
+
messageId: params.messageId ?? null,
|
|
1054
|
+
isAdmin: Boolean(params.isAdmin),
|
|
1055
|
+
signal: params.signal,
|
|
1056
|
+
onToken: typeof params.onToken === 'function' ? params.onToken : null,
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** @private */
|
|
1061
|
+
static _result({
|
|
1062
|
+
text,
|
|
1063
|
+
raw = '',
|
|
1064
|
+
memories = {},
|
|
1065
|
+
trigger = null,
|
|
1066
|
+
contextKey,
|
|
1067
|
+
isGroup,
|
|
1068
|
+
userName,
|
|
1069
|
+
latencyMs,
|
|
1070
|
+
error = null,
|
|
1071
|
+
images = [],
|
|
1072
|
+
model = null,
|
|
1073
|
+
userId = null,
|
|
1074
|
+
aliases = [],
|
|
1075
|
+
mergedIdentities = false,
|
|
1076
|
+
repairedMemory = false,
|
|
1077
|
+
}) {
|
|
1078
|
+
return {
|
|
1079
|
+
text,
|
|
1080
|
+
raw,
|
|
1081
|
+
memories,
|
|
1082
|
+
trigger,
|
|
1083
|
+
isGroup,
|
|
1084
|
+
contextKey,
|
|
1085
|
+
userName,
|
|
1086
|
+
latencyMs,
|
|
1087
|
+
chunks: ResponseFormatter.chunk(text),
|
|
1088
|
+
error,
|
|
1089
|
+
images,
|
|
1090
|
+
model,
|
|
1091
|
+
userId,
|
|
1092
|
+
aliases,
|
|
1093
|
+
mergedIdentities,
|
|
1094
|
+
repairedMemory,
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
module.exports = AlexaAI;
|