alexa-ai 2.4.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 +39 -0
- package/README.md +63 -0
- package/package.json +1 -1
- package/src/AlexaAI.js +47 -1
- package/src/core/Config.js +17 -0
- package/src/core/DeepAIClient.js +14 -1
- package/src/repositories/ConversationRepository.js +7 -1
- package/src/services/PromptBuilder.js +85 -3
- package/src/services/ResponseFormatter.js +17 -0
- package/test/fakes.js +2 -2
- package/test/run-tests.js +97 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,45 @@ 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
|
+
|
|
23
|
+
## [2.5.0] — 2026-09-07
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **`proxy` option / `DEEPAI_PROXY`** — route `/api/*` calls through a
|
|
27
|
+
residential or mobile proxy (`http(s)://` / `socks5://`). Honoured by the
|
|
28
|
+
curl transports (`-x`); `transport: 'auto'` drops global fetch from the
|
|
29
|
+
chain when a proxy is set. This is what makes anonymous image generation
|
|
30
|
+
work from a VPS / data-center IP.
|
|
31
|
+
- **`englishOnly` (default on)** — the persona instructs English-only replies;
|
|
32
|
+
any CJK/Kana/Hangul in an answer triggers one translation re-ask and, if
|
|
33
|
+
that fails, script stripping with a plain-English fallback sentence.
|
|
34
|
+
- `DEEPAI_LOGIN_REQUIRED` error for login-gated models ("model only available
|
|
35
|
+
to logged in users") — no key rotation, anonymous retry or transport
|
|
36
|
+
cascade is attempted for them.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- **`detectNsfw()` on free keys** — `nsfw-detector` has no free/anonymous
|
|
40
|
+
tier, so after the API refusal the engine asks the vision model for a
|
|
41
|
+
safety score (`via: 'chat'`) and otherwise returns `DEEPAI_PRO_REQUIRED`
|
|
42
|
+
with an explanatory message.
|
|
43
|
+
- When script stripping empties the reply, a fixed English sentence is sent
|
|
44
|
+
instead of the original non-English text.
|
|
45
|
+
|
|
7
46
|
## [2.4.0] — 2026-09-07
|
|
8
47
|
|
|
9
48
|
### Fixed
|
package/README.md
CHANGED
|
@@ -908,6 +908,69 @@ retry: <anonymous refusal>`).
|
|
|
908
908
|
purpose — emoji and typographic dashes render as garbage (Chinese-looking
|
|
909
909
|
mojibake) in cmd.exe/PowerShell with a non-UTF-8 codepage.
|
|
910
910
|
|
|
911
|
+
**Running on a VPS / server IP.** DeepAI refuses anonymous `/api/*` generation
|
|
912
|
+
from data-center IPs ("Please try this model on deepai.org"), so image
|
|
913
|
+
generation that works at home fails on a VPS. Two options:
|
|
914
|
+
|
|
915
|
+
1. `proxy` option (2.5.0) — route the `/api/*` calls through a
|
|
916
|
+
residential/mobile proxy; the curl transports honour
|
|
917
|
+
`proxy: 'socks5://host:port'` / `http://host:port` (or `DEEPAI_PROXY`).
|
|
918
|
+
Global fetch cannot use the proxy, so `auto` switches to curl automatically
|
|
919
|
+
when one is configured.
|
|
920
|
+
2. A DeepAI **Pro key** — works from any IP, no proxy needed.
|
|
921
|
+
|
|
922
|
+
**English-only replies (`englishOnly`, 2.5.0, on by default).** The persona
|
|
923
|
+
now instructs the model to answer in English only; if a reply still contains
|
|
924
|
+
CJK/Kana/Hangul, the engine re-asks once for an English version and strips any
|
|
925
|
+
remaining script as a last resort. Disable with `englishOnly: false`.
|
|
926
|
+
|
|
927
|
+
**`detectNsfw()` on a free key.** The `nsfw-detector` model has **no free or
|
|
928
|
+
anonymous tier** on DeepAI (no try-it on its model page; registered free keys
|
|
929
|
+
get the Pro refusal). 2.5.0 therefore: (a) tries the API, (b) on a plan
|
|
930
|
+
refusal asks the vision model for a score instead (`via: 'chat'` — works when
|
|
931
|
+
the key can see images), (c) otherwise returns `error: 'DEEPAI_PRO_REQUIRED'`
|
|
932
|
+
with a clear message. A Pro key makes the real model work.
|
|
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
|
+
|
|
951
|
+
**Always check `result.ok` before sending media.** On failure every helper
|
|
952
|
+
returns `url: null` — passing that straight to Baileys'
|
|
953
|
+
`prepareWAMessageMedia` crashes the bot with
|
|
954
|
+
`TypeError: Cannot read properties of undefined (reading 'toString')`. Guard
|
|
955
|
+
the bot side:
|
|
956
|
+
|
|
957
|
+
```js
|
|
958
|
+
const r = await ai.generateImage(prompt);
|
|
959
|
+
if (!r.ok || !r.url) {
|
|
960
|
+
await sock.sendMessage(jid, { text: `Image failed: ${r.message || r.error}` });
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
try {
|
|
964
|
+
await sock.sendMessage(jid, { image: { url: r.url } });
|
|
965
|
+
} catch (e) {
|
|
966
|
+
console.error('send failed', e);
|
|
967
|
+
}
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
Also register `process.on('uncaughtException', …)` and
|
|
971
|
+
`process.on('unhandledRejection', …)` handlers in the bot entrypoint so one
|
|
972
|
+
bad send can never kill the whole process.
|
|
973
|
+
|
|
911
974
|
**`generateImage()` returns `{ ok: false, error: 'DEEPAI_QUOTA_EXCEEDED' }`**
|
|
912
975
|
`/api/text2img` is Pro-only for registered keys ("APIs are only available for
|
|
913
976
|
Pro members in good standing"), the anonymous browser-shaped retry was
|
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/AlexaAI.js
CHANGED
|
@@ -418,6 +418,26 @@ class AlexaAI {
|
|
|
418
418
|
// Guarantee no @MEMORY remnant ever reaches WhatsApp.
|
|
419
419
|
if (/@\s*MEMORY/i.test(finalText)) finalText = MemoryExtractor.strip(finalText);
|
|
420
420
|
|
|
421
|
+
// English-only replies: if the model answered in another script,
|
|
422
|
+
// re-ask once for an English version and strip any remainder.
|
|
423
|
+
if (this.config.englishOnly && ResponseFormatter.hasNonEnglish(finalText)) {
|
|
424
|
+
let repaired = null;
|
|
425
|
+
try {
|
|
426
|
+
repaired = await this.client.chat([
|
|
427
|
+
{ role: 'user', content: `Rewrite the following in plain English only, keeping the same meaning and formatting. Output only the English text and nothing else:\n\n${finalText}` },
|
|
428
|
+
]);
|
|
429
|
+
} catch {
|
|
430
|
+
repaired = null;
|
|
431
|
+
}
|
|
432
|
+
if (repaired && repaired.trim() && !ResponseFormatter.hasNonEnglish(repaired)) {
|
|
433
|
+
finalText = repaired.trim();
|
|
434
|
+
} else {
|
|
435
|
+
finalText =
|
|
436
|
+
ResponseFormatter.stripNonEnglishScripts(finalText) ||
|
|
437
|
+
'Sorry, I could not phrase that in English. Please ask me again.';
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
421
441
|
if (!finalText.trim()) {
|
|
422
442
|
finalText = 'Sorry, I did not quite catch that. Could you say it again?';
|
|
423
443
|
}
|
|
@@ -790,8 +810,34 @@ class AlexaAI {
|
|
|
790
810
|
try {
|
|
791
811
|
const data = await this.client.detectNsfw(field, {}, opts);
|
|
792
812
|
const score = typeof data?.output?.nsfw_score === 'number' ? data.output.nsfw_score : null;
|
|
793
|
-
return { ok: true, score, nsfw: score == null ? null : score >= threshold, error: null, raw: data };
|
|
813
|
+
return { ok: true, score, nsfw: score == null ? null : score >= threshold, error: null, via: 'api', raw: data };
|
|
794
814
|
} catch (err) {
|
|
815
|
+
// The dedicated model is Pro/login-only. As a best effort, ask
|
|
816
|
+
// the vision model for a safety score instead — works whenever
|
|
817
|
+
// the key can see images.
|
|
818
|
+
if (err.code === 'DEEPAI_LOGIN_REQUIRED' || err.code === 'DEEPAI_QUOTA_EXCEEDED') {
|
|
819
|
+
try {
|
|
820
|
+
const media = Media.normalize(image);
|
|
821
|
+
if (media) {
|
|
822
|
+
const judged = await this.vision.describe(media,
|
|
823
|
+
'Rate how sexually explicit this image is. Reply with ONLY a single decimal number between 0 (completely safe) and 1 (explicit), with no other text.');
|
|
824
|
+
const m = /(?:0\.\d+|1(?:\.0+)?|0|1)/.exec(String(judged.description || judged.text || ''));
|
|
825
|
+
if (judged.ok && m) {
|
|
826
|
+
const score = Number(m[0]);
|
|
827
|
+
return { ok: true, score, nsfw: score >= threshold, error: null, via: 'chat' };
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
} catch {
|
|
831
|
+
/* fall through to the honest refusal */
|
|
832
|
+
}
|
|
833
|
+
return {
|
|
834
|
+
ok: false,
|
|
835
|
+
score: null,
|
|
836
|
+
nsfw: null,
|
|
837
|
+
error: 'DEEPAI_PRO_REQUIRED',
|
|
838
|
+
message: 'nsfw-detector is only available to DeepAI Pro keys (it has no free/anonymous tier), and the vision fallback could not run on this key.',
|
|
839
|
+
};
|
|
840
|
+
}
|
|
795
841
|
return { ok: false, score: null, nsfw: null, error: err.code || 'NSFW_FAILED', message: err.message };
|
|
796
842
|
}
|
|
797
843
|
}
|
package/src/core/Config.js
CHANGED
|
@@ -126,6 +126,12 @@ class Config {
|
|
|
126
126
|
this.curlImpersonatePath =
|
|
127
127
|
opts.curlImpersonatePath || process.env.DEEPAI_CURL_IMPERSONATE || null;
|
|
128
128
|
this.curlImpersonateTarget = opts.curlImpersonateTarget || 'chrome136';
|
|
129
|
+
// Optional proxy for /api/* calls (http/https/socks5 URL). Applied to
|
|
130
|
+
// the curl transports via -x; global fetch cannot honour it without
|
|
131
|
+
// the undici package, so 'auto' skips fetch entirely when this is set.
|
|
132
|
+
// Useful when the bot runs on a server IP that DeepAI refuses for
|
|
133
|
+
// anonymous generation.
|
|
134
|
+
this.proxy = opts.proxy || process.env.DEEPAI_PROXY || null;
|
|
129
135
|
// ---- /api/* anonymous fallback ----------------------------------------
|
|
130
136
|
// When a registered key is refused ("Pro members in good standing"),
|
|
131
137
|
// retry once with a fresh anonymous key in the browser dialect
|
|
@@ -178,6 +184,13 @@ class Config {
|
|
|
178
184
|
|
|
179
185
|
// ---- Conversation / memory tuning -----------------------------------
|
|
180
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
|
|
181
194
|
this.maxMemories = Config._int(opts.maxMemories, 25, 0, 200);
|
|
182
195
|
this.maxMessageLength = Config._int(opts.maxMessageLength, 8000, 100, 100000);
|
|
183
196
|
this.sharedGroupThread = Boolean(opts.sharedGroupThread);
|
|
@@ -213,6 +226,10 @@ class Config {
|
|
|
213
226
|
opts.pool || {}
|
|
214
227
|
);
|
|
215
228
|
|
|
229
|
+
// Replies must be plain English: any CJK/Kana/Hangul in a model
|
|
230
|
+
// answer triggers one translation re-ask, then script stripping.
|
|
231
|
+
this.englishOnly = opts.englishOnly !== false;
|
|
232
|
+
|
|
216
233
|
this.debug = Boolean(opts.debug);
|
|
217
234
|
this.logger = opts.logger || console;
|
|
218
235
|
|
package/src/core/DeepAIClient.js
CHANGED
|
@@ -741,7 +741,8 @@ class DeepAIClient {
|
|
|
741
741
|
if (t === 'fetch') return ['fetch'];
|
|
742
742
|
if (t === 'curl') return ['curl'];
|
|
743
743
|
if (t === 'impersonate') return ['impersonate'];
|
|
744
|
-
|
|
744
|
+
// A proxy can only be honoured by the curl transports.
|
|
745
|
+
const chain = this.config.proxy ? ['curl'] : ['fetch', 'curl'];
|
|
745
746
|
if (await DeepAIClient.resolveImpersonateBinary(this.config)) chain.push('impersonate');
|
|
746
747
|
return chain;
|
|
747
748
|
}
|
|
@@ -793,6 +794,7 @@ class DeepAIClient {
|
|
|
793
794
|
: this.apiKey;
|
|
794
795
|
|
|
795
796
|
const args = impersonate ? ['--impersonate', this.config.curlImpersonateTarget] : [];
|
|
797
|
+
if (this.config.proxy) args.push('-x', this.config.proxy);
|
|
796
798
|
args.push(
|
|
797
799
|
url,
|
|
798
800
|
'-sS', '--compressed',
|
|
@@ -1072,6 +1074,17 @@ class DeepAIClient {
|
|
|
1072
1074
|
const msg = statusMessage || DeepAIClient._detectJsonStatus(body) || `HTTP ${status}`;
|
|
1073
1075
|
const lowered = String(msg).toLowerCase();
|
|
1074
1076
|
|
|
1077
|
+
// Login-gated models cannot be recovered by key rotation, anonymous
|
|
1078
|
+
// retries or another transport — report them as their own error.
|
|
1079
|
+
if (lowered.includes('model only available to logged in users')) {
|
|
1080
|
+
return new DeepAIError(`DeepAI refused the request: ${msg}`, {
|
|
1081
|
+
code: 'DEEPAI_LOGIN_REQUIRED',
|
|
1082
|
+
status,
|
|
1083
|
+
body,
|
|
1084
|
+
retryable: false,
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1075
1088
|
const quotaHints = [
|
|
1076
1089
|
'quota exceeded',
|
|
1077
1090
|
'try it exceeded',
|
|
@@ -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,8 +142,10 @@ 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.',
|
|
148
|
+
'You always reply in plain English only. Never answer in Chinese, Japanese, Korean or any other non-Latin script.',
|
|
131
149
|
'Use WhatsApp formatting only: *bold*, _italic_, ~strike~, `code`. Never use ** or markdown headers.',
|
|
132
150
|
'You have a permanent memory database: facts you are given about a person are things you genuinely remember, in private chats and in every group. Never claim you cannot remember.',
|
|
133
151
|
'Append new personal facts at the very end as @MEMORY: {"key": "value"} and never mention that tag.',
|
|
@@ -204,13 +222,77 @@ class PromptBuilder {
|
|
|
204
222
|
|
|
205
223
|
const cleaned = history
|
|
206
224
|
.filter((m) => m && (m.role === 'user' || m.role === 'assistant'))
|
|
207
|
-
.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
|
+
})
|
|
208
231
|
.filter((m) => m.content.length > 0);
|
|
209
232
|
|
|
210
233
|
const trimmed = cleaned.slice(-limit);
|
|
211
234
|
while (trimmed.length && trimmed[0].role === 'assistant') trimmed.shift();
|
|
212
235
|
return trimmed;
|
|
213
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
|
+
}
|
|
214
296
|
}
|
|
215
297
|
|
|
216
298
|
module.exports = PromptBuilder;
|
|
@@ -19,6 +19,23 @@
|
|
|
19
19
|
* Fenced code blocks are protected and restored verbatim.
|
|
20
20
|
*/
|
|
21
21
|
class ResponseFormatter {
|
|
22
|
+
// CJK ideographs, Kana, Hangul, fullwidth forms — the scripts that must
|
|
23
|
+
// never appear in an English-only reply.
|
|
24
|
+
static NON_ENGLISH_RE = /[\u2E80-\u2EFF\u3000-\u303F\u3040-\u30FF\u3130-\u318F\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF]/;
|
|
25
|
+
|
|
26
|
+
/** True when the text contains CJK/Kana/Hangul/fullwidth characters. */
|
|
27
|
+
static hasNonEnglish(text) {
|
|
28
|
+
return ResponseFormatter.NON_ENGLISH_RE.test(String(text || ''));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Remove CJK/Kana/Hangul/fullwidth runs (last-resort cleanup). */
|
|
32
|
+
static stripNonEnglishScripts(text) {
|
|
33
|
+
return String(text || '')
|
|
34
|
+
.replace(/([\u2E80-\u2EFF\u3000-\u303F\u3040-\u30FF\u3130-\u318F\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF])+/g, ' ')
|
|
35
|
+
.replace(/ {2,}/g, ' ')
|
|
36
|
+
.trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
/**
|
|
23
40
|
* @param {string} reply
|
|
24
41
|
* @returns {string}
|
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'));
|
|
@@ -730,18 +753,44 @@ section('DeepAIClient — the whole endpoint surface (mocked transport)');
|
|
|
730
753
|
const anonNsfw = calls.find((c) => DeepAIClient.isTryItKey(c.key));
|
|
731
754
|
ok('anonymous nsfw retry uses the model-page dialect', anonNsfw && anonNsfw.form.generation_source === 'img');
|
|
732
755
|
|
|
733
|
-
// anonymousApiFallback:false keeps the refusal
|
|
756
|
+
// anonymousApiFallback:false keeps the refusal (no /api retry; the
|
|
757
|
+
// vision fallback may still run but makes no /api/nsfw-detector call)
|
|
734
758
|
const strict = new AlexaAI({ key: '11111111-2222-3333-4444-555555555555', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, anonymousApiFallback: false });
|
|
735
|
-
|
|
736
|
-
let fetchCalls = 0;
|
|
759
|
+
let apiCalls = 0;
|
|
737
760
|
global.fetch = async (url, init = {}) => {
|
|
738
|
-
|
|
761
|
+
if (/nsfw-detector$/.test(String(url))) apiCalls++;
|
|
739
762
|
return { status: 402, headers: { get: () => 'application/json' }, text: async () => JSON.stringify({ status: 'APIs are only available for Pro members in good standing' }) };
|
|
740
763
|
};
|
|
741
764
|
const refused = await strict.detectNsfw(Buffer.from('fake-image-bytes'));
|
|
742
765
|
global.fetch = realFetch;
|
|
743
|
-
ok('anonymousApiFallback:false surfaces the refusal', refused.ok === false && refused.error === '
|
|
744
|
-
ok('no anonymous retry was made',
|
|
766
|
+
ok('anonymousApiFallback:false surfaces the refusal', refused.ok === false && refused.error === 'DEEPAI_PRO_REQUIRED');
|
|
767
|
+
ok('no anonymous retry was made', apiCalls === 1);
|
|
768
|
+
|
|
769
|
+
// login-gated models map to DEEPAI_LOGIN_REQUIRED and never rotate keys
|
|
770
|
+
{
|
|
771
|
+
const cfg2 = new Config({ key: 'tryit-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', keys: ['tryit-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'tryit-2-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'], postgresUrl: 'postgres://u:p@localhost/db', maxRetries: 0 });
|
|
772
|
+
const c2 = new DeepAIClient(cfg2);
|
|
773
|
+
const seen2 = [];
|
|
774
|
+
global.fetch = async (url, init = {}) => {
|
|
775
|
+
seen2.push(init.headers['api-key']);
|
|
776
|
+
return { status: 401, headers: { get: () => 'application/json' }, text: async () => JSON.stringify({ status: 'model only available to logged in users' }) };
|
|
777
|
+
};
|
|
778
|
+
let loginErr = null;
|
|
779
|
+
try { await c2.runApi('nsfw-detector', { image: 'https://x/y.png' }); } catch (e) { loginErr = e; }
|
|
780
|
+
global.fetch = realFetch;
|
|
781
|
+
ok('login-gated model raises DEEPAI_LOGIN_REQUIRED', loginErr?.code === 'DEEPAI_LOGIN_REQUIRED');
|
|
782
|
+
ok('no key rotation on login-gated models', seen2.length === 1);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// the vision fallback answers when the model refuses for plan reasons
|
|
786
|
+
{
|
|
787
|
+
const ai2 = new AlexaAI({ key: '11111111-2222-3333-4444-555555555555', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false });
|
|
788
|
+
ai2.vision = { describe: async () => ({ ok: true, description: '0.85' }) };
|
|
789
|
+
global.fetch = async () => ({ status: 402, headers: { get: () => 'application/json' }, text: async () => JSON.stringify({ status: 'APIs are only available for Pro members in good standing' }) });
|
|
790
|
+
const judged = await ai2.detectNsfw(Buffer.from('fake'), { threshold: 0.7 });
|
|
791
|
+
global.fetch = realFetch;
|
|
792
|
+
ok('detectNsfw falls back to the vision model', judged.ok === true && judged.via === 'chat' && judged.score === 0.85 && judged.nsfw === true);
|
|
793
|
+
}
|
|
745
794
|
}
|
|
746
795
|
|
|
747
796
|
{
|
|
@@ -829,6 +878,22 @@ section('DeepAIClient — the whole endpoint surface (mocked transport)');
|
|
|
829
878
|
ok('fetch-only transport never shells out', curlCalled === false);
|
|
830
879
|
}
|
|
831
880
|
|
|
881
|
+
// 5. a proxy pins the chain to the curl transports and passes -x
|
|
882
|
+
{
|
|
883
|
+
let call = null;
|
|
884
|
+
DeepAIClient.execCurl = async (bin, args) => { call = { bin, args }; return '{"id":"p1","share_url":"https://deepai.org/p.png"}\n200'; };
|
|
885
|
+
const client = new DeepAIClient(new Config({
|
|
886
|
+
key: 'k', postgresUrl: 'postgres://u:p@localhost/db',
|
|
887
|
+
proxy: 'socks5://127.0.0.1:9050', maxRetries: 0,
|
|
888
|
+
}));
|
|
889
|
+
const chain = await client._transportChain();
|
|
890
|
+
ok('proxy removes fetch from the transport chain', !chain.includes('fetch') && chain.includes('curl'));
|
|
891
|
+
const data = await client.runApi('text2img', { text: 'cat' });
|
|
892
|
+
ok('proxied request succeeds through curl', data.share_url === 'https://deepai.org/p.png');
|
|
893
|
+
const xi = call.args.indexOf('-x');
|
|
894
|
+
ok('curl receives the proxy flag', xi !== -1 && call.args[xi + 1] === 'socks5://127.0.0.1:9050');
|
|
895
|
+
}
|
|
896
|
+
|
|
832
897
|
global.fetch = realFetch;
|
|
833
898
|
DeepAIClient.execCurl = realExecCurl;
|
|
834
899
|
DeepAIClient._impersonateCache = realCache;
|
|
@@ -925,6 +990,32 @@ async function endToEndTests() {
|
|
|
925
990
|
deepai.push('Sure! DeepAI can help you with that.');
|
|
926
991
|
const leak = await ai.chat({ message: 'can you help me?', userId: '78151912841263@lid' });
|
|
927
992
|
ok('vendor name never ships', !/deepai/i.test(leak.text));
|
|
993
|
+
|
|
994
|
+
// 7. A non-English reply is re-asked in English.
|
|
995
|
+
deepai.push('你好!很高兴认识你,Nimal。');
|
|
996
|
+
deepai.push('Hello! Very nice to meet you, Nimal.');
|
|
997
|
+
const zh = await ai.chat({ message: 'hi again', userId: '78151912841263@lid' });
|
|
998
|
+
check('Chinese reply is re-asked in English', zh.text, 'Hello! Very nice to meet you, Nimal.');
|
|
999
|
+
|
|
1000
|
+
// 8. When the re-ask also fails, the CJK is stripped (or replaced).
|
|
1001
|
+
deepai.push('这是一段完全中文的回答。');
|
|
1002
|
+
deepai.push('还是中文。');
|
|
1003
|
+
const zh2 = await ai.chat({ message: 'hello', userId: '78151912841263@lid' });
|
|
1004
|
+
ok('unrecoverable reply carries no CJK', !/[\u4E00-\u9FFF]/.test(zh2.text));
|
|
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);
|
|
928
1019
|
} finally {
|
|
929
1020
|
deepai.restore();
|
|
930
1021
|
}
|