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/README.md ADDED
@@ -0,0 +1,862 @@
1
+ # alexa-ai
2
+
3
+ > The AI engine behind the Alexa WhatsApp bot — DeepAI-powered conversation with
4
+ > PostgreSQL-backed long-term memory and cross-chat identity.
5
+
6
+ [![Node.js ≥ 18](https://img.shields.io/badge/node-%E2%89%A5%2018-339933?logo=node.js&logoColor=white)](https://nodejs.org)
7
+ [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-12%2B-4169E1?logo=postgresql&logoColor=white)](https://www.postgresql.org)
8
+ [![Tests](https://img.shields.io/badge/tests-392%20passing-brightgreen)](#testing)
9
+ [![License: ISC](https://img.shields.io/badge/license-ISC-blue.svg)](LICENSE)
10
+ [![Version](https://img.shields.io/badge/version-2.1.0-informational)](CHANGELOG.md)
11
+
12
+ `alexa-ai` is a standalone Node.js library with **no WhatsApp code in it**. Your
13
+ bot — Baileys, whatsapp-web.js or anything else — hands it a message and a
14
+ sender, and receives a WhatsApp-ready reply. Everything in between — persona,
15
+ memory, identity resolution, media understanding, output formatting and the
16
+ DeepAI transport — is handled by the engine.
17
+
18
+ ```js
19
+ const AlexaAI = require('alexa-ai');
20
+
21
+ const ai = new AlexaAI({
22
+ key: process.env.DEEPAI_API_KEY,
23
+ postgresUrl: process.env.POSTGRES_URL,
24
+ });
25
+
26
+ const { text } = await ai.chat({
27
+ message: "Hi, I'm Nimal and I love cricket",
28
+ userId: '78151912841263@lid', // sender (DM or group)
29
+ groupId: '120363413125431525@g.us', // omit for a DM
30
+ userName: 'Nimal',
31
+ });
32
+
33
+ await sock.sendMessage(jid, { text }); // your WhatsApp layer
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Highlights
39
+
40
+ - **Long-term memory in PostgreSQL.** Facts learned about a person survive
41
+ restarts and are available in their private chat *and* in every group.
42
+ - **One person, many addresses.** WhatsApp uses `…@lid` in groups and
43
+ `…@s.whatsapp.net` in DMs. The engine links every address to a single
44
+ identity and merges duplicate rows automatically.
45
+ - **The whole DeepAI surface.** Chat, attachments, reasoning tasks, sessions,
46
+ account settings and the classic `/api/*` family — text-to-image, upscaling,
47
+ editing, colourising, NSFW detection, summarisation — from one client.
48
+ - **Works on free keys.** Image generation, summarisation and image reading
49
+ fall back to routes that work on anonymous `tryit-…` keys.
50
+ - **WhatsApp-native output.** Markdown is converted to WhatsApp formatting,
51
+ long replies are chunked, and DeepAI's wire packets never reach the user.
52
+ - **A persona you can rely on.** Deterministic command triggers, an identity
53
+ lock and a memory guard keep Alexa in character whatever the backend returns.
54
+ - **Object-oriented and testable.** Every responsibility is its own class, and
55
+ 300+ assertions run with no network and no database.
56
+
57
+ ---
58
+
59
+ ## Contents
60
+
61
+ - [Requirements](#requirements)
62
+ - [Installation](#installation)
63
+ - [Quick start](#quick-start)
64
+ - [Usage](#usage)
65
+ - [`ai.chat(params)`](#aichatparams)
66
+ - [Integrating with an existing bot](#integrating-with-an-existing-bot)
67
+ - [Identity: one person, many addresses](#identity-one-person-many-addresses)
68
+ - [Memory](#memory)
69
+ - [Images and documents](#images-and-documents)
70
+ - [Image generation, web search and other tools](#image-generation-web-search-and-other-tools)
71
+ - [Moderation and administration](#moderation-and-administration)
72
+ - [Configuration](#configuration)
73
+ - [Architecture](#architecture)
74
+ - [Database schema](#database-schema)
75
+ - [DeepAI API reference](#deepai-api-reference)
76
+ - [Troubleshooting](#troubleshooting)
77
+ - [Testing](#testing)
78
+ - [Examples](#examples)
79
+ - [Changelog](#changelog)
80
+ - [License](#license)
81
+
82
+ ---
83
+
84
+ ## Requirements
85
+
86
+ | | |
87
+ | --- | --- |
88
+ | **Node.js** | 18 or newer — the engine uses the built-in `fetch`, `FormData` and `Blob` |
89
+ | **PostgreSQL** | 12 or newer, local or managed (Supabase, Neon, Railway, Heroku…) |
90
+ | **DeepAI key** | an anonymous `tryit-…` key is enough for chat, memory, OCR and image generation; a paid key additionally unlocks native vision and the `/api/*` endpoints |
91
+
92
+ ---
93
+
94
+ ## Installation
95
+
96
+ The package is distributed from this repository rather than the npm registry:
97
+
98
+ ```bash
99
+ npm install github:AlexaInc/deepai
100
+ # or, from a local checkout
101
+ npm install /path/to/deepai
102
+ ```
103
+
104
+ `pg` is the only runtime dependency and is installed automatically.
105
+
106
+ Provide the two required settings through the environment (or pass them to the
107
+ constructor — see [Configuration](#configuration)):
108
+
109
+ ```bash
110
+ DEEPAI_API_KEY=tryit-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
111
+ POSTGRES_URL=postgres://user:password@host:5432/alexa
112
+ ```
113
+
114
+ Tables are created automatically on first connect. To run the migration
115
+ explicitly (for example in a deploy step):
116
+
117
+ ```bash
118
+ POSTGRES_URL=... npm run migrate
119
+ ```
120
+
121
+ To confirm the installed build:
122
+
123
+ ```bash
124
+ node -e "console.log(require('alexa-ai').version)" # 2.1.0
125
+ ```
126
+
127
+ ---
128
+
129
+ ## Quick start
130
+
131
+ ```js
132
+ const AlexaAI = require('alexa-ai');
133
+
134
+ const ai = new AlexaAI({
135
+ key: process.env.DEEPAI_API_KEY,
136
+ postgresUrl: process.env.POSTGRES_URL,
137
+ });
138
+
139
+ // A Baileys message from a group. Baileys exposes both addresses of the sender:
140
+ // the privacy jid used in the group and the phone jid used in DMs.
141
+ const { text } = await ai.chat({
142
+ message: text,
143
+ userId: msg.key.participant, // 78151912841263@lid
144
+ aliases: [msg.key.participantAlt], // 94771234567@s.whatsapp.net
145
+ groupId: msg.key.remoteJid, // 120363413125431525@g.us
146
+ userName: msg.pushName,
147
+ });
148
+
149
+ await sock.sendMessage(msg.key.remoteJid, { text });
150
+ ```
151
+
152
+ That is the whole integration. The engine opens its connection pool lazily on
153
+ the first call; call `await ai.close()` on shutdown.
154
+
155
+ ---
156
+
157
+ ## Usage
158
+
159
+ ### `ai.chat(params)`
160
+
161
+ Handles one incoming message: resolves the sender to a person, loads their
162
+ memory and the thread history, builds the prompt, calls DeepAI, post-processes
163
+ the reply and persists everything.
164
+
165
+ | param | type | required | notes |
166
+ | ----------- | ----------------- | -------- | -------------------------------------------------- |
167
+ | `message` | `string` | ✔\* | the user's text |
168
+ | `userId` | `string` | ✔ | `78151912841263@lid` or `947…@s.whatsapp.net` |
169
+ | `userLid` | `string` | – | the sender's `@lid` address, if known |
170
+ | `userPhone` | `string` | – | phone jid **or** bare number behind the `@lid` |
171
+ | `aliases` | `string[]` | – | any other address for the same person |
172
+ | `groupId` | `string` | – | `120363413125431525@g.us`; omit or `''` for a DM |
173
+ | `userName` | `string` | – | WhatsApp push name |
174
+ | `groupName` | `string` | – | group subject |
175
+ | `image` | see below | – | an attached image or document |
176
+ | `messageId` | `string` | – | WhatsApp message id, used to de-duplicate redeliveries |
177
+ | `isAdmin` | `boolean` | – | sender is a group admin |
178
+ | `model` | `string` | – | override the DeepAI model for this turn |
179
+ | `webAccess` | `boolean` | – | let DeepAI search the web for this turn |
180
+ | `thinking` | `boolean` | – | use the asynchronous reasoning path |
181
+ | `onToken` | `function` | – | `(delta, full)` streaming callback |
182
+ | `signal` | `AbortSignal` | – | cancel an in-flight request |
183
+
184
+ \* required unless an `image` is supplied.
185
+
186
+ `image` accepts a `Buffer`, `Uint8Array`, data URI, raw base64 string, `http(s)`
187
+ URL, or a `{ buffer | base64 | data | url, mimetype?, filename? }` object —
188
+ Baileys and whatsapp-web.js media objects work as-is. The content type is
189
+ detected from the bytes when it is missing or wrong.
190
+
191
+ > **Pass every address you have.** Supplying both the `@lid` and the phone jid
192
+ > (Baileys: `key.participant` and `key.participantAlt`) is what lets Alexa
193
+ > recognise a DM user inside a group. See [Identity](#identity-one-person-many-addresses).
194
+
195
+ Returns:
196
+
197
+ ```js
198
+ {
199
+ text : 'Nice to meet you, Nimal! 🏏', // clean, WhatsApp-ready
200
+ raw : '...@MEMORY: {"name":"Nimal"}', // unmodified model output
201
+ memories : { name: 'Nimal', hobby: 'cricket' }, // facts learned this turn
202
+ trigger : null, // 'weather' | 'menu' | 'ping' | 'doc' when matched
203
+ isGroup : false,
204
+ contextKey: 'dm:78151912841263@lid',
205
+ userName : 'Nimal',
206
+ userId : 42, // the canonical person behind every alias
207
+ aliases : ['78151912841263@lid', '94771234567@s.whatsapp.net'],
208
+ mergedIdentities: false, // true when two rows were folded into one
209
+ repairedMemory : false, // true when an "I can't remember" denial was corrected
210
+ images : [], // urls when the model used its image tool
211
+ model : 'standard', // the model that actually answered
212
+ latencyMs : 1420,
213
+ chunks : ['...'], // pre-split for WhatsApp's length cap
214
+ error : null // 'user_blocked' | 'group_disabled' | 'DEEPAI_QUOTA_EXCEEDED' | ...
215
+ }
216
+ ```
217
+
218
+ `chat()` never throws on a network or model failure — it returns a friendly
219
+ `text` and sets `error`, so the bot always has something to send. It *does*
220
+ throw `ValidationError` for a malformed `userId`.
221
+
222
+ ### Integrating with an existing bot
223
+
224
+ [`examples/bot-ai.js`](examples/bot-ai.js) is a complete drop-in module for a
225
+ bot whose AI layer has the classic callback signature. Copy it to
226
+ `src/modules/Aii.js` (or wherever your bot requires it) and nothing else
227
+ changes:
228
+
229
+ ```js
230
+ const ai = require('./modules/Aii');
231
+
232
+ // string message
233
+ const reply = await ai(text, senderJid, groupJid, pushName);
234
+
235
+ // message with an attachment, plus both sender addresses
236
+ const reply = await ai({ text, files: [buffer] }, { id: lid, phone: phoneJid }, groupJid, pushName);
237
+
238
+ // straight from a Baileys message object
239
+ const reply = await ai.fromMessage(msg, sock);
240
+ ```
241
+
242
+ The module also exposes the administration and media helpers described below
243
+ (`ai.generateImage`, `ai.searchWeb`, `ai.blockUser`, …) and checks the
244
+ installed engine version at startup.
245
+
246
+ If you prefer to keep your own module, `engine.ask()` has the same
247
+ `(message, userId, groupId, userName, callback)` signature, including the
248
+ `{ text, files: [] }` message shape.
249
+
250
+ ### Identity: one person, many addresses
251
+
252
+ WhatsApp addresses the same human differently depending on where they write:
253
+
254
+ ```
255
+ DM -> key.remoteJid = 94771234567@s.whatsapp.net (phone jid)
256
+ GROUP -> key.participant = 78151912841263@lid (privacy / LID jid)
257
+ ```
258
+
259
+ A naive implementation keys users on the jid and ends up with two rows — one
260
+ that knows everything about the person and one that knows nothing. Since LID
261
+ addressing is now the default in groups, this affects every user.
262
+
263
+ `alexa-ai` models identity as an **alias graph**:
264
+
265
+ ```
266
+ 94771234567@s.whatsapp.net ─┐
267
+ 78151912841263@lid ─────────┤ wa_user_identities ┌────────────────────┐
268
+ 94771234567:12@s.whatsapp.net┤ (jid -> user_id) ─────►│ wa_users id = 42 │
269
+ …any future address ────────┘ └─────────┬──────────┘
270
+ │ user_id
271
+ ┌────────────▼─────────────┐
272
+ │ wa_memories │
273
+ │ (user_id, key) UNIQUE │
274
+ └──────────────────────────┘
275
+
276
+ conversations stay separate so chat context never bleeds between rooms
277
+ (keyed on the person's canonical address, so they survive an address change):
278
+ dm:94771234567@s.whatsapp.net
279
+ group:120363413125431525@g.us:94771234567@s.whatsapp.net
280
+ group:120363999888777666@g.us:94771234567@s.whatsapp.net
281
+ ```
282
+
283
+ Pass every address you have and the engine links them. If two addresses turn
284
+ out to belong to rows that already exist, the rows are **merged** inside a
285
+ transaction — memories, transcripts, group membership and counters move to the
286
+ surviving row.
287
+
288
+ ```js
289
+ // Baileys provides both addresses on a group message
290
+ const sender = msg.key.participant || msg.key.remoteJid; // 781…@lid
291
+ const senderAlt = msg.key.participantAlt || msg.key.participantPn; // 947…@s.whatsapp.net
292
+
293
+ await ai.chat({ message, userId: sender, aliases: [senderAlt], groupId, userName });
294
+
295
+ // or record a mapping whenever you learn one
296
+ await ai.linkIdentity('78151912841263@lid', '94771234567@s.whatsapp.net');
297
+
298
+ await ai.getAliases('78151912841263@lid'); // every address for this person
299
+ await ai.whoIs('94771234567@s.whatsapp.net'); // { user, aliases, memories }
300
+ ```
301
+
302
+ Rules the engine follows:
303
+
304
+ - **Memories are global per person.** A fact learned in a DM is available in
305
+ every group, and vice versa.
306
+ - **Transcripts are per thread.** A DM and each group keep independent history,
307
+ so group chatter never contaminates a private conversation.
308
+ - **A jid belongs to exactly one person.** When an address appears under a
309
+ second user, the rows are merged: the older row survives, and the newest
310
+ value wins per memory key.
311
+ - **`@lid` is a privacy id, never a phone number.** `phone` stays `NULL` for
312
+ those identities until a phone jid is linked.
313
+ - **Device suffixes are ignored.** `94771234567:12@s.whatsapp.net` and
314
+ `94771234567@s.whatsapp.net` are the same person.
315
+ - **Nothing breaks without aliases.** If you only ever pass one address, the
316
+ engine behaves as a plain one-row-per-jid system.
317
+
318
+ Example session against a live database and the live API:
319
+
320
+ ```
321
+ [DM] "Hi, I'm Nimal and I love cricket. I live in Galle."
322
+ -> learned {name: Nimal, location: Galle, hobby: cricket}
323
+ [GROUP A] "Do you remember my name and where I live?"
324
+ -> "I do! Your name is Nimal, and I believe you're from Galle."
325
+ [GROUP B] "What is my hobby?"
326
+ -> "Your hobby is cricket."
327
+ [GROUP A] a different user asks "Do you know my name?"
328
+ -> "I don't have that information yet." ← correctly isolated
329
+ ```
330
+
331
+ ### Memory
332
+
333
+ Facts are learned in two ways and stored under `UNIQUE (user_id, key)`, so
334
+ re-learning `name` overwrites it instead of creating duplicates:
335
+
336
+ 1. **From the model.** The persona asks the model to append
337
+ `@MEMORY: {"key": "value"}` to a reply whenever the user reveals something
338
+ personal. `MemoryExtractor` parses the tag (including malformed variants)
339
+ and strips it from the visible text.
340
+ 2. **From the user's own words.** Small models frequently ignore the tag
341
+ rule, so `FactMiner` extracts high-confidence first-person facts locally
342
+ (`my name is…`, `I live in…`, `I support…`) and skips third-party
343
+ statements (`my friend lives in Kandy`). Model-emitted tags win on conflict.
344
+
345
+ Stored facts are injected into every prompt for that person — as a block in
346
+ the persona and again as a compact note directly above the live message, where
347
+ they are far less likely to be diluted by a long system prompt. A recall
348
+ question (*"do you remember me?"*) additionally receives an explicit
349
+ `[MEMORY CHECK]` directive, and `AmnesiaGuard` rewrites any residual
350
+ *"as a bot I can't remember"* from the database so the reply is never false.
351
+
352
+ ```js
353
+ await ai.getMemories(jid); // { name: 'Nimal', ... }
354
+ await ai.remember(jid, 'favourite_team', 'Sri Lanka');
355
+ await ai.forget(jid, 'favourite_team');
356
+ await ai.forgetAll(jid);
357
+ await ai.clearHistory(jid); // wipe the DM transcript (memories stay)
358
+ await ai.clearHistory(jid, groupJid); // wipe one group thread
359
+ await ai.getProfile(jid); // user + memories + threads
360
+ ```
361
+
362
+ Every `jid` argument follows the alias graph, so any address the person is
363
+ known under works.
364
+
365
+ ### Images and documents
366
+
367
+ An attachment on `chat()` runs through a provider chain; the first provider
368
+ that produces text wins:
369
+
370
+ | # | Provider | Handles | Free key |
371
+ |---|----------|---------|----------|
372
+ | 0 | DeepAI document extraction | `.txt` `.pdf` `.docx` `.csv` `.md` … | ✅ |
373
+ | 1 | DeepAI native vision | full understanding of any photo | paid plans |
374
+ | 2 | OCR (`ocr.space`) | text inside images and screenshots | ✅ |
375
+ | 3 | Honest fallback | photos with no readable text | ✅ |
376
+
377
+ - The file is uploaded **once** and reused by every provider.
378
+ - Vision walks the full `visionModels` chain per model. A plan refusal puts
379
+ native vision on a 30-minute cooldown rather than disabling it for the life
380
+ of the process, so upgrading the key simply starts working.
381
+ - When the upload succeeded but nothing could be pre-read, the attachment
382
+ uuid is forwarded with the real conversation; an account with vision then
383
+ sees the picture in full context.
384
+ - A photo with no readable text gets an honest reply asking what it shows —
385
+ the model is never allowed to invent a description.
386
+ - Oversized files are rejected (`maxImageBytes`, default 12 MB) rather than
387
+ truncated.
388
+
389
+ ```js
390
+ // a screenshot -> OCR
391
+ await ai.chat({ message: 'what does this say?', userId, image: buffer });
392
+ // -> "It says: SECRETCODE ZQ7412 …"
393
+
394
+ // a document -> server-side extraction
395
+ await ai.chat({ message: 'what is the total?', userId,
396
+ image: { buffer, mimetype: 'text/plain', filename: 'invoice.txt' } });
397
+ // -> "`Total: 4500 LKR, due 2026-10-01`"
398
+
399
+ // read something without touching the conversation
400
+ await ai.describeImage(buffer, 'is this a receipt?'); // { ok, text, description, source }
401
+ ```
402
+
403
+ > **Tip:** the default OCR key is `ocr.space`'s shared demo key and is
404
+ > rate-limited. Get a free key at [ocr.space/ocrapi](https://ocr.space/ocrapi)
405
+ > and set `OCR_API_KEY` (or `ocrApiKey`).
406
+
407
+ ### Image generation, web search and other tools
408
+
409
+ These helpers live on the engine and **never throw** — each resolves to
410
+ `{ ok, …, error?, message? }` so a command handler can check `ok` and forward
411
+ `message` when it is false.
412
+
413
+ ```js
414
+ await ai.generateImage('a red tuk-tuk in Galle at sunset'); // { ok, url, id, via }
415
+ await ai.searchWeb('coffee'); // { ok, text, answer, sources: [{ title, url }] }
416
+ await ai.summarizeText(longText); // { ok, text }
417
+ await ai.describeImage(buffer, caption); // { ok, text, description, source }
418
+
419
+ await ai.upscaleImage(buffer); // { ok, url } 4x super-resolution
420
+ await ai.editImage(buffer, 'make the sky purple'); // { ok, url }
421
+ await ai.colorizeImage(buffer); // { ok, url }
422
+ await ai.detectNsfw(buffer); // { ok, score, nsfw }
423
+
424
+ await ai.deepaiHealth(); // { ok, latencyMs, reply }
425
+ await ai.deepai.runApi('waifu2x', { image: buffer }); // any /api/<name> endpoint
426
+ ```
427
+
428
+ All media arguments accept the same shapes as `chat({ image })`.
429
+
430
+ **`generateImage()` on a free key.** `POST /api/text2img` is a paid endpoint:
431
+ anonymous keys receive `{"status": "Out of API credits"}`. The engine tries it
432
+ first because it is fast and returns a plain URL; when it is refused, it
433
+ drives the same in-chat `generate_image` tool the deepai.org web client uses,
434
+ which works on free chat keys. The result reports the route that answered
435
+ (`via: 'api' | 'chat'`). Options: `{ apiOnly }`, `{ chatToolOnly }`,
436
+ `{ aspectRatio: '16:9' }` for the chat tool, and `width` / `height` /
437
+ `image_generator_version` for the API.
438
+
439
+ **`summarizeText()`** follows the same pattern — `/api/summarization` first, a
440
+ stateless chat request as the fallback.
441
+
442
+ **`searchWeb()`** is a one-off research request with DeepAI's web access
443
+ enabled. It applies the persona and formatting rules but touches no user's
444
+ memory or history, so it can be called with no jid at all. The default answer
445
+ is long-form and ready to send to WhatsApp — an intro, three to five
446
+ `*Heading:*` sections with numbered `*Title*: detail` points, and one
447
+ `*Sources:*` block at the end:
448
+
449
+ ```
450
+ Coffee remains one of the most traded commodities in the world, and 2026 has
451
+ brought record prices.
452
+
453
+ *Recent Coffee News:*
454
+ 1. *Arabica futures hit a high*: Prices rose 12% in August after frost damaged
455
+ crops in Brazil.
456
+ 2. *Starbucks menu shake-up*: The chain removed 30% of its drinks.
457
+
458
+
459
+ *Coffee Trends:*
460
+ 1. *Cold brew keeps growing*: Ready-to-drink sales are up 20%.
461
+
462
+
463
+ *Sources:*
464
+ 1. Reuters – Coffee prices — https://www.reuters.com/…
465
+ 2. National Coffee Association — https://www.ncausa.org/
466
+ ```
467
+
468
+ The model is given the layout as a fill-in template rather than a description
469
+ of one; small models follow a visible layout far more reliably. If a long-form
470
+ answer still comes back under `minWords` (default 150), one follow-up turn asks
471
+ the model to rewrite it in full, and the longer reply wins — `attempts` and
472
+ `words` in the result show what happened.
473
+
474
+ DeepAI reports the pages it used in two different ways depending on the model
475
+ that answered: some send a structured web-results packet, others (observed
476
+ with `gpt-4o-mini`) just write a `Sources:` list in prose. Both end up in the
477
+ `sources` array as `{ title, url, description }`; the list is rendered exactly
478
+ once in `text`, and `answer` holds the prose without it. Sentences in which
479
+ the model talks about itself — *"I'm a language model"*, *"I can't browse the
480
+ web"*, *"based on my training data"* — are removed, and leftover template
481
+ placeholders are dropped. Third-party names in the research itself (news
482
+ about OpenAI or Google) are kept verbatim.
483
+
484
+ ```js
485
+ await ai.searchWeb('coffee', {
486
+ detail: 'short', // 2–4 sentences instead of the sectioned long form
487
+ minWords: 0, // never retry a short long-form answer (default 150)
488
+ includeSources: false, // keep the *Sources:* block out of `text`
489
+ maxSources: 3, // how many to list in `text` (the array is not capped)
490
+ language: 'Sinhala', // answer language (default: the language of the query)
491
+ instructions: 'focus on Sri Lanka',
492
+ });
493
+ ```
494
+
495
+ ### Moderation and administration
496
+
497
+ ```js
498
+ await ai.blockUser(jid); // any of the person's addresses
499
+ await ai.unblockUser(jid);
500
+ await ai.isBlocked(jid);
501
+
502
+ await ai.setGroupEnabled(groupJid, false); // mute Alexa in one group
503
+ await ai.isGroupEnabled(groupJid); // unknown groups are enabled
504
+
505
+ await ai.stats(); // { users, groups, conversations, messages, memories, active_24h }
506
+ await ai.health(); // { ok, now, database }
507
+ await ai.close(); // close the pool on shutdown
508
+ ```
509
+
510
+ `blockUser()` and `setGroupEnabled()` create the row when the person or group
511
+ has never been seen, so a pre-emptive block or mute is already in force on
512
+ the first message. Blocked users and disabled groups get
513
+ `{ text: '', error: 'user_blocked' | 'group_disabled' }` from `chat()`, so the
514
+ bot can simply skip sending.
515
+
516
+ ---
517
+
518
+ ## Configuration
519
+
520
+ ```js
521
+ new AlexaAI({
522
+ key: 'tryit-...', // required (alias: apiKey); falls back to DEEPAI_API_KEY
523
+ postgresUrl: 'postgres://...', // required (alias: databaseUrl); falls back to POSTGRES_URL / DATABASE_URL
524
+
525
+ // DeepAI
526
+ model: 'standard', // chat model
527
+ fallbackModels: ['standard'], // tried in order when the main model is refused
528
+ visionModel: 'gpt-4o-mini', // first model tried for attachments
529
+ visionModels: [...], // full vision fallback chain
530
+ keys: ['tryit-a', 'tryit-b'], // rotated automatically on "try it exceeded"
531
+ autoKeyRotation: false, // mint a fresh anonymous key when every key is spent
532
+ webAccess: false, // DeepAI web search on every turn
533
+ thinkingSupport: false, // asynchronous reasoning path
534
+ serverMemory: false, // DeepAI's own /chat_memory profile
535
+ enabledTools: ['image_generator', 'image_editor'],
536
+ endpoints: { chat: '/hacking_is_a_serious_crime', ... }, // override any route
537
+
538
+ // Persona
539
+ assistantName: 'Alexa', // also drives IdentityGuard / AmnesiaGuard
540
+ creator: 'Hansaka',
541
+ systemPrompt: '...', // replace the whole persona
542
+ systemRole: true, // also send a role:'system' digest
543
+ identityLock: true, // inject the identity lock on identity questions
544
+ amnesiaGuard: true, // repair "I can't remember" denials
545
+
546
+ // Identity & memory
547
+ linkIdentities: true, // @lid <-> phone alias graph
548
+ mergeIdentities: true, // merge rows that prove to be one person
549
+ historyLimit: 14, // past messages replayed to the model
550
+ maxMemories: 25, // facts injected per request
551
+ sharedGroupThread: false, // true = one thread per group instead of per member
552
+ triggers: true, // deterministic weather/menu/ping/doc matching
553
+ memory: true, // long-term memory
554
+ factMining: true, // local fact extraction
555
+
556
+ // Media
557
+ ocr: true,
558
+ ocrApiKey: process.env.OCR_API_KEY,
559
+ maxImageBytes: 12 * 1024 * 1024,
560
+
561
+ // Infrastructure
562
+ timeout: 60000,
563
+ maxRetries: 2,
564
+ autoMigrate: true,
565
+ ssl: undefined, // auto: off for localhost, relaxed for managed PostgreSQL
566
+ pool: { max: 10 }, // extra node-postgres pool options
567
+ logger: console,
568
+ debug: false,
569
+ });
570
+ ```
571
+
572
+ ---
573
+
574
+ ## Architecture
575
+
576
+ The bot talks to one class, `AlexaAI`. Every other responsibility is its own
577
+ class with a single job:
578
+
579
+ ```
580
+ AlexaAI orchestrator; the only class the bot touches
581
+ ├── Config validated settings, env fallbacks, redacted logging
582
+ ├── Endpoints every DeepAI route in one overridable map
583
+ ├── DeepAIClient DeepAI transport: chat, tasks, attachments, sessions,
584
+ │ settings, /api/* — retries and key rotation
585
+ ├── StreamParser splits DeepAI's stream: text | tool activity |
586
+ │ web results | generated images | chain-of-thought
587
+ ├── Persona / SystemPrompt the Alexa prompt, renameable per deployment
588
+ ├── Database pg pool, migrations, transactions
589
+ ├── UserRepository users, groups, membership, blocking
590
+ ├── IdentityRepository the alias graph (@lid <-> phone) and row merging
591
+ ├── MemoryRepository long-term facts (global per person)
592
+ ├── ConversationRepository threads, messages, history windows, usage log
593
+ ├── IdentityResolver "which person is this?" across every address
594
+ ├── PromptBuilder assembles chatHistory (system + persona + memory)
595
+ ├── TriggerDetector deterministic weather/menu/ping/doc matching
596
+ ├── MathDetector flags maths questions for terse answers
597
+ ├── MemoryExtractor parses and strips the @MEMORY tag
598
+ ├── FactMiner local first-person fact extraction
599
+ ├── ResponseFormatter enforces WhatsApp formatting; chunks long replies
600
+ ├── IdentityGuard keeps Alexa in character (no vendor names,
601
+ │ no "Alexa Mini", no self-denial)
602
+ ├── AmnesiaGuard never lets her deny a memory she actually has
603
+ ├── ImageDescriber vision chain: documents -> DeepAI -> OCR -> fallback
604
+ ├── WebAnswer searchWeb prompt; lifts "Sources:" lists out of prose
605
+ ├── Media normalises every media input shape
606
+ └── JidParser normalises @lid / @s.whatsapp.net / @g.us
607
+ ```
608
+
609
+ All classes are exported from the package entry point for advanced use and
610
+ testing (`const { StreamParser, Media, JidParser } = require('alexa-ai')`).
611
+
612
+ ### Design notes
613
+
614
+ **Command triggers are matched in code, not by the model.**
615
+ The bot's command router expects byte-exact outputs for four intents
616
+ (`weather <city>`, `menu`, `ping`, `doc`). Small models do not comply reliably
617
+ — `standard` will happily answer *"send me the docs"* with an essay.
618
+ `TriggerDetector` matches these intents deterministically and bypasses the
619
+ model entirely, so routing can never break. Disable with `triggers: false`.
620
+
621
+ **Memory is injected next to the question, not only at the top.**
622
+ Facts placed solely in the header of a long persona get diluted; in testing
623
+ the model insisted *"our conversation just started"* in 0/4 trials from the
624
+ header alone and recalled correctly in 4/4 when the same facts were repeated
625
+ as a compact note above the live message. Both placements are used.
626
+
627
+ **Facts are mined locally as a safety net.**
628
+ The model frequently ignores the `@MEMORY:` rule. `FactMiner` extracts
629
+ high-confidence facts from explicit first-person statements and defers to the
630
+ model's own tags on conflict. Disable with `factMining: false`.
631
+
632
+ **Identity is a graph, not a column.**
633
+ A person is not their jid. `wa_user_identities` maps every address to one
634
+ row, and rows that prove to be the same person are merged in a transaction.
635
+ This is what makes DM facts appear in groups.
636
+
637
+ **The engine has the last word, not the model.**
638
+ Two deterministic post-processors run on every reply: `IdentityGuard`
639
+ (vendor names, `Alexa Mini`-style renames, self-denials) and `AmnesiaGuard`
640
+ (*"I can't remember you"* while the database holds facts about you). Both
641
+ rewrite from data already in hand, add no latency and make no extra API call.
642
+
643
+ ---
644
+
645
+ ## Database schema
646
+
647
+ Eight tables, created automatically. Full DDL in
648
+ [`src/db/schema.sql`](src/db/schema.sql); the migration is idempotent.
649
+
650
+ | table | purpose |
651
+ | -------------------- | ------------------------------------------------------------------ |
652
+ | `wa_users` | one row per person; canonical `jid`, push name, counters, block flag |
653
+ | `wa_user_identities` | every address a person is seen under → one `user_id` (`@lid` ↔ phone) |
654
+ | `wa_groups` | one row per group; subject, per-group AI on/off switch |
655
+ | `wa_group_members` | which user was seen in which group (per-room stats, admin flag) |
656
+ | `wa_conversations` | one thread per DM / per (group, user); `context_key` unique |
657
+ | `wa_messages` | transcript; de-duplicated by `(conversation_id, wa_message_id)` |
658
+ | `wa_memories` | long-term facts, `UNIQUE (user_id, key)`, optional `expires_at` |
659
+ | `wa_ai_usage` | audit log: model, latency, ok/error |
660
+
661
+ Indexes cover the hot paths (newest-N messages per thread, memories per user,
662
+ recently active users). `updated_at` columns are maintained by triggers.
663
+
664
+ ---
665
+
666
+ ## DeepAI API reference
667
+
668
+ `DeepAIClient` implements every route used by the deepai.org web client. All
669
+ of them are reachable as `ai.deepai.*`, and the paths live in one overridable
670
+ map (`endpoints`), so a rename on DeepAI's side is a configuration change.
671
+
672
+ | area | route | client method |
673
+ | ---- | ----- | ------------- |
674
+ | chat | `POST /hacking_is_a_serious_crime` | `chat()` / `chatDetailed()` |
675
+ | reasoning tasks | `GET /check_chat_task_status?type=&task_id=` | `taskStatus()` / `waitForTask()` |
676
+ | moderation score | `GET /check-sensitivity?request_id=` | `checkSensitivity()` |
677
+ | attachments | `POST /chat_attachments/upload` | `uploadAttachment()` |
678
+ | attachments | `GET /chat_attachments/get?uuid=` | `getAttachment()` |
679
+ | sessions | `POST /save_chat_session` | `saveSession()` |
680
+ | sessions | `GET /get_chat_session?uuid=` | `getSession()` |
681
+ | sessions | `POST /rename_chat_session` | `renameSession()` |
682
+ | sessions | `POST /delete_chat_session` | `deleteSession()` |
683
+ | sessions | `POST /delete_all_chat_history` | `deleteAllSessions()` |
684
+ | account memory | `GET/POST /chat_memory` | `chatMemory()` |
685
+ | agent mode | `GET/POST /chat_sandbox` | `chatSandbox()` |
686
+ | background tasks | `GET/POST /chat_concierge` | `chatConcierge()` |
687
+ | abuse report | `POST /report_character` | `reportCharacter()` |
688
+ | image generation | `POST /api/text2img` | `text2img()` |
689
+ | image editing | `POST /api/image-editor` | `editImage()` |
690
+ | upscaling | `POST /api/torch-srgan` | `upscaleImage()` |
691
+ | colourising | `POST /api/colorizer` | `colorizeImage()` |
692
+ | moderation | `POST /api/nsfw-detector` | `detectNsfw()` |
693
+ | summarising | `POST /api/summarization` | `summarize()` |
694
+ | anything else | `POST /api/<name>` | `runApi(name, fields)` |
695
+
696
+ ### The chat request
697
+
698
+ ```http
699
+ POST https://api.deepai.org/hacking_is_a_serious_crime
700
+ api-key: tryit-...
701
+ Origin: https://deepai.org
702
+ Content-Type: multipart/form-data
703
+
704
+ chat_style = chat
705
+ chatHistory = [{"role":"user","content":"..."}]
706
+ model = standard
707
+ session_uuid = <uuid v4>
708
+ tool_activity_support = 1
709
+ thinking_image_tool_support= 1
710
+ enabled_tools = ["image_generator","image_editor"]
711
+ attachment_uuids = ["..."] (top level — never inside a message)
712
+ memory_enabled = true|false
713
+ web_access_enabled = true|false
714
+ sandbox_enabled = true|false (+ sandbox_turn_id)
715
+ concierge_enabled = true|false
716
+ thinking_support = 1 (-> {"task_id"} + polling)
717
+ hacker_is_stinky = very_stinky
718
+ ```
719
+
720
+ ### The chat response
721
+
722
+ The body is streamed UTF-8 text with out-of-band packets embedded in it.
723
+ `StreamParser` separates them so none of this reaches WhatsApp:
724
+
725
+ ```
726
+ \u001C{"tool_activity":"Searching the web"}\u001C tool status pings
727
+ …answer…\u001C[{"title":…,"url":…}] web-search sources
728
+ …answer…\u001C{"type":"generated_image","share_url":…} generated image
729
+ \u001DTHINKING_START12s\u001E<chain of thought>\u001DTHINKING_END
730
+ ```
731
+
732
+ ### Observed behaviour worth knowing
733
+
734
+ - The response is **streamed text**, not JSON and not SSE.
735
+ - Refusals arrive as a short JSON body — `{"status": "Only paid accounts can
736
+ use genius"}`, `{"status": "Out of API credits"}` — often with **HTTP 200**.
737
+ The client treats these as errors; quota refusals rotate to the next
738
+ configured key before the model chain is tried.
739
+ - **`role: "system"` is ignored** by the chat endpoint. The persona is
740
+ therefore delivered as a priming user/assistant pair, which the API does
741
+ honour; a short system digest is sent as well for backends that respect it.
742
+ - Anonymous `tryit-` keys are limited to `standard` and `gpt-4o-mini`.
743
+ Requesting `gpt-4.1`, `claude-*` or Genius returns a paid-account refusal.
744
+ - **Any request carrying an attachment is routed to a text-only model on free
745
+ keys**, whatever the `model` field says — we confirmed this by requesting
746
+ non-existent model names, matched browser headers, signed-in cookies and the
747
+ full browser field set; every variant resolved to
748
+ `llama-3.1-8b-instruct-turbo`, while the same key routes text-only requests
749
+ to `gpt-4o-mini` correctly. DeepAI states the reason in the reply itself:
750
+ *"neither native vision nor document text extraction added their contents to
751
+ this model request."* Document extraction (`.txt`, `.pdf`, …) does work on
752
+ free keys, which is why documents are provider #0 in the vision chain.
753
+ - `attachment_uuids` **must be a top-level form field**. Placing it inside a
754
+ message object forces the text-only downgrade even on paid keys.
755
+ - `/chat_attachments/upload` rejects the `api-key` header but succeeds
756
+ anonymously **with** an `Origin` header.
757
+ - Message `content` must be a **plain string**. OpenAI-style array content
758
+ (`[{type:'text'},{type:'image_url'}]`) is rejected with HTTP 500.
759
+
760
+ ---
761
+
762
+ ## Troubleshooting
763
+
764
+ **`TypeError: getEngine(...).generateImage is not a function`** (or
765
+ `.searchWeb`, `.upscaleImage`, …)
766
+ The copy of `alexa-ai` in your bot's `node_modules` is older than the wrapper
767
+ expects. Because the package is installed from GitHub, `npm install` does not
768
+ refresh it automatically. Reinstall and verify:
769
+
770
+ ```bash
771
+ npm uninstall alexa-ai
772
+ npm install github:AlexaInc/deepai
773
+ node -e "console.log(require('alexa-ai').version)" # must print 2.1.1 or newer
774
+ ```
775
+
776
+ `AlexaAI.version` and `AlexaAI.methods()` let the bot assert this at startup;
777
+ `examples/bot-ai.js` does so in `assertEngineVersion()`.
778
+
779
+ **`searchWeb()` answers are short, or `sources` is empty while links appear in
780
+ `text`**
781
+ Fixed in 2.1.1. Earlier builds asked the model for *"a short, direct answer"*
782
+ and only read sources from DeepAI's structured packet, which `gpt-4o-mini`
783
+ does not send. Reinstall as above and confirm the version is 2.1.1 or newer.
784
+ If a particular model still answers briefly, check `attempts` / `words` in the
785
+ result: the engine retries once below `minWords`, and a persistently short
786
+ model is best swapped with the `model` option.
787
+
788
+ **`generateImage()` returns `{ ok: false, error: 'DEEPAI_QUOTA_EXCEEDED' }`**
789
+ Both routes were refused: `/api/text2img` needs credits and the in-chat image
790
+ tool hit the key's chat quota. Add more keys (`keys: [...]`) or wait for the
791
+ quota to reset. `message` carries DeepAI's exact wording.
792
+
793
+ **Photos are answered with "I can't view images right now"**
794
+ Native vision needs a paid DeepAI key; on a free key only text inside the
795
+ image can be read (OCR). Set your own `OCR_API_KEY` if the shared demo key is
796
+ rate-limited. Documents (`.txt`, `.pdf`, `.docx`) are extracted server-side
797
+ and work on free keys.
798
+
799
+ **Alexa recognises someone in a DM but not in a group**
800
+ Only one of the person's addresses is being passed. Supply both on group
801
+ messages (`userId: key.participant`, `aliases: [key.participantAlt]`) or call
802
+ `linkIdentity()` once when you learn the mapping.
803
+
804
+ **`Only paid accounts can use …`**
805
+ The configured `model` is not available on the key. The engine falls through
806
+ `fallbackModels`; keep `'standard'` in that list.
807
+
808
+ **`Cannot connect to PostgreSQL`**
809
+ Managed providers usually require SSL. The engine enables relaxed SSL
810
+ automatically for non-local hosts; pass `ssl: { rejectUnauthorized: true }`
811
+ (or a CA bundle) to enforce verification, or `ssl: false` to disable it.
812
+
813
+ ---
814
+
815
+ ## Testing
816
+
817
+ ```bash
818
+ npm test # no network, no database
819
+ POSTGRES_URL=postgres://... npm test # + live PostgreSQL
820
+ POSTGRES_URL=... DEEPAI_KEY=tryit-... node test/run-tests.js # + live DeepAI
821
+ ```
822
+
823
+ - `test/run-tests.js` — 217 unit assertions covering jid parsing, alias
824
+ collection, memory extraction from malformed model output, trigger
825
+ matching, formatting enforcement, identity and amnesia repair, the DeepAI
826
+ stream format and the exact request shape of every endpoint (mocked
827
+ transport), plus an end-to-end run of the `chat()` pipeline on a fake
828
+ database and a fake DeepAI. With `POSTGRES_URL` it additionally proves the
829
+ identity model against a real database: a fact learned under a phone jid in
830
+ a DM is readable under the `@lid` in a group, two pre-existing rows merge
831
+ without losing a memory or a transcript, and unrelated users stay isolated.
832
+ - `test/wrapper-methods.js` — 175 assertions exercising every method exposed
833
+ to the bot (`generateImage`, `searchWeb`, `summarizeText`, the media
834
+ helpers, `describeImage`, `deepaiHealth`, and the memory, identity and
835
+ moderation API) against a mock that behaves like DeepAI's free tier —
836
+ including both ways DeepAI reports web-search sources — plus alias and
837
+ unseen-row cases on a real database when `POSTGRES_URL` is set.
838
+
839
+ 392 assertions run offline; 458 with a database.
840
+
841
+ ---
842
+
843
+ ## Examples
844
+
845
+ | file | what it shows |
846
+ | ---- | ------------- |
847
+ | [`examples/bot-ai.js`](examples/bot-ai.js) | complete drop-in AI module for the bot: callback signature, Baileys helper, admin and media commands, startup version check |
848
+ | [`examples/demo.js`](examples/demo.js) | interactive walkthrough — a user introduces themselves in a DM, is recognised in two groups, a second user stays isolated, triggers return exact output |
849
+
850
+ ```bash
851
+ POSTGRES_URL=... DEEPAI_KEY=tryit-... node examples/demo.js
852
+ ```
853
+
854
+ ---
855
+
856
+ ## Changelog
857
+
858
+ See [CHANGELOG.md](CHANGELOG.md).
859
+
860
+ ## License
861
+
862
+ [ISC](LICENSE) © Hansaka