alexa-ai 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to `alexa-ai` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [2.6.0] — 2026-09-07
8
+
9
+ ### Added
10
+ - **Time-aware conversations.** Previously the replayed history had no
11
+ timestamps, so a thread from a week ago read like it happened today and the
12
+ model answered accordingly. Now:
13
+ - the system prompt carries a live "Today is <weekday, date, time, zone>"
14
+ line (`timeZone` option, defaults to the server zone);
15
+ - history turns further apart than `timeGapMinutes` (default 60) carry a
16
+ bracketed gap marker, e.g. "[about 1 week passed since the previous
17
+ message]";
18
+ - the live message carries the same marker when the thread itself was
19
+ resumed after a long pause.
20
+ Disable with `historyTimeMarkers: false`. `getHistory()` now returns
21
+ `createdAt` alongside `role`/`content`.
22
+
7
23
  ## [2.5.0] — 2026-09-07
8
24
 
9
25
  ### Added
package/README.md CHANGED
@@ -931,6 +931,23 @@ refusal asks the vision model for a score instead (`via: 'chat'` — works when
931
931
  the key can see images), (c) otherwise returns `error: 'DEEPAI_PRO_REQUIRED'`
932
932
  with a clear message. A Pro key makes the real model work.
933
933
 
934
+ **The bot thinks last week's chat happened today.** Fixed in 2.6.0. Two
935
+ mechanisms now keep the model time-aware:
936
+
937
+ 1. The system prompt carries a live date line — *"Today is Tuesday, 22
938
+ September 2026 at 14:05 (Asia/Colombo time)."* — so it can answer
939
+ date questions and judge how old memories are.
940
+ 2. Replayed history carries time-gap markers. When two turns are more than
941
+ `timeGapMinutes` apart (default 60), the later turn is prefixed with a
942
+ bracketed note like `[about 1 week passed since the previous message]`,
943
+ and the same marker is attached to the live message when a thread is
944
+ resumed after a long pause. The model explicitly knows the old messages
945
+ are from last week.
946
+
947
+ Options: `timeGapMinutes` (default 60), `historyTimeMarkers: false` to
948
+ disable markers, `timeZone` (e.g. `'Asia/Colombo'`; defaults to the server's
949
+ zone).
950
+
934
951
  **Always check `result.ok` before sending media.** On failure every helper
935
952
  returns `url: null` — passing that straight to Baileys'
936
953
  `prepareWAMessageMedia` crashes the bot with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alexa-ai",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "AI engine for the Alexa WhatsApp bot: DeepAI-powered chat with PostgreSQL-backed long-term memory, cross-chat identity (@lid <-> phone), vision/OCR, image generation and web search.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -184,6 +184,13 @@ class Config {
184
184
 
185
185
  // ---- Conversation / memory tuning -----------------------------------
186
186
  this.historyLimit = Config._int(opts.historyLimit, 14, 2, 60);
187
+ // Time awareness: the prompt carries a "Today is ..." line, and a
188
+ // gap marker is inserted between history turns that are further
189
+ // apart than timeGapMinutes, so the model knows how much time has
190
+ // passed instead of treating a week-old chat as happening today.
191
+ this.historyTimeMarkers = opts.historyTimeMarkers !== false;
192
+ this.timeGapMinutes = Config._int(opts.timeGapMinutes, 60, 1, 60 * 24 * 365);
193
+ this.timeZone = opts.timeZone || process.env.DEEPAI_TIME_ZONE || null; // null = server local
187
194
  this.maxMemories = Config._int(opts.maxMemories, 25, 0, 200);
188
195
  this.maxMessageLength = Config._int(opts.maxMessageLength, 8000, 100, 100000);
189
196
  this.sharedGroupThread = Boolean(opts.sharedGroupThread);
@@ -119,7 +119,13 @@ class ConversationRepository {
119
119
  ORDER BY created_at ASC, id ASC`,
120
120
  [conversationId, limit]
121
121
  );
122
- return rows.map((r) => ({ role: r.role, content: r.content }));
122
+ // createdAt rides along so the prompt can carry time-gap markers;
123
+ // without them a week-old thread reads like it happened just now.
124
+ return rows.map((r) => ({
125
+ role: r.role,
126
+ content: r.content,
127
+ createdAt: r.created_at ? new Date(r.created_at).toISOString() : null,
128
+ }));
123
129
  }
124
130
 
125
131
  async findByContextKey(contextKey) {
@@ -84,13 +84,29 @@ class PromptBuilder {
84
84
  // 2) Assistant acknowledgement locks the role in.
85
85
  messages.push({ role: 'assistant', content: this._acknowledgement() });
86
86
 
87
- // 3) Prior turns of this thread.
88
- for (const turn of PromptBuilder._sanitiseHistory(history, this.config.historyLimit)) {
87
+ // 3) Prior turns of this thread, with time-gap markers so the model
88
+ // knows a thread resumed days later instead of assuming "today".
89
+ const annotatedHistory = PromptBuilder._timeAnnotatedHistory(history, this.config);
90
+ for (const turn of annotatedHistory) {
89
91
  messages.push(turn);
90
92
  }
91
93
 
92
94
  // 4) The live message, with its reinforcement notes.
93
95
  let current = String(message ?? '').trim();
96
+
97
+ // 4a) The common case: the thread itself is old. When the last
98
+ // replayed turn is further back than the gap threshold, the live
99
+ // message carries the marker so the model knows how much time passed
100
+ // since the previous conversation.
101
+ if (this.config.historyTimeMarkers) {
102
+ const lastStamped = [...annotatedHistory].reverse().find((t) => t.createdAt);
103
+ if (lastStamped) {
104
+ const gap = Date.now() - Date.parse(lastStamped.createdAt);
105
+ if (gap >= this.config.timeGapMinutes * 60 * 1000) {
106
+ current = `[${PromptBuilder._gapLabel(gap)} passed since the previous message]\n\n${current}`;
107
+ }
108
+ }
109
+ }
94
110
  if (imageContext) {
95
111
  current = current
96
112
  ? `[Image attached — visual description: ${imageContext}]\n\n${current}`
@@ -126,6 +142,7 @@ class PromptBuilder {
126
142
  const { assistantName, creator } = this.config;
127
143
  return [
128
144
  `You are ${assistantName}, a warm, friendly female WhatsApp assistant created by ${creator}.`,
145
+ PromptBuilder._todayLine(this.config.timeZone),
129
146
  `Your name is exactly "${assistantName}" — never a variant such as "${assistantName} Mini" or "${assistantName} AI".`,
130
147
  'Never mention DeepAI, ChatGPT, OpenAI, GPT, Llama, Gemini or any model/company name, and never call yourself a language model.',
131
148
  'You always reply in plain English only. Never answer in Chinese, Japanese, Korean or any other non-Latin script.',
@@ -205,13 +222,77 @@ class PromptBuilder {
205
222
 
206
223
  const cleaned = history
207
224
  .filter((m) => m && (m.role === 'user' || m.role === 'assistant'))
208
- .map((m) => ({ role: m.role, content: String(m.content ?? '').trim() }))
225
+ .map((m) => {
226
+ const turn = { role: m.role, content: String(m.content ?? '').trim() };
227
+ const t = Date.parse(m.createdAt || m.created_at || m.timestamp || '');
228
+ if (Number.isFinite(t)) turn.createdAt = new Date(t).toISOString();
229
+ return turn;
230
+ })
209
231
  .filter((m) => m.content.length > 0);
210
232
 
211
233
  const trimmed = cleaned.slice(-limit);
212
234
  while (trimmed.length && trimmed[0].role === 'assistant') trimmed.shift();
213
235
  return trimmed;
214
236
  }
237
+
238
+ /**
239
+ * History plus out-of-band time-gap markers. Whenever two consecutive
240
+ * turns are further apart than config.timeGapMinutes, the later turn is
241
+ * prefixed with a bracketed note like "[About 7 days passed since the
242
+ * previous message]" so the model reasons correctly about old threads.
243
+ * @private
244
+ */
245
+ static _timeAnnotatedHistory(history, config) {
246
+ const turns = PromptBuilder._sanitiseHistory(history, config.historyLimit);
247
+ if (!config.historyTimeMarkers) return turns;
248
+ const thresholdMs = config.timeGapMinutes * 60 * 1000;
249
+
250
+ let previous = null;
251
+ return turns.map((turn) => {
252
+ const annotated = { ...turn };
253
+ if (previous && previous.createdAt && turn.createdAt) {
254
+ const gap = Date.parse(turn.createdAt) - Date.parse(previous.createdAt);
255
+ if (gap >= thresholdMs) {
256
+ const label = PromptBuilder._gapLabel(gap);
257
+ annotated.content = `[${label} passed since the previous message]\n\n${turn.content}`;
258
+ }
259
+ }
260
+ if (turn.createdAt) previous = turn;
261
+ return annotated;
262
+ });
263
+ }
264
+
265
+ /** @private human duration for gap markers. */
266
+ static _gapLabel(ms) {
267
+ const minutes = Math.round(ms / 60000);
268
+ if (minutes < 60) return `${Math.max(1, minutes)} minute${minutes === 1 ? '' : 's'}`;
269
+ const hours = Math.round(minutes / 60);
270
+ if (hours < 24) return `about ${hours} hour${hours === 1 ? '' : 's'}`;
271
+ const days = Math.round(hours / 24);
272
+ if (days < 7) return `about ${days} day${days === 1 ? '' : 's'}`;
273
+ if (days < 30) return `about ${Math.round(days / 7)} week${Math.round(days / 7) === 1 ? '' : 's'}`;
274
+ const months = Math.round(days / 30);
275
+ return `about ${months} month${months === 1 ? '' : 's'}`;
276
+ }
277
+
278
+ /**
279
+ * "Today is Tuesday, 22 September 2026 at 14:05 (Colombo time)." — keeps
280
+ * the model grounded on the current date; also used for date questions.
281
+ * @private
282
+ */
283
+ static _todayLine(timeZone) {
284
+ try {
285
+ const zone = timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
286
+ const now = new Date();
287
+ const date = new Intl.DateTimeFormat('en-GB', {
288
+ weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
289
+ hour: '2-digit', minute: '2-digit', hour12: false, timeZone: zone,
290
+ }).format(now);
291
+ return `Today is ${date} (${zone.replace(/_/g, ' ')} time). Use this for date and time questions, and to reason about how old conversations and memories are.`;
292
+ } catch {
293
+ return `Today is ${new Date().toDateString()}. Use this for date and time questions.`;
294
+ }
295
+ }
215
296
  }
216
297
 
217
298
  module.exports = PromptBuilder;
package/test/fakes.js CHANGED
@@ -79,7 +79,7 @@ function createFakeDb() {
79
79
  return { name: user?.display_name || named?.value || user?.push_name || null };
80
80
  }
81
81
  if (/^INSERT INTO wa_messages/.test(q)) {
82
- const row = { id: seq++, conversation_id: p[0], user_id: p[1], role: p[2], content: p[3] };
82
+ const row = { id: seq++, conversation_id: p[0], user_id: p[1], role: p[2], content: p[3], created_at: new Date().toISOString() };
83
83
  state.messages.push(row);
84
84
  return row;
85
85
  }
@@ -97,7 +97,7 @@ function createFakeDb() {
97
97
  if (/FROM wa_messages/.test(q)) {
98
98
  return state.messages
99
99
  .filter((m) => m.conversation_id === p[0])
100
- .map((m) => ({ role: m.role, content: m.content }));
100
+ .map((m) => ({ role: m.role, content: m.content, created_at: m.created_at }));
101
101
  }
102
102
  if (/FROM wa_memories/.test(q)) {
103
103
  return state.memories.filter((m) => m.user_id === p[0]).map((m) => ({ key: m.key, value: m.value }));
package/test/run-tests.js CHANGED
@@ -328,6 +328,29 @@ section('PromptBuilder — persona delivery');
328
328
  ok('group context injected', persona.content.includes('GROUP'));
329
329
  ok('group turn states it is the same person as the DM', persona.content.includes('SAME person'));
330
330
  check('third turn is the assistant ack', msgs[2].role, 'assistant');
331
+
332
+ // time awareness: current-date line + gap markers
333
+ ok('system digest carries a Today line', /Today is .+ time\)\. Use this for date/.test(msgs[0].content));
334
+ {
335
+ const now = Date.now();
336
+ const aged = pb.build({
337
+ message: 'hi again',
338
+ history: [
339
+ { role: 'user', content: 'old question', createdAt: new Date(now - 7 * 86400000).toISOString() },
340
+ { role: 'assistant', content: 'old answer', createdAt: new Date(now - 7 * 86400000 + 30000).toISOString() },
341
+ { role: 'user', content: 'recent question', createdAt: new Date(now - 60000).toISOString() },
342
+ ],
343
+ });
344
+ const turns = aged.slice(3).filter((m) => m.role === 'user' || m.role === 'assistant');
345
+ ok('week-long gap gets a marker', turns.some((m) => m.content.includes('[about 1 week passed since the previous message]')));
346
+ ok('short gap gets no marker', !turns.some((m) => /passed since/.test(m.content) && m.content.includes('old answer')));
347
+ const off = new PromptBuilder(new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db', historyTimeMarkers: false }));
348
+ const plain = off.build({
349
+ message: 'hi',
350
+ history: [{ role: 'user', content: 'old question', createdAt: new Date(now - 7 * 86400000).toISOString() }, { role: 'user', content: 'recent', createdAt: new Date(now).toISOString() }],
351
+ });
352
+ ok('markers can be disabled', !plain.slice(3).some((m) => /passed since/.test(m.content)));
353
+ }
331
354
  ok('last turn contains the live message', msgs[msgs.length - 1].content.endsWith('hello'));
332
355
  ok('recall note precedes the live message', msgs[msgs.length - 1].content.includes('Remembered facts'));
333
356
  ok('recall note lists known facts', msgs[msgs.length - 1].content.includes('name=Nimal'));
@@ -980,6 +1003,19 @@ async function endToEndTests() {
980
1003
  const zh2 = await ai.chat({ message: 'hello', userId: '78151912841263@lid' });
981
1004
  ok('unrecoverable reply carries no CJK', !/[\u4E00-\u9FFF]/.test(zh2.text));
982
1005
  ok('unrecoverable reply is non-empty', zh2.text.trim().length > 0);
1006
+
1007
+ // 9. A week-old thread is replayed with a time-gap marker, so the
1008
+ // model knows the old messages are from last week, not today.
1009
+ deepai.push('That was a week ago! Today is a new day.');
1010
+ for (const m of db.state.messages) {
1011
+ if (m.conversation_id && m.created_at) m.created_at = new Date(Date.now() - 7 * 86400000).toISOString();
1012
+ }
1013
+ const agedReply = await ai.chat({ message: 'do you remember me?', userId: '78151912841263@lid' });
1014
+ const lastCall = deepai.calls[deepai.calls.length - 1];
1015
+ const sentHistory = lastCall.fields.chatHistory || '';
1016
+ ok('aged thread sends a gap marker', /passed since the previous message/.test(sentHistory));
1017
+ ok('gap marker says roughly a week', /about 1 week/.test(sentHistory));
1018
+ ok('reply is not corrupted by the marker', typeof agedReply.text === 'string' && agedReply.text.length > 0);
983
1019
  } finally {
984
1020
  deepai.restore();
985
1021
  }