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/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;