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,215 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * MemoryRepository
5
+ * ----------------
6
+ * Long-term facts about a person.
7
+ *
8
+ * Design rule: memories are keyed to `user_id` ONLY — never to a group. That
9
+ * is what makes Alexa recognise the same person's details in a DM and in any
10
+ * group. `UNIQUE (user_id, key)` means re-learning a key
11
+ * (e.g. the user moves city) overwrites instead of piling up duplicates.
12
+ */
13
+ class MemoryRepository {
14
+ /** @param {import('../db/Database')} db */
15
+ constructor(db) {
16
+ this.db = db;
17
+ }
18
+
19
+ /** Reserved keys that must never be stored as "facts". */
20
+ static BLOCKED_KEYS = new Set(['', 'null', 'undefined', 'none', 'n/a', 'memory', 'key', 'value']);
21
+
22
+ static MAX_KEY = 64;
23
+ static MAX_VALUE = 512;
24
+
25
+ /**
26
+ * Insert or update one fact.
27
+ * @param {number} userId
28
+ * @param {string} key
29
+ * @param {string} value
30
+ * @param {object} [opts]
31
+ * @param {string} [opts.source='auto']
32
+ * @param {string} [opts.learnedIn]
33
+ * @param {number} [opts.confidence=1]
34
+ * @param {Date|null} [opts.expiresAt]
35
+ * @returns {Promise<object|null>}
36
+ */
37
+ async remember(userId, key, value, opts = {}) {
38
+ const normalisedKey = MemoryRepository.normalizeKey(key);
39
+ const normalisedValue = MemoryRepository.normalizeValue(value);
40
+ if (!userId || !normalisedKey || !normalisedValue) return null;
41
+
42
+ return this.db.one(
43
+ `INSERT INTO wa_memories (user_id, key, value, source, learned_in, confidence, expires_at)
44
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
45
+ ON CONFLICT (user_id, key) DO UPDATE
46
+ SET value = EXCLUDED.value,
47
+ source = EXCLUDED.source,
48
+ learned_in = COALESCE(EXCLUDED.learned_in, wa_memories.learned_in),
49
+ confidence = EXCLUDED.confidence,
50
+ expires_at = EXCLUDED.expires_at,
51
+ updated_at = NOW()
52
+ RETURNING *`,
53
+ [
54
+ userId,
55
+ normalisedKey,
56
+ normalisedValue,
57
+ opts.source || 'auto',
58
+ opts.learnedIn || null,
59
+ typeof opts.confidence === 'number' ? opts.confidence : 1.0,
60
+ opts.expiresAt || null,
61
+ ]
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Store many facts at once (one round-trip, single transaction).
67
+ * @param {number} userId
68
+ * @param {Record<string,any>} facts
69
+ * @param {object} [opts]
70
+ * @returns {Promise<object[]>}
71
+ */
72
+ async rememberMany(userId, facts, opts = {}) {
73
+ if (!userId || !facts || typeof facts !== 'object') return [];
74
+
75
+ const entries = Object.entries(facts)
76
+ .map(([k, v]) => [MemoryRepository.normalizeKey(k), MemoryRepository.normalizeValue(v)])
77
+ .filter(([k, v]) => k && v);
78
+
79
+ if (!entries.length) return [];
80
+
81
+ // Cap per-turn writes so a malformed model reply can't flood the table.
82
+ const limited = entries.slice(0, 12);
83
+
84
+ return this.db.transaction(async (client) => {
85
+ const saved = [];
86
+ for (const [key, value] of limited) {
87
+ const { rows } = await client.query(
88
+ `INSERT INTO wa_memories (user_id, key, value, source, learned_in, confidence)
89
+ VALUES ($1, $2, $3, $4, $5, $6)
90
+ ON CONFLICT (user_id, key) DO UPDATE
91
+ SET value = EXCLUDED.value,
92
+ source = EXCLUDED.source,
93
+ learned_in = COALESCE(EXCLUDED.learned_in, wa_memories.learned_in),
94
+ updated_at = NOW()
95
+ RETURNING *`,
96
+ [
97
+ userId,
98
+ key,
99
+ value,
100
+ opts.source || 'auto',
101
+ opts.learnedIn || null,
102
+ typeof opts.confidence === 'number' ? opts.confidence : 1.0,
103
+ ]
104
+ );
105
+ saved.push(rows[0]);
106
+ }
107
+ return saved;
108
+ });
109
+ }
110
+
111
+ /**
112
+ * All live memories for a user (expired rows filtered out).
113
+ * @param {number} userId
114
+ * @param {number} [limit=25]
115
+ */
116
+ async getAll(userId, limit = 25) {
117
+ if (!userId) return [];
118
+ return this.db.many(
119
+ `SELECT * FROM wa_memories
120
+ WHERE user_id = $1
121
+ AND (expires_at IS NULL OR expires_at > NOW())
122
+ ORDER BY updated_at DESC
123
+ LIMIT $2`,
124
+ [userId, limit]
125
+ );
126
+ }
127
+
128
+ /** Plain `{key: value}` map for prompt injection. */
129
+ async getMap(userId, limit = 25) {
130
+ const rows = await this.getAll(userId, limit);
131
+ const map = {};
132
+ for (const row of rows) map[row.key] = row.value;
133
+ return map;
134
+ }
135
+
136
+ async get(userId, key) {
137
+ const normalisedKey = MemoryRepository.normalizeKey(key);
138
+ if (!userId || !normalisedKey) return null;
139
+ return this.db.one(
140
+ `SELECT * FROM wa_memories
141
+ WHERE user_id = $1 AND key = $2
142
+ AND (expires_at IS NULL OR expires_at > NOW())`,
143
+ [userId, normalisedKey]
144
+ );
145
+ }
146
+
147
+ async forget(userId, key) {
148
+ const normalisedKey = MemoryRepository.normalizeKey(key);
149
+ if (!userId || !normalisedKey) return false;
150
+ const { rowCount } = await this.db.query(
151
+ 'DELETE FROM wa_memories WHERE user_id = $1 AND key = $2',
152
+ [userId, normalisedKey]
153
+ );
154
+ return rowCount > 0;
155
+ }
156
+
157
+ async forgetAll(userId) {
158
+ if (!userId) return 0;
159
+ const { rowCount } = await this.db.query('DELETE FROM wa_memories WHERE user_id = $1', [userId]);
160
+ return rowCount;
161
+ }
162
+
163
+ /** Bump usage counters for memories that were injected into a prompt. */
164
+ async markUsed(userId, keys) {
165
+ if (!userId || !Array.isArray(keys) || !keys.length) return;
166
+ await this.db.query(
167
+ 'UPDATE wa_memories SET hit_count = hit_count + 1 WHERE user_id = $1 AND key = ANY($2::text[])',
168
+ [userId, keys]
169
+ );
170
+ }
171
+
172
+ /** Housekeeping: drop expired rows. */
173
+ async pruneExpired() {
174
+ const { rowCount } = await this.db.query(
175
+ 'DELETE FROM wa_memories WHERE expires_at IS NOT NULL AND expires_at <= NOW()'
176
+ );
177
+ return rowCount;
178
+ }
179
+
180
+ // ------------------------------------------------------------ helpers ---
181
+
182
+ /** `Favourite Food ` -> `favourite_food` */
183
+ static normalizeKey(key) {
184
+ if (key == null) return null;
185
+ const cleaned = String(key)
186
+ .trim()
187
+ .toLowerCase()
188
+ .replace(/[\s-]+/g, '_')
189
+ .replace(/[^a-z0-9_]/g, '')
190
+ .replace(/_{2,}/g, '_')
191
+ .replace(/^_|_$/g, '');
192
+ if (!cleaned || MemoryRepository.BLOCKED_KEYS.has(cleaned)) return null;
193
+ return cleaned.slice(0, MemoryRepository.MAX_KEY);
194
+ }
195
+
196
+ /** Objects/arrays are JSON-stringified so nested model output still stores. */
197
+ static normalizeValue(value) {
198
+ if (value == null) return null;
199
+ let str;
200
+ if (typeof value === 'object') {
201
+ try {
202
+ str = JSON.stringify(value);
203
+ } catch {
204
+ return null;
205
+ }
206
+ } else {
207
+ str = String(value);
208
+ }
209
+ str = str.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').trim();
210
+ if (!str || MemoryRepository.BLOCKED_KEYS.has(str.toLowerCase())) return null;
211
+ return str.slice(0, MemoryRepository.MAX_VALUE);
212
+ }
213
+ }
214
+
215
+ module.exports = MemoryRepository;
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+
3
+ const JidParser = require('../utils/JidParser');
4
+ const { ValidationError } = require('../core/errors');
5
+
6
+ /**
7
+ * UserRepository
8
+ * --------------
9
+ * Users and groups. One `wa_users` row per person, keyed by canonical jid, so
10
+ * the same human is recognised in a DM and in every group.
11
+ */
12
+ class UserRepository {
13
+ /** @param {import('../db/Database')} db */
14
+ constructor(db) {
15
+ this.db = db;
16
+ }
17
+
18
+ /**
19
+ * Find-or-create a user, refreshing `last_seen_at` and push name.
20
+ * @param {string} rawJid
21
+ * @param {object} [info]
22
+ * @param {string} [info.pushName]
23
+ * @param {object} [info.metadata]
24
+ * @returns {Promise<object>} user row
25
+ */
26
+ async upsertUser(rawJid, info = {}) {
27
+ const parsed = JidParser.parse(rawJid);
28
+ if (!parsed.valid || parsed.isGroup) {
29
+ throw new ValidationError(`Invalid user jid: ${JSON.stringify(rawJid)}`);
30
+ }
31
+
32
+ const pushName = UserRepository._clean(info.pushName, 128);
33
+ const metadata = info.metadata && typeof info.metadata === 'object' ? info.metadata : {};
34
+
35
+ // If this jid is already a known ALIAS of somebody, that person is who
36
+ // is writing — creating a second row here is exactly the bug that made
37
+ // Alexa forget people between a DM and a group.
38
+ const known = await this.db.one(
39
+ `SELECT u.* FROM wa_users u
40
+ JOIN wa_user_identities i ON i.user_id = u.id
41
+ WHERE i.jid = $1
42
+ LIMIT 1`,
43
+ [parsed.jid]
44
+ );
45
+ if (known) {
46
+ await this.db.query('UPDATE wa_user_identities SET last_seen_at = NOW() WHERE jid = $1', [parsed.jid]);
47
+ return (await this.touch(known.id, { pushName, metadata })) || known;
48
+ }
49
+
50
+ // COALESCE keeps a previously-known push name if this event lacks one.
51
+ const user = await this.db.one(
52
+ `INSERT INTO wa_users (jid, jid_local, jid_server, jid_type, phone, push_name, metadata, last_seen_at)
53
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, NOW())
54
+ ON CONFLICT (jid) DO UPDATE
55
+ SET last_seen_at = NOW(),
56
+ push_name = COALESCE(NULLIF(EXCLUDED.push_name, ''), wa_users.push_name),
57
+ phone = COALESCE(wa_users.phone, EXCLUDED.phone),
58
+ metadata = wa_users.metadata || EXCLUDED.metadata
59
+ RETURNING *`,
60
+ [
61
+ parsed.jid,
62
+ parsed.local,
63
+ parsed.server,
64
+ parsed.type,
65
+ parsed.phone,
66
+ pushName,
67
+ JSON.stringify(metadata),
68
+ ]
69
+ );
70
+
71
+ // Every user is their own primary identity. DO NOTHING on conflict:
72
+ // re-pointing a jid at another person is IdentityRepository's job, and
73
+ // it merges instead of silently stealing the alias.
74
+ await this.db.query(
75
+ `INSERT INTO wa_user_identities (user_id, jid, jid_local, jid_server, jid_type, phone, is_primary, source)
76
+ VALUES ($1, $2, $3, $4, $5, $6, TRUE, 'primary')
77
+ ON CONFLICT (jid) DO UPDATE SET last_seen_at = NOW()`,
78
+ [user.id, parsed.jid, parsed.local, parsed.server, parsed.type, parsed.phone]
79
+ );
80
+
81
+ return user;
82
+ }
83
+
84
+ /** Refresh `last_seen_at` / push name on a known row. */
85
+ async touch(userId, info = {}) {
86
+ if (!userId) return null;
87
+ const pushName = UserRepository._clean(info.pushName, 128);
88
+ const metadata = info.metadata && typeof info.metadata === 'object' ? info.metadata : {};
89
+ return this.db.one(
90
+ `UPDATE wa_users
91
+ SET last_seen_at = NOW(),
92
+ push_name = COALESCE(NULLIF($2, ''), push_name),
93
+ metadata = metadata || $3::jsonb
94
+ WHERE id = $1
95
+ RETURNING *`,
96
+ [userId, pushName, JSON.stringify(metadata)]
97
+ );
98
+ }
99
+
100
+ async findById(userId) {
101
+ if (!userId) return null;
102
+ return this.db.one('SELECT * FROM wa_users WHERE id = $1', [userId]);
103
+ }
104
+
105
+ /**
106
+ * Find-or-create a group.
107
+ * @param {string} rawJid
108
+ * @param {object} [info]
109
+ * @param {string} [info.subject]
110
+ * @returns {Promise<object|null>}
111
+ */
112
+ async upsertGroup(rawJid, info = {}) {
113
+ if (!rawJid) return null;
114
+ const parsed = JidParser.parse(rawJid);
115
+ if (!parsed.valid || !parsed.isGroup) {
116
+ throw new ValidationError(`Invalid group jid: ${JSON.stringify(rawJid)}`);
117
+ }
118
+
119
+ const subject = UserRepository._clean(info.subject, 256);
120
+ const metadata = info.metadata && typeof info.metadata === 'object' ? info.metadata : {};
121
+
122
+ return this.db.one(
123
+ `INSERT INTO wa_groups (jid, subject, metadata, last_seen_at)
124
+ VALUES ($1, $2, $3::jsonb, NOW())
125
+ ON CONFLICT (jid) DO UPDATE
126
+ SET last_seen_at = NOW(),
127
+ subject = COALESCE(NULLIF(EXCLUDED.subject, ''), wa_groups.subject),
128
+ metadata = wa_groups.metadata || EXCLUDED.metadata
129
+ RETURNING *`,
130
+ [parsed.jid, subject, JSON.stringify(metadata)]
131
+ );
132
+ }
133
+
134
+ /** Record that `userId` is present in `groupId`. */
135
+ async linkMember(groupId, userId, isAdmin = false) {
136
+ if (!groupId || !userId) return null;
137
+ return this.db.one(
138
+ `INSERT INTO wa_group_members (group_id, user_id, is_admin, last_seen_at)
139
+ VALUES ($1, $2, $3, NOW())
140
+ ON CONFLICT (group_id, user_id) DO UPDATE
141
+ SET last_seen_at = NOW(),
142
+ message_count = wa_group_members.message_count + 1,
143
+ is_admin = EXCLUDED.is_admin OR wa_group_members.is_admin
144
+ RETURNING *`,
145
+ [groupId, userId, Boolean(isAdmin)]
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Look a user up by ANY address they are known under — the row itself or
151
+ * one of their linked aliases (`@lid` <-> phone jid).
152
+ */
153
+ async findByJid(rawJid) {
154
+ const jid = JidParser.normalize(rawJid);
155
+ if (!jid) return null;
156
+ return this.db.one(
157
+ `SELECT u.* FROM wa_users u
158
+ WHERE u.jid = $1
159
+ UNION
160
+ SELECT u.* FROM wa_users u
161
+ JOIN wa_user_identities i ON i.user_id = u.id
162
+ WHERE i.jid = $1
163
+ LIMIT 1`,
164
+ [jid]
165
+ );
166
+ }
167
+
168
+ async findGroupByJid(rawJid) {
169
+ const jid = JidParser.normalize(rawJid);
170
+ if (!jid) return null;
171
+ return this.db.one('SELECT * FROM wa_groups WHERE jid = $1', [jid]);
172
+ }
173
+
174
+ async incrementMessageCount(userId, chars = 0) {
175
+ if (!userId) return;
176
+ await this.db.query(
177
+ `UPDATE wa_users
178
+ SET message_count = message_count + 1,
179
+ token_estimate = token_estimate + $2
180
+ WHERE id = $1`,
181
+ [userId, Math.ceil(chars / 4)]
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Block/unblock by ANY address the person is known under.
187
+ *
188
+ * Previously this matched `wa_users.jid` only, so blocking someone by the
189
+ * `@lid` seen in a group silently did nothing (returned null) when their
190
+ * row had been created from a DM phone jid — and vice versa. It now walks
191
+ * the alias graph, and when blocking someone the bot has never seen it
192
+ * creates the row so the block is already in force on their first message.
193
+ */
194
+ async setBlocked(rawJid, blocked = true) {
195
+ const parsed = JidParser.parse(rawJid);
196
+ if (!parsed.valid || parsed.isGroup) {
197
+ throw new ValidationError(`Invalid user jid: ${JSON.stringify(rawJid)}`);
198
+ }
199
+ let user = await this.findByJid(parsed.jid);
200
+ if (!user) {
201
+ if (!blocked) return null; // nothing to unblock
202
+ user = await this.upsertUser(parsed.jid);
203
+ }
204
+ return this.db.one('UPDATE wa_users SET is_blocked = $2 WHERE id = $1 RETURNING *', [
205
+ user.id,
206
+ Boolean(blocked),
207
+ ]);
208
+ }
209
+
210
+ async isBlocked(rawJid) {
211
+ const user = await this.findByJid(rawJid);
212
+ return Boolean(user?.is_blocked);
213
+ }
214
+
215
+ /**
216
+ * Enable/disable the AI in a group. Creates the group row when the bot
217
+ * has not seen the group yet (an admin usually disables Alexa *before*
218
+ * she has answered there), instead of returning null and doing nothing.
219
+ */
220
+ async setGroupEnabled(rawJid, enabled = true) {
221
+ const parsed = JidParser.parse(rawJid);
222
+ if (!parsed.valid || !parsed.isGroup) {
223
+ throw new ValidationError(`Invalid group jid: ${JSON.stringify(rawJid)}`);
224
+ }
225
+ const group = (await this.findGroupByJid(parsed.jid)) || (await this.upsertGroup(parsed.jid));
226
+ return this.db.one('UPDATE wa_groups SET is_enabled = $2 WHERE id = $1 RETURNING *', [
227
+ group.id,
228
+ Boolean(enabled),
229
+ ]);
230
+ }
231
+
232
+ /** Name Alexa should use: learned name > WhatsApp push name > fallback. */
233
+ async resolveDisplayName(userId, fallback = 'there') {
234
+ const row = await this.db.one(
235
+ `SELECT COALESCE(
236
+ u.display_name,
237
+ (SELECT m.value FROM wa_memories m
238
+ WHERE m.user_id = u.id AND m.key IN ('name','full_name','nickname')
239
+ ORDER BY (m.key = 'name') DESC, m.updated_at DESC LIMIT 1),
240
+ u.push_name
241
+ ) AS name
242
+ FROM wa_users u WHERE u.id = $1`,
243
+ [userId]
244
+ );
245
+ return UserRepository._clean(row?.name, 64) || fallback;
246
+ }
247
+
248
+ async setDisplayName(rawJid, name) {
249
+ const jid = JidParser.normalize(rawJid);
250
+ return this.db.one('UPDATE wa_users SET display_name = $2 WHERE jid = $1 RETURNING *', [
251
+ jid,
252
+ UserRepository._clean(name, 64),
253
+ ]);
254
+ }
255
+
256
+ async stats() {
257
+ return this.db.one(`
258
+ SELECT (SELECT COUNT(*) FROM wa_users) AS users,
259
+ (SELECT COUNT(*) FROM wa_groups) AS groups,
260
+ (SELECT COUNT(*) FROM wa_conversations) AS conversations,
261
+ (SELECT COUNT(*) FROM wa_messages) AS messages,
262
+ (SELECT COUNT(*) FROM wa_memories) AS memories,
263
+ (SELECT COUNT(*) FROM wa_users WHERE last_seen_at > NOW() - INTERVAL '24 hours') AS active_24h
264
+ `);
265
+ }
266
+
267
+ static _clean(value, max) {
268
+ if (value == null) return null;
269
+ const str = String(value).replace(/[\u0000-\u001F\u007F]/g, '').trim();
270
+ if (!str) return null;
271
+ return str.length > max ? str.slice(0, max) : str;
272
+ }
273
+ }
274
+
275
+ module.exports = UserRepository;
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AmnesiaGuard
5
+ * ------------
6
+ * Stops the assistant from denying a memory it demonstrably has.
7
+ *
8
+ * THE BUG THIS FIXES
9
+ * ------------------
10
+ * The engine stores facts per human and injects them into every prompt, but the
11
+ * free DeepAI tier still loves to answer:
12
+ *
13
+ * user (in a group): "do you remember me?"
14
+ * model: "Unfortunately, as a bot I can't remember you."
15
+ *
16
+ * …while the database is holding `name=Nimal, location=Galle, hobby=cricket`.
17
+ * The reply is simply false, and it is the single most damaging thing the bot
18
+ * can say.
19
+ *
20
+ * Two layers:
21
+ * 1. `directiveFor()` — a short, explicit instruction (plus the answer the
22
+ * model should give) placed immediately before a recall question.
23
+ * 2. `repair()` — if the reply still denies having a memory while we
24
+ * hold facts, the denial is rewritten from the database. Deterministic:
25
+ * no second round-trip, no extra latency, and it can never be wrong.
26
+ */
27
+ class AmnesiaGuard {
28
+ /** "do you remember me", "what's my name", "who am I", … */
29
+ static RECALL_QUESTION =
30
+ /\b(?:do (?:you|u) (?:still )?(?:remember|know|recall)|remember me|remember my|you remember|what(?:'s| is)? my (?:name|age|city|town|country|job|hobby|favou?rite)|who am i|do you know (?:me|my|who i am)|mata mathakada|mage nama)\b/i;
31
+
32
+ /** Denials of having any memory at all. */
33
+ static DENIAL =
34
+ /(?:\b(?:i|we)\s+(?:really\s+)?(?:can(?:'|no)?t|cannot|can not|do(?:n'?t| not)|am unable to|are unable to|have no (?:way|ability)|don'?t have (?:the )?(?:ability|capability|memory|access)|lack the ability)\s+(?:to\s+)?(?:really\s+)?(?:remember|recall|retain|store|save|access|keep track of)\b)|(?:\bas an? (?:ai|bot|assistant|language model)[^.!?]{0,60}(?:remember|recall|memory|retain)\b)|(?:\bi\s+(?:have|hold|retain)\s+no\s+(?:memory|memories|record|recollection)\b)|(?:\bno memory of (?:you|our|previous|past|earlier)\b)|(?:\bi\s+don'?t\s+(?:have|keep|retain|store)\s+(?:any\s+)?(?:memory|memories|records?|information about you)\b)|(?:\b(?:our|this) conversation (?:has )?just started\b)|(?:\bi don'?t have access to (?:previous|past|prior|earlier) (?:conversations|chats|messages)\b)|(?:\bevery (?:conversation|chat) (?:starts|begins) (?:fresh|anew)\b)|(?:\bi start(?: over)? fresh\b)/i;
35
+
36
+ /** Human-friendly labels for the keys we store most often. */
37
+ static LABELS = {
38
+ name: 'your name is',
39
+ full_name: 'your full name is',
40
+ nickname: 'you also go by',
41
+ age: "you're",
42
+ location: "you're from",
43
+ city: "you're from",
44
+ country: "you're from",
45
+ hobby: 'you love',
46
+ favourite_food: 'your favourite food is',
47
+ favorite_food: 'your favourite food is',
48
+ favourite_team: 'you support',
49
+ favorite_team: 'you support',
50
+ job: 'you work as',
51
+ occupation: 'you work as',
52
+ studies: "you're studying",
53
+ language: 'you speak',
54
+ birthday: 'your birthday is',
55
+ };
56
+
57
+ /**
58
+ * @param {object} [persona]
59
+ * @param {string} [persona.assistantName='Alexa']
60
+ */
61
+ constructor({ assistantName = 'Alexa' } = {}) {
62
+ this.name = String(assistantName || 'Alexa').trim() || 'Alexa';
63
+ }
64
+
65
+ /** Is the user asking the assistant to recall something about them? */
66
+ static isRecallQuestion(message) {
67
+ const text = String(message ?? '').trim();
68
+ if (!text || text.length > 400) return false;
69
+ return AmnesiaGuard.RECALL_QUESTION.test(text);
70
+ }
71
+
72
+ /** Does this reply deny having a memory? */
73
+ static isDenial(reply) {
74
+ const text = String(reply ?? '');
75
+ if (!text.trim()) return false;
76
+ return AmnesiaGuard.DENIAL.test(text);
77
+ }
78
+
79
+ /**
80
+ * Instruction injected right before a recall question, carrying the answer.
81
+ * @param {object} params
82
+ * @param {Record<string,string>} params.memories
83
+ * @param {string} [params.displayName]
84
+ * @param {boolean} params.isRecall
85
+ * @param {boolean} [params.isGroup]
86
+ * @returns {string} '' when nothing needs saying
87
+ */
88
+ directiveFor({ memories = {}, displayName = null, isRecall = false, isGroup = false } = {}) {
89
+ const keys = Object.keys(memories || {});
90
+ if (!isRecall) return '';
91
+
92
+ if (!keys.length) {
93
+ return (
94
+ '[MEMORY CHECK: You DO have a permanent memory, but you have not saved any facts about this ' +
95
+ 'person yet. Say warmly that you do not have their details saved yet and ask them to tell you. ' +
96
+ 'Do NOT say you are unable to remember things.]\n\n'
97
+ );
98
+ }
99
+
100
+ const facts = keys.map((k) => `${k.replace(/_/g, ' ')}: ${memories[k]}`).join('; ');
101
+ const where = isGroup
102
+ ? 'You know this person from their private chat with you, and this message is in a group — the same person, same memory. '
103
+ : '';
104
+ return (
105
+ `[MEMORY CHECK: You DO remember this person. ${where}Saved facts: ${facts}. ` +
106
+ `Answer their question directly and warmly using these facts${displayName ? `, greeting them as ${displayName}` : ''}. ` +
107
+ 'NEVER say you cannot remember, have no memory, or that the conversation just started.]\n\n'
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Rewrite a reply that denies having a memory.
113
+ *
114
+ * @param {string} reply
115
+ * @param {object} params
116
+ * @param {Record<string,string>} params.memories
117
+ * @param {string} [params.displayName]
118
+ * @param {boolean} [params.isRecall]
119
+ * @returns {{ text: string, repaired: boolean }}
120
+ */
121
+ repair(reply, { memories = {}, displayName = null, isRecall = false } = {}) {
122
+ const text = String(reply ?? '');
123
+ if (!AmnesiaGuard.isDenial(text)) return { text, repaired: false };
124
+
125
+ const known = Object.keys(memories || {}).length > 0;
126
+
127
+ // Drop only the sentences that contain the denial; keep the rest.
128
+ const sentences = AmnesiaGuard.splitSentences(text);
129
+ const kept = sentences.filter((s) => !AmnesiaGuard.DENIAL.test(s)).join(' ').trim();
130
+
131
+ if (known) {
132
+ const recall = this.recallSentence(memories, displayName);
133
+ if (isRecall || !kept) return { text: recall, repaired: true };
134
+ return { text: `${recall} ${kept}`.trim(), repaired: true };
135
+ }
136
+
137
+ const honest = displayName
138
+ ? `I don't have any details saved about you yet, ${displayName} — tell me and I'll remember. 😊`
139
+ : "I don't have any details saved about you yet — tell me and I'll remember. 😊";
140
+ return { text: kept ? `${honest} ${kept}`.trim() : honest, repaired: true };
141
+ }
142
+
143
+ /**
144
+ * A warm, WhatsApp-formatted sentence built from stored facts.
145
+ * @param {Record<string,string>} memories
146
+ * @param {string} [displayName]
147
+ */
148
+ recallSentence(memories, displayName = null) {
149
+ const entries = Object.entries(memories || {}).filter(([, v]) => v);
150
+ const name = memories.name || memories.full_name || displayName;
151
+
152
+ const parts = [];
153
+ for (const [key, value] of entries.slice(0, 6)) {
154
+ if (key === 'name' || key === 'full_name') continue;
155
+ const label = AmnesiaGuard.LABELS[key] || `your ${key.replace(/_/g, ' ')} is`;
156
+ parts.push(`${label} _${value}_`);
157
+ }
158
+
159
+ const opener = name ? `Of course I remember you, *${name}*! 😊` : 'Of course I remember you! 😊';
160
+ if (!parts.length) return opener;
161
+
162
+ const list =
163
+ parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`;
164
+ return `${opener} I remember that ${list}.`;
165
+ }
166
+
167
+ /** @private naive but dependable sentence splitter. */
168
+ static splitSentences(text) {
169
+ return String(text)
170
+ .split(/(?<=[.!?])\s+/)
171
+ .map((s) => s.trim())
172
+ .filter(Boolean);
173
+ }
174
+ }
175
+
176
+ module.exports = AmnesiaGuard;