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 +16 -0
- package/README.md +17 -0
- package/package.json +1 -1
- package/src/core/Config.js +7 -0
- package/src/repositories/ConversationRepository.js +7 -1
- package/src/services/PromptBuilder.js +84 -3
- package/test/fakes.js +2 -2
- package/test/run-tests.js +36 -0
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.
|
|
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": {
|
package/src/core/Config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
}
|