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 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.4.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": {
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
  }
@@ -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
 
@@ -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
- const chain = ['fetch', 'curl'];
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
- 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,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) => ({ 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
+ })
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
- calls.length = 0;
736
- let fetchCalls = 0;
759
+ let apiCalls = 0;
737
760
  global.fetch = async (url, init = {}) => {
738
- fetchCalls++;
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 === 'DEEPAI_QUOTA_EXCEEDED');
744
- ok('no anonymous retry was made', fetchCalls === 1);
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
  }