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.
@@ -0,0 +1,335 @@
1
+ 'use strict';
2
+
3
+ const Media = require('../utils/Media');
4
+
5
+ /**
6
+ * ImageDescriber
7
+ * --------------
8
+ * Turns an attached photo or document into text the model can reason about,
9
+ * and — when the account really does have vision — hands the attachment
10
+ * straight to the chat call so the model sees the picture itself.
11
+ *
12
+ * PROVIDER CHAIN (first success wins)
13
+ * -----------------------------------
14
+ * 0. Documents — upload + server-side extraction. Works on FREE
15
+ * keys: a .txt/.pdf comes back `complete` and its
16
+ * text IS injected into the model context.
17
+ * 1. DeepAI vision — upload once, then try every model in
18
+ * `config.visionModels` (gpt-4o-mini, gpt-4.1-mini,
19
+ * gpt-4o, standard …). Anonymous "tryit" keys are
20
+ * downgraded server-side and answer "does not
21
+ * support image attachments", so a refusal puts the
22
+ * provider on a cooldown instead of a permanent
23
+ * latch (a plan upgrade then just starts working).
24
+ * 2. OCR (ocr.space) — reads screenshots, bills, error messages, notes.
25
+ * This covers most images people send a WhatsApp bot.
26
+ * 3. Honest fallback — ask the user to describe it rather than inventing
27
+ * a description (the model will happily hallucinate).
28
+ *
29
+ * `describe()` also returns `attachmentUuids`, so AlexaAI can forward the file
30
+ * with the real conversation instead of a one-off side request.
31
+ */
32
+ class ImageDescriber {
33
+ /**
34
+ * @param {import('../core/DeepAIClient')} client
35
+ * @param {import('../core/Config')} config
36
+ */
37
+ constructor(client, config) {
38
+ this.client = client;
39
+ this.config = config;
40
+ this.log = config.logger;
41
+
42
+ // Cooldown instead of a hard latch: vision may become available later.
43
+ this._visionCooldownUntil = 0;
44
+ this._visionCooldownMs = 30 * 60 * 1000;
45
+ this._modelsRefused = new Set();
46
+ this._ocrOff = !config.ocrEnabled;
47
+ }
48
+
49
+ /** True while DeepAI vision is known to be unavailable. */
50
+ get visionUnavailable() {
51
+ return Date.now() < this._visionCooldownUntil;
52
+ }
53
+
54
+ /**
55
+ * @param {object} image { buffer, url, base64, mimetype, filename }
56
+ * @param {string} [caption]
57
+ * @returns {Promise<{
58
+ * ok:boolean, description:string|null, source:string|null,
59
+ * reason:string|null, attachmentUuids:string[]
60
+ * }>}
61
+ */
62
+ async describe(input, caption = '') {
63
+ // Accept a bare Buffer / base64 / data URI / URL as well as the
64
+ // { buffer | url } object shape — see utils/Media.
65
+ const image = Media.normalize(input);
66
+ if (!image) return ImageDescriber._fail('no_image');
67
+
68
+ // Make sure we have bytes; OCR needs them and so does the upload.
69
+ const buffer = await this._resolveBuffer(image);
70
+ if (!buffer) return ImageDescriber._fail('unreadable');
71
+
72
+ // A URL input has no mimetype until we have the bytes.
73
+ if (!image.mimetype || image.mimetype === 'application/octet-stream') {
74
+ image.mimetype = Media.sniff(buffer) || 'image/jpeg';
75
+ }
76
+
77
+ const isDocument = ImageDescriber._isDocument(image);
78
+
79
+ // Upload once and reuse the uuid for every provider attempt.
80
+ let uuid = null;
81
+ let extraction = null;
82
+ try {
83
+ const attachment = await this.client.uploadAttachment(
84
+ buffer,
85
+ image.filename || (isDocument ? 'document.txt' : 'image.jpg'),
86
+ image.mimetype || (isDocument ? 'text/plain' : 'image/jpeg')
87
+ );
88
+ uuid = attachment?.uuid ? String(attachment.uuid) : null;
89
+ if (uuid) {
90
+ const settled = (await this.client.getAttachment(uuid)) || attachment;
91
+ extraction = settled?.extraction_status || null;
92
+ }
93
+ } catch (err) {
94
+ if (this.config.debug) this.log.warn?.(`[AlexaAI] Attachment upload failed: ${err.message}`);
95
+ }
96
+
97
+ const uuids = uuid ? [uuid] : [];
98
+
99
+ // ---- 0. Documents: extraction genuinely works on free keys --------
100
+ if (uuid && isDocument && extraction === 'complete') {
101
+ const viaDoc = await this._ask(uuid, caption, {
102
+ document: true,
103
+ models: [this.config.model, ...this.config.visionModels],
104
+ });
105
+ if (viaDoc.ok) return { ...viaDoc, source: 'document', attachmentUuids: uuids };
106
+ }
107
+
108
+ // ---- 1. DeepAI native vision --------------------------------------
109
+ if (uuid && !isDocument && !this.visionUnavailable && extraction !== 'failed') {
110
+ const viaDeepAI = await this._ask(uuid, caption, { models: this.config.visionModels });
111
+ if (viaDeepAI.ok) return { ...viaDeepAI, source: 'deepai', attachmentUuids: uuids };
112
+ if (viaDeepAI.reason === 'plan') this._coolDownVision(extraction);
113
+ }
114
+
115
+ // ---- 2. OCR ---------------------------------------------------------
116
+ if (!this._ocrOff) {
117
+ const viaOcr = await this._tryOcr(buffer, image);
118
+ if (viaOcr.ok) return { ...viaOcr, attachmentUuids: uuids };
119
+ }
120
+
121
+ return { ...ImageDescriber._fail('vision_unavailable'), attachmentUuids: uuids };
122
+ }
123
+
124
+ // ------------------------------------------------------------ providers --
125
+
126
+ /**
127
+ * @private Ask the model about an uploaded attachment, walking the model
128
+ * chain until one of them actually looks at it.
129
+ *
130
+ * IMPORTANT: `attachment_uuids` must be a TOP-LEVEL form field. Putting it
131
+ * inside the message object makes DeepAI downgrade the request to
132
+ * `llama-3.1-8b-instruct-turbo`, which then answers "does not support
133
+ * image attachments".
134
+ */
135
+ async _ask(uuid, caption, { models = [], document = false } = {}) {
136
+ const prompt = document
137
+ ? caption
138
+ ? `Using the attached document, answer: ${caption}`
139
+ : 'Summarise the attached document clearly and concisely.'
140
+ : caption
141
+ ? `Look at the attached image and answer: ${caption}`
142
+ : 'Describe the attached image in 2-3 sentences: the main subject, the setting, and any visible text.';
143
+
144
+ let sawRefusal = false;
145
+ for (const model of models.filter(Boolean)) {
146
+ if (this._modelsRefused.has(model)) continue;
147
+ try {
148
+ const reply = await this.client.chat([{ role: 'user', content: prompt }], {
149
+ models: [model], // no silent fallback: we walk the chain ourselves
150
+ attachmentUuids: [uuid],
151
+ });
152
+
153
+ if (ImageDescriber._isRefusal(reply)) {
154
+ sawRefusal = true;
155
+ this._modelsRefused.add(model);
156
+ if (this.config.debug) {
157
+ this.log.warn?.(`[AlexaAI] ${model} cannot see attachments — trying the next model`);
158
+ }
159
+ continue;
160
+ }
161
+ const text = String(reply || '').trim();
162
+ if (text) return { ok: true, description: text, source: null, reason: null };
163
+ } catch (err) {
164
+ const msg = String(err?.message || '').toLowerCase();
165
+ if (
166
+ msg.includes('does not support image') ||
167
+ msg.includes('only paid accounts') ||
168
+ msg.includes('vision-capable') ||
169
+ msg.includes('quota') ||
170
+ msg.includes('paid users')
171
+ ) {
172
+ sawRefusal = true;
173
+ this._modelsRefused.add(model);
174
+ continue;
175
+ }
176
+ if (this.config.debug) this.log.warn?.(`[AlexaAI] Vision via ${model} failed: ${err.message}`);
177
+ }
178
+ }
179
+ return ImageDescriber._fail(sawRefusal ? 'plan' : 'error');
180
+ }
181
+
182
+ /** @private Park DeepAI vision for a while after a plan refusal. */
183
+ _coolDownVision(extraction) {
184
+ this._visionCooldownUntil = Date.now() + this._visionCooldownMs;
185
+ this._modelsRefused.clear();
186
+ if (this.config.debug) {
187
+ this.log.warn?.(
188
+ `[AlexaAI] DeepAI did not process the image (extraction_status=${extraction}). ` +
189
+ 'Native vision needs a paid DeepAI plan — using OCR for the next 30 minutes.'
190
+ );
191
+ }
192
+ }
193
+
194
+ /** @private Is this a text-bearing document rather than an image? */
195
+ static _isDocument(image) {
196
+ return Media.isDocument(image);
197
+ }
198
+
199
+ /** @private OCR text extraction. */
200
+ async _tryOcr(buffer, image) {
201
+ const controller = new AbortController();
202
+ const timer = setTimeout(() => controller.abort(), this.config.ocrTimeout);
203
+ try {
204
+ const form = new FormData();
205
+ form.append(
206
+ 'base64Image',
207
+ `data:${image.mimetype || 'image/jpeg'};base64,${buffer.toString('base64')}`
208
+ );
209
+ form.append('language', this.config.ocrLanguage);
210
+ form.append('OCREngine', '2');
211
+ form.append('scale', 'true');
212
+ form.append('isTable', 'false');
213
+
214
+ const response = await fetch(this.config.ocrUrl, {
215
+ method: 'POST',
216
+ body: form,
217
+ headers: { apikey: this.config.ocrApiKey },
218
+ signal: controller.signal,
219
+ });
220
+
221
+ const data = await response.json();
222
+ if (data.IsErroredOnProcessing) {
223
+ if (this.config.debug) {
224
+ this.log.warn?.(`[AlexaAI] OCR error: ${JSON.stringify(data.ErrorMessage)}`);
225
+ }
226
+ return ImageDescriber._fail('ocr_error');
227
+ }
228
+
229
+ const text = String(data?.ParsedResults?.[0]?.ParsedText || '')
230
+ .replace(/\r/g, '')
231
+ .replace(/\n{3,}/g, '\n\n')
232
+ .trim();
233
+
234
+ // Empty = a photo with no text. Not a failure, just nothing to read.
235
+ if (text.length < 2) return ImageDescriber._fail('no_text');
236
+
237
+ const clipped = text.length > 2500 ? `${text.slice(0, 2500)}…` : text;
238
+ return {
239
+ ok: true,
240
+ source: 'ocr',
241
+ reason: null,
242
+ description: `The image contains the following text (extracted by OCR):\n"""\n${clipped}\n"""`,
243
+ };
244
+ } catch (err) {
245
+ if (this.config.debug) this.log.warn?.(`[AlexaAI] OCR failed: ${err.message}`);
246
+ return ImageDescriber._fail('ocr_error');
247
+ } finally {
248
+ clearTimeout(timer);
249
+ }
250
+ }
251
+
252
+ // -------------------------------------------------------------- helpers --
253
+
254
+ /** @private Ensure we have raw bytes: buffer, base64/data-URI, or URL. */
255
+ async _resolveBuffer(image) {
256
+ if (Buffer.isBuffer(image.buffer)) return ImageDescriber._cap(image.buffer, this.config.maxImageBytes);
257
+ if (image.buffer instanceof Uint8Array) {
258
+ return ImageDescriber._cap(Buffer.from(image.buffer), this.config.maxImageBytes);
259
+ }
260
+
261
+ const inline = image.base64 || image.data;
262
+ if (typeof inline === 'string' && inline) {
263
+ const payload = inline.startsWith('data:') ? inline.slice(inline.indexOf(',') + 1) : inline;
264
+ try {
265
+ return ImageDescriber._cap(Buffer.from(payload, 'base64'), this.config.maxImageBytes);
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
271
+ if (!image.url) return null;
272
+ const controller = new AbortController();
273
+ const timer = setTimeout(() => controller.abort(), this.config.ocrTimeout);
274
+ try {
275
+ const response = await fetch(image.url, { signal: controller.signal });
276
+ if (!response.ok) return null;
277
+ const arrayBuffer = await response.arrayBuffer();
278
+ return ImageDescriber._cap(Buffer.from(arrayBuffer), this.config.maxImageBytes);
279
+ } catch {
280
+ return null;
281
+ } finally {
282
+ clearTimeout(timer);
283
+ }
284
+ }
285
+
286
+ static _cap(buffer, max) {
287
+ if (!buffer || !buffer.length) return null;
288
+ return buffer.length > max ? null : buffer;
289
+ }
290
+
291
+ static _fail(reason) {
292
+ return { ok: false, description: null, source: null, reason, attachmentUuids: [] };
293
+ }
294
+
295
+ /** Public alias of the failure shape (used by `AlexaAI.describeImage`). */
296
+ static fallbackResult(reason) {
297
+ return ImageDescriber._fail(reason);
298
+ }
299
+
300
+ /** @private Model said it cannot see the image. */
301
+ static _isRefusal(reply) {
302
+ const lowered = String(reply || '').toLowerCase();
303
+ return (
304
+ lowered.includes("can't see") ||
305
+ lowered.includes('cannot see') ||
306
+ lowered.includes("can't view") ||
307
+ lowered.includes('cannot view') ||
308
+ lowered.includes('unable to view') ||
309
+ lowered.includes('unable to see') ||
310
+ lowered.includes('not able to see') ||
311
+ lowered.includes('not able to view') ||
312
+ lowered.includes("can't read or repeat") ||
313
+ lowered.includes('does not support image') ||
314
+ lowered.includes('no image') ||
315
+ lowered.includes("didn't receive an image") ||
316
+ lowered.includes('i do not have the ability to view')
317
+ );
318
+ }
319
+
320
+ /** Friendly WhatsApp-formatted fallback when nothing could be read. */
321
+ static fallbackMessage(caption) {
322
+ if (caption && caption.trim()) {
323
+ return (
324
+ "I can see you've sent me an image, but I'm not able to view pictures right now. 🙏\n\n" +
325
+ 'Could you tell me what it shows? Then I can help you straight away!'
326
+ );
327
+ }
328
+ return (
329
+ "Thanks for the picture! 📸 I can't view images at the moment, " +
330
+ 'but if you tell me what it shows I would love to help.'
331
+ );
332
+ }
333
+ }
334
+
335
+ module.exports = ImageDescriber;
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * MathDetector
5
+ * ------------
6
+ * Recognises maths questions so PromptBuilder can attach a one-line "MATH MODE"
7
+ * instruction directly above the user's message.
8
+ *
9
+ * WHY: the persona says "provide ONLY the direct final formula and result, no
10
+ * step-by-step". Live testing showed the free-tier model ignores that when the
11
+ * rule sits far up in a long persona — it returned a full nine-line derivation
12
+ * for "area of a circle with radius 7". Repeating the constraint next to the
13
+ * question fixed it (verified: `A = π * 7² ≈ 153.938`).
14
+ */
15
+ class MathDetector {
16
+ /** Explicit calculation verbs. */
17
+ static VERBS = /\b(calculate|compute|evaluate|solve|simplify|factor|derive|integrate|differentiate|convert)\b/i;
18
+
19
+ /** Arithmetic expressions: 12 * 47, 2+2, 15/3, 2^8. */
20
+ static EXPRESSION = /\d\s*[+\-*/^×÷]\s*\d/;
21
+
22
+ /** Percentage / fraction phrasing. */
23
+ static PERCENT = /\b\d+(?:\.\d+)?\s*%|\bpercent(?:age)?\s+of\b|\b\d+\s*%\s*of\b/i;
24
+
25
+ /** Common maths nouns paired with numbers. */
26
+ static TOPIC = /\b(area|perimeter|circumference|volume|radius|diameter|hypotenuse|square root|sqrt|cube root|factorial|average|mean|median|logarithm|log|sine|cosine|tangent|equation|derivative|integral)\b/i;
27
+
28
+ /** "what is X" followed by something numeric. */
29
+ static WHAT_IS_NUMBER = /\b(?:what(?:'?s| is)|how much is)\b[^?]*\d/i;
30
+
31
+ /** Phrases that look mathematical but want prose, not a bare number. */
32
+ static PROSE = /\b(explain|why|history|who invented|prove|proof|meaning|difference between|tell me about|what does .* mean|help me understand|teach|learn)\b/i;
33
+
34
+ /**
35
+ * @param {string} message
36
+ * @returns {boolean}
37
+ */
38
+ static isMath(message) {
39
+ const text = String(message ?? '').trim();
40
+ if (!text || text.length > 400) return false;
41
+
42
+ // A request for explanation overrides math mode.
43
+ if (MathDetector.PROSE.test(text)) return false;
44
+
45
+ // Must contain at least one digit to be a calculation.
46
+ if (!/\d/.test(text)) return false;
47
+
48
+ if (MathDetector.EXPRESSION.test(text)) return true;
49
+ if (MathDetector.PERCENT.test(text)) return true;
50
+ if (MathDetector.VERBS.test(text)) return true;
51
+ if (MathDetector.TOPIC.test(text)) return true;
52
+ if (MathDetector.WHAT_IS_NUMBER.test(text)) return true;
53
+
54
+ return false;
55
+ }
56
+
57
+ /** The instruction appended above a maths question. */
58
+ static HINT =
59
+ '[MATH MODE: Reply with ONLY the final formula and result on ONE line wrapped in single backticks. ' +
60
+ 'No explanation, no steps, no restating the question, no extra sentences. ' +
61
+ 'Example: `A = π * 7² ≈ 153.938`]\n\n';
62
+ }
63
+
64
+ module.exports = MathDetector;
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+
3
+ const MemoryRepository = require('../repositories/MemoryRepository');
4
+
5
+ /**
6
+ * MemoryExtractor
7
+ * ---------------
8
+ * Parses the `@MEMORY: {...}` tag the persona appends, strips it from the
9
+ * user-visible text, and returns the facts to persist.
10
+ *
11
+ * Real models are messy, so the parser tolerates:
12
+ * @MEMORY: {"name": "Nimal"}
13
+ * @MEMORY:{"name":"Nimal","hobby":"cricket"}
14
+ * @memory: {'name': 'Nimal'} (single quotes)
15
+ * @MEMORY: {"name": "Nimal"} @MEMORY: {"city":"Galle"} (multiple tags)
16
+ * *@MEMORY:* {"name": "Nimal"} (WhatsApp bolded tag)
17
+ * ```@MEMORY: {"name":"Nimal"}``` (fenced)
18
+ * @MEMORY: name: Nimal, hobby: cricket (non-JSON fallback)
19
+ */
20
+ class MemoryExtractor {
21
+ // Tag, then a balanced-ish {...} block. Non-greedy, no nested braces expected.
22
+ // `[*_~\s]*` absorbs WhatsApp emphasis the model may wrap the tag in,
23
+ // e.g. "*@MEMORY:*" or "_@MEMORY:_".
24
+ static TAG_JSON = /[*_~]*@\s*MEMORY\s*:?[*_~]*\s*(\{[^{}]*\})/gi;
25
+ // Fallback: "@MEMORY: key: value, key2: value2" until end of line.
26
+ static TAG_LOOSE = /[*_~]*@\s*MEMORY\s*:?[*_~]*\s*([^\n{}]+)/gi;
27
+
28
+ /**
29
+ * @param {string} reply raw model output
30
+ * @returns {{ text: string, memories: Record<string,string>, found: boolean }}
31
+ */
32
+ static extract(reply) {
33
+ const original = String(reply ?? '');
34
+ if (!original) return { text: '', memories: {}, found: false };
35
+
36
+ const memories = {};
37
+ let found = false;
38
+ let text = original;
39
+
40
+ // --- Pass 1: JSON payloads -----------------------------------------
41
+ text = text.replace(MemoryExtractor.TAG_JSON, (_match, jsonBlock) => {
42
+ const parsed = MemoryExtractor._parseObject(jsonBlock);
43
+ if (parsed) {
44
+ Object.assign(memories, parsed);
45
+ found = true;
46
+ return '';
47
+ }
48
+ return '';
49
+ });
50
+
51
+ // --- Pass 2: loose "key: value" payloads ---------------------------
52
+ if (/@\s*MEMORY/i.test(text)) {
53
+ text = text.replace(MemoryExtractor.TAG_LOOSE, (_match, body) => {
54
+ const parsed = MemoryExtractor._parseLoose(body);
55
+ if (parsed && Object.keys(parsed).length) {
56
+ Object.assign(memories, parsed);
57
+ found = true;
58
+ }
59
+ return '';
60
+ });
61
+ }
62
+
63
+ return {
64
+ text: MemoryExtractor._tidy(text),
65
+ memories: MemoryExtractor._sanitise(memories),
66
+ found,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Remove any stray memory-tag remnants without extracting.
72
+ * Used as a final safety net before sending to WhatsApp.
73
+ */
74
+ static strip(reply) {
75
+ return MemoryExtractor.extract(reply).text;
76
+ }
77
+
78
+ /** @private JSON first, then a lenient single-quote repair. */
79
+ static _parseObject(block) {
80
+ try {
81
+ const parsed = JSON.parse(block);
82
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
83
+ } catch {
84
+ /* fall through to repair */
85
+ }
86
+
87
+ try {
88
+ const repaired = block
89
+ .replace(/'/g, '"')
90
+ // quote bare keys: {name: "x"} -> {"name": "x"}
91
+ .replace(/([{,]\s*)([A-Za-z_][A-Za-z0-9_ -]*)\s*:/g, '$1"$2":')
92
+ // drop trailing commas
93
+ .replace(/,\s*}/g, '}');
94
+ const parsed = JSON.parse(repaired);
95
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
96
+ } catch {
97
+ /* give up on this block */
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /** @private "name: Nimal, hobby: cricket" -> object */
103
+ static _parseLoose(body) {
104
+ const out = {};
105
+ const segments = String(body).split(/[,;]+/);
106
+ for (const segment of segments) {
107
+ const match = segment.match(/^\s*["']?([A-Za-z_][A-Za-z0-9_ -]{0,40})["']?\s*[:=]\s*(.+?)\s*$/);
108
+ if (!match) continue;
109
+ const key = match[1];
110
+ const value = match[2].replace(/^["']|["']$/g, '');
111
+ if (key && value) out[key] = value;
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /** @private Normalise keys/values through the repository's rules. */
117
+ static _sanitise(raw) {
118
+ const clean = {};
119
+ for (const [k, v] of Object.entries(raw)) {
120
+ const key = MemoryRepository.normalizeKey(k);
121
+ const value = MemoryRepository.normalizeValue(v);
122
+ if (key && value) clean[key] = value;
123
+ }
124
+ return clean;
125
+ }
126
+
127
+ /**
128
+ * @private Tidy the leftover prose: collapse the hole the tag left behind,
129
+ * drop empty code fences, and trim trailing separators.
130
+ */
131
+ static _tidy(text) {
132
+ return String(text)
133
+ .replace(/```\s*```/g, '')
134
+ .replace(/[ \t]{2,}/g, ' ')
135
+ .replace(/[ \t]+\n/g, '\n')
136
+ .replace(/\n{3,}/g, '\n\n')
137
+ .replace(/[\s\-–—•,;:]+$/g, '')
138
+ .trim();
139
+ }
140
+ }
141
+
142
+ module.exports = MemoryExtractor;