alexa-ai 2.1.1 → 2.1.2

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,43 @@ 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.1.2] — 2026-09-06
8
+
9
+ ### Changed
10
+ - **`searchWeb()` now searches the web itself** instead of trusting DeepAI's
11
+ server-side web access. Observed live with `gpt-4o-mini`: the same query
12
+ produced one report without any sources and one with four invented links,
13
+ because the model skipped the search and wrote from memory. The engine now
14
+ queries Bing, Bing News, Wikipedia, Google News and DuckDuckGo in parallel
15
+ (no API key needed; the four feeds were verified live on 2026-09-06 —
16
+ DuckDuckGo's html endpoint serves a bot challenge from data-centre IPs, so
17
+ its lite endpoint is tried first and a challenge page yields no results), hands the results to the model as numbered material, and asks
18
+ it to cite result numbers and never write URLs. The `*Sources:*` block is
19
+ built from the search results only — cited first — so a link the model made
20
+ up can never reach the chat. Citation markers are removed from the prose;
21
+ URLs the model still types are turned into citations when they match a
22
+ result and dropped otherwise.
23
+ - Result gained `grounded` (did the engine's search answer?), `providers`,
24
+ `via` (`'model'` | `'digest'`), and each source carries `date`, `provider`
25
+ and `cited`.
26
+ - New options: `results` (bring your own search API's results), `search:false`,
27
+ `providers`, `maxResults`; constructor options `webSearch`,
28
+ `webSearchProviders`, `webSearchTimeout`, `webSearchResults`,
29
+ `webSearchProvider(query, { maxResults, signal })`.
30
+ - When every provider fails the request falls back to DeepAI's web access as
31
+ before (`grounded:false`); when the search worked but the model call failed,
32
+ a plain digest of the results is returned (`via:'digest'`, still `ok:true`).
33
+
34
+ ### Added
35
+ - `WebSearch` service (exported) with parsers for the DuckDuckGo html/lite
36
+ pages, RSS 2.0 news feeds and the MediaWiki search API, redirect unwrapping
37
+ (`duckduckgo.com/l/?uddg=`, `bing.com/news/apiclick.aspx?url=`), ad and
38
+ duplicate filtering, and provider round-robin.
39
+ - `WebAnswer.formatResults()`, `extractCitations()`, `stripUrls()`, `digest()`.
40
+ - The npm package now ships the test suite (`test/`), so the published
41
+ tarball is a complete snapshot of the repository at the tagged version:
42
+ `npm explore alexa-ai -- npm test`.
43
+
7
44
  ## [2.1.1] — 2026-09-06
8
45
 
9
46
  ### Fixed
package/README.md CHANGED
@@ -439,11 +439,16 @@ which works on free chat keys. The result reports the route that answered
439
439
  **`summarizeText()`** follows the same pattern — `/api/summarization` first, a
440
440
  stateless chat request as the fallback.
441
441
 
442
- **`searchWeb()`** is a one-off research request with DeepAI's web access
443
- enabled. It applies the persona and formatting rules but touches no user's
444
- memory or history, so it can be called with no jid at all. The default answer
445
- is long-form and ready to send to WhatsApp an intro, three to five
446
- `*Heading:*` sections with numbered `*Title*: detail` points, and one
442
+ **`searchWeb()`** is a one-off research request that touches no user's
443
+ memory or history, so it can be called with no jid at all. The engine does the
444
+ searching itself — Bing, Bing News, Wikipedia, Google News and DuckDuckGo, in
445
+ parallel, no API key needed and hands the results to the model as numbered
446
+ material. The model writes the report from them and cites result numbers; it
447
+ is told never to write a URL. The `*Sources:*` block is then built from the
448
+ search results only (cited ones first), so every link the bot shows is a page
449
+ that actually came back from a search, never one the model made up. The
450
+ default answer is long-form and ready to send to WhatsApp — an intro, three to
451
+ five `*Heading:*` sections with numbered `*Title*: detail` points, and one
447
452
  `*Sources:*` block at the end:
448
453
 
449
454
  ```
@@ -471,15 +476,25 @@ answer still comes back under `minWords` (default 150), one follow-up turn asks
471
476
  the model to rewrite it in full, and the longer reply wins — `attempts` and
472
477
  `words` in the result show what happened.
473
478
 
474
- DeepAI reports the pages it used in two different ways depending on the model
475
- that answered: some send a structured web-results packet, others (observed
476
- with `gpt-4o-mini`) just write a `Sources:` list in prose. Both end up in the
477
- `sources` array as `{ title, url, description }`; the list is rendered exactly
478
- once in `text`, and `answer` holds the prose without it. Sentences in which
479
- the model talks about itself *"I'm a language model"*, *"I can't browse the
480
- web"*, *"based on my training data"* — are removed, and leftover template
481
- placeholders are dropped. Third-party names in the research itself (news
482
- about OpenAI or Google) are kept verbatim.
479
+ The result tells you which path answered:
480
+
481
+ ```js
482
+ const r = await ai.searchWeb('coffee');
483
+ // r.grounded true → the engine searched; sources are real search results
484
+ // false no search results (host offline / providers blocked);
485
+ // DeepAI's own web access answered, and its sources are
486
+ // whatever the model reported treat them with care
487
+ // r.providers ['bing', 'bing-news', 'wikipedia', 'google-news', 'duckduckgo'] — who answered
488
+ // r.sources [{ title, url, description, date, provider, cited }]
489
+ // r.via 'model' | 'digest' (the model failed but the search worked:
490
+ // a plain list of the results is returned)
491
+ // r.words, r.attempts, r.model
492
+ ```
493
+
494
+ Sentences in which the model talks about itself — *"I'm a language model"*,
495
+ *"I can't browse the web"*, *"based on my training data"* — are removed, and
496
+ leftover template placeholders are dropped. Third-party names in the research
497
+ itself (news about OpenAI or Google) are kept verbatim.
483
498
 
484
499
  ```js
485
500
  await ai.searchWeb('coffee', {
@@ -489,9 +504,37 @@ await ai.searchWeb('coffee', {
489
504
  maxSources: 3, // how many to list in `text` (the array is not capped)
490
505
  language: 'Sinhala', // answer language (default: the language of the query)
491
506
  instructions: 'focus on Sri Lanka',
507
+ providers: ['bing-news', 'google-news'], // only news for this call
508
+ maxResults: 5, // results handed to the model (default 8)
509
+ search: false, // skip the built-in search, use DeepAI's web access
510
+ });
511
+
512
+ // Bring your own search API (Brave, Serper, Tavily, …): pass its results and
513
+ // the built-in search is skipped — the sources block is built from them.
514
+ await ai.searchWeb('coffee', {
515
+ results: [{ title, url, description, date }],
492
516
  });
493
517
  ```
494
518
 
519
+ Engine-wide settings in the constructor:
520
+
521
+ ```js
522
+ new AlexaAI({
523
+ webSearch: true, // false = never search; always use DeepAI's
524
+ webSearchProviders: ['bing', 'bing-news', 'wikipedia', 'google-news', 'duckduckgo'],
525
+ webSearchTimeout: 8000, // per provider, ms
526
+ webSearchResults: 8, // results handed to the model
527
+ webSearchProvider: async (query, { maxResults, signal }) => [...], // replace the built-ins
528
+ });
529
+ ```
530
+
531
+ The built-in providers are public pages and feeds, so they can change or be
532
+ rate-limited; each one is best-effort and a failure just means fewer results.
533
+ When none return anything the request falls back to DeepAI's server-side web
534
+ access — which on the free models is exactly the behaviour that motivated this
535
+ design: it often skips the search and writes a plausible report with invented
536
+ links. Check `grounded` if that matters to you.
537
+
495
538
  ### Moderation and administration
496
539
 
497
540
  ```js
@@ -601,7 +644,8 @@ AlexaAI orchestrator; the only class the bot touches
601
644
  │ no "Alexa Mini", no self-denial)
602
645
  ├── AmnesiaGuard never lets her deny a memory she actually has
603
646
  ├── ImageDescriber vision chain: documents -> DeepAI -> OCR -> fallback
604
- ├── WebAnswer searchWeb prompt; lifts "Sources:" lists out of prose
647
+ ├── WebSearch the engine's own web search (Bing, Bing/Google News, Wikipedia, DuckDuckGo)
648
+ ├── WebAnswer searchWeb prompt, citation handling, sources block
605
649
  ├── Media normalises every media input shape
606
650
  └── JidParser normalises @lid / @s.whatsapp.net / @g.us
607
651
  ```
@@ -770,7 +814,7 @@ refresh it automatically. Reinstall and verify:
770
814
  ```bash
771
815
  npm uninstall alexa-ai
772
816
  npm install github:AlexaInc/deepai
773
- node -e "console.log(require('alexa-ai').version)" # must print 2.1.1 or newer
817
+ node -e "console.log(require('alexa-ai').version)" # must print 2.1.2 or newer
774
818
  ```
775
819
 
776
820
  `AlexaAI.version` and `AlexaAI.methods()` let the bot assert this at startup;
@@ -780,10 +824,22 @@ node -e "console.log(require('alexa-ai').version)" # must print 2.1.1 or newer
780
824
  `text`**
781
825
  Fixed in 2.1.1. Earlier builds asked the model for *"a short, direct answer"*
782
826
  and only read sources from DeepAI's structured packet, which `gpt-4o-mini`
783
- does not send. Reinstall as above and confirm the version is 2.1.1 or newer.
784
- If a particular model still answers briefly, check `attempts` / `words` in the
785
- result: the engine retries once below `minWords`, and a persistently short
786
- model is best swapped with the `model` option.
827
+ does not send. If a particular model still answers briefly, check `attempts` /
828
+ `words` in the result: the engine retries once below `minWords`, and a
829
+ persistently short model is best swapped with the `model` option.
830
+
831
+ **`searchWeb()` sometimes has no sources, or the sources are links that do
832
+ not exist**
833
+ Fixed in 2.1.2. Before, the model was both researcher and writer: DeepAI's
834
+ server-side search ran unreliably on the free models, so the model often
835
+ wrote from memory — one run had no sources, the next had four invented ones.
836
+ The engine now searches the web itself and the sources block is built only
837
+ from those results. If `sources` is still empty, look at `grounded` in the
838
+ result: `false` means none of the search providers answered from your host
839
+ (firewall, DNS, rate limit — enable `debug:true` to see each provider's
840
+ error), and the reply came from DeepAI's own web access. Point
841
+ `webSearchProvider` at a search API you control if the public endpoints are
842
+ blocked where the bot runs.
787
843
 
788
844
  **`generateImage()` returns `{ ok: false, error: 'DEEPAI_QUOTA_EXCEEDED' }`**
789
845
  Both routes were refused: `/api/text2img` needs credits and the in-chat image
package/index.js CHANGED
@@ -33,6 +33,7 @@ const ResponseFormatter = require('./src/services/ResponseFormatter');
33
33
  const TriggerDetector = require('./src/services/TriggerDetector');
34
34
  const ImageDescriber = require('./src/services/ImageDescriber');
35
35
  const WebAnswer = require('./src/services/WebAnswer');
36
+ const WebSearch = require('./src/services/WebSearch');
36
37
  const JidParser = require('./src/utils/JidParser');
37
38
  const Media = require('./src/utils/Media');
38
39
  const StreamParser = require('./src/core/StreamParser');
@@ -63,6 +64,7 @@ module.exports.ResponseFormatter = ResponseFormatter;
63
64
  module.exports.TriggerDetector = TriggerDetector;
64
65
  module.exports.ImageDescriber = ImageDescriber;
65
66
  module.exports.WebAnswer = WebAnswer;
67
+ module.exports.WebSearch = WebSearch;
66
68
  module.exports.JidParser = JidParser;
67
69
  module.exports.Media = Media;
68
70
  module.exports.StreamParser = StreamParser;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alexa-ai",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
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": {
@@ -40,6 +40,7 @@
40
40
  "index.js",
41
41
  "src/",
42
42
  "examples/",
43
+ "test/",
43
44
  "README.md",
44
45
  "CHANGELOG.md",
45
46
  "LICENSE"
package/src/AlexaAI.js CHANGED
@@ -17,6 +17,7 @@ const IdentityResolver = require('./services/IdentityResolver');
17
17
  const TriggerDetector = require('./services/TriggerDetector');
18
18
  const ImageDescriber = require('./services/ImageDescriber');
19
19
  const WebAnswer = require('./services/WebAnswer');
20
+ const WebSearch = require('./services/WebSearch');
20
21
  const StreamParser = require('./core/StreamParser');
21
22
  const JidParser = require('./utils/JidParser');
22
23
  const Media = require('./utils/Media');
@@ -69,6 +70,7 @@ class AlexaAI {
69
70
 
70
71
  this.prompts = new PromptBuilder(this.config);
71
72
  this.vision = new ImageDescriber(this.client, this.config);
73
+ this.webSearch = new WebSearch(this.config);
72
74
 
73
75
  // Persona-aware guards (renaming the assistant renames these too).
74
76
  this.identityGuard = new IdentityGuard({
@@ -785,25 +787,35 @@ class AlexaAI {
785
787
  * One-off, stateless web research request — for "search the web for X"
786
788
  * commands that must not touch anyone's memory.
787
789
  *
788
- * By default the answer is long-form: an intro, three to five `*Heading:*`
789
- * sections with numbered `*Headline*: detail` points (about 300–450
790
- * words), followed by one `*Sources:*` block. Sources come from DeepAI's
791
- * web-result packet when the model sends one, and are otherwise lifted
792
- * out of the "Sources:" list the model writes — either way they are
793
- * returned in `sources` and rendered exactly once in `text`.
790
+ * HOW IT WORKS
791
+ * ------------
792
+ * 1. The engine searches the web itself (`WebSearch`: DuckDuckGo, Bing
793
+ * News, Google News, Wikipedia no API key needed) and collects real
794
+ * pages with titles, snippets and dates.
795
+ * 2. Those results go to the model as numbered material. The model writes
796
+ * the report from them and cites result numbers; it is told never to
797
+ * write a URL.
798
+ * 3. The `*Sources:*` block is built from the search results only —
799
+ * cited ones first. A URL the model wrote itself is never shown.
794
800
  *
795
- * Small models sometimes ignore the layout and answer in a sentence. When
796
- * a long-form answer comes back under `minWords`, one follow-up turn asks
797
- * the model to rewrite it in full; the longer of the two replies wins.
801
+ * When the search returns nothing (offline host, all providers blocked)
802
+ * the request falls back to DeepAI's server-side web access, which is
803
+ * unreliable on free models: it may skip the search and invent pages.
804
+ * `grounded` in the result tells you which path answered.
798
805
  *
799
- * The persona still applies (WhatsApp formatting, no renaming of the
800
- * assistant), but third-party vendor names in the research itself are
801
- * kept, and the identity/memory guards run without a user context, so
802
- * this is safe to call with no jid at all.
806
+ * Default output is long-form: an intro, three to five `*Heading:*`
807
+ * sections with numbered `*Headline*: detail` points (about 300–450
808
+ * words), then one `*Sources:*` block. A long-form reply under `minWords`
809
+ * is retried once; the longer reply wins.
803
810
  *
804
811
  * @param {string} query
805
812
  * @param {object} [opts]
806
813
  * @param {'long'|'short'} [opts.detail='long'] `short` = 2–4 sentences
814
+ * @param {Array<{title?:string,url:string,description?:string,date?:string}>} [opts.results]
815
+ * results from the host application's own search API (skips the built-in search)
816
+ * @param {boolean} [opts.search=true] `false` = skip the built-in search, use DeepAI's
817
+ * @param {string[]} [opts.providers] subset of WebSearch.PROVIDERS for this call
818
+ * @param {number} [opts.maxResults] results handed to the model (default config.webSearchResults)
807
819
  * @param {number} [opts.minWords=150] long form only: retry once below this (0 disables)
808
820
  * @param {boolean} [opts.includeSources=true] append the *Sources:* block to `text`
809
821
  * @param {number} [opts.maxSources=5] sources listed in `text` (the array is not capped)
@@ -812,12 +824,12 @@ class AlexaAI {
812
824
  * @param {string} [opts.model]
813
825
  * @param {string} [opts.userName]
814
826
  * @param {AbortSignal} [opts.signal]
815
- * @returns {Promise<{ok:boolean, text:string, answer:string, sources:Array<{title:string|null,url:string|null,description:string|null}>, words:number, attempts:number, model?:string|null, error?:string, message?:string}>}
827
+ * @returns {Promise<{ok:boolean, text:string, answer:string, sources:Array<{title:string|null,url:string|null,description:string|null,date?:string|null,provider?:string,cited?:boolean}>, grounded:boolean, providers:string[], words:number, attempts:number, via:'model'|'digest', model?:string|null, error?:string, message?:string}>}
816
828
  */
817
829
  async searchWeb(query, opts = {}) {
818
830
  const question = String(query ?? '').trim();
819
831
  const failure = (error, message, extra = {}) => ({
820
- ok: false, text: '', answer: '', sources: [], words: 0, attempts: 0, error, message, ...extra,
832
+ ok: false, text: '', answer: '', sources: [], grounded: false, providers: [], words: 0, attempts: 0, via: 'model', error, message, ...extra,
821
833
  });
822
834
  if (!question) return failure('VALIDATION_ERROR', 'searchWeb(): query is required');
823
835
 
@@ -826,18 +838,48 @@ class AlexaAI {
826
838
  const maxSources = Number.isFinite(opts.maxSources) ? opts.maxSources : 5;
827
839
  const minWords = detail === 'long' && Number.isFinite(opts.minWords) ? Math.max(0, opts.minWords) : detail === 'long' ? 150 : 0;
828
840
 
829
- const request = { search: true, webAccess: true, model: opts.model, signal: opts.signal };
841
+ // ---- 1. search ------------------------------------------------------
842
+ let results = WebSearch.normalise(opts.results, 'caller');
843
+ let providers = results.length ? ['caller'] : [];
844
+ if (!results.length && opts.search !== false) {
845
+ const found = await this.webSearch.search(question, {
846
+ providers: opts.providers,
847
+ maxResults: opts.maxResults,
848
+ signal: opts.signal,
849
+ });
850
+ results = found.results;
851
+ providers = found.providers;
852
+ if (!results.length && this.config.debug) {
853
+ this.log.debug?.(
854
+ `[AlexaAI] searchWeb: no web results for "${question}" ` +
855
+ (found.errors.length ? `(${found.errors.map((e) => `${e.provider}: ${e.message}`).join('; ')})` : '') +
856
+ ' — falling back to DeepAI web access'
857
+ );
858
+ }
859
+ }
860
+ const grounded = results.length > 0;
861
+
862
+ // ---- 2. ask the model ---------------------------------------------
863
+ const request = { search: !grounded, webAccess: !grounded, model: opts.model, signal: opts.signal };
830
864
  const messages = this.prompts.build({
831
- message: WebAnswer.prompt(question, { detail, language: opts.language, instructions: opts.instructions }),
865
+ message: WebAnswer.prompt(question, {
866
+ detail,
867
+ results: grounded ? results : null,
868
+ language: opts.language,
869
+ instructions: opts.instructions,
870
+ }),
832
871
  memories: {},
833
872
  history: [],
834
873
  userName: opts.userName || null,
835
874
  });
836
875
 
876
+ let reply;
877
+ let result;
878
+ let attempts = 0;
837
879
  try {
838
- let reply = await this.client.chatDetailed(messages, request);
839
- let result = this._webAnswerFrom(reply);
840
- let attempts = 1;
880
+ reply = await this.client.chatDetailed(messages, request);
881
+ result = this._webAnswerFrom(reply, results);
882
+ attempts = 1;
841
883
 
842
884
  if (minWords && result.words < minWords && result.answer) {
843
885
  // Second chance: keep the first reply in the transcript so the
@@ -845,12 +887,12 @@ class AlexaAI {
845
887
  const retryMessages = [
846
888
  ...messages,
847
889
  { role: 'assistant', content: reply.text },
848
- { role: 'user', content: WebAnswer.expandPrompt(question, result.words) },
890
+ { role: 'user', content: WebAnswer.expandPrompt(question, result.words, { grounded }) },
849
891
  ];
850
892
  attempts = 2;
851
893
  try {
852
894
  const second = await this.client.chatDetailed(retryMessages, request);
853
- const candidate = this._webAnswerFrom(second, result.sources);
895
+ const candidate = this._webAnswerFrom(second, results, result.sources);
854
896
  if (candidate.words > result.words) {
855
897
  reply = second;
856
898
  result = candidate;
@@ -859,34 +901,75 @@ class AlexaAI {
859
901
  this.log.debug?.(`[AlexaAI] searchWeb expansion failed, keeping first reply: ${err.message}`);
860
902
  }
861
903
  }
862
-
863
- const { answer, sources, words } = result;
864
- if (!answer && !sources.length) return failure('DEEPAI_EMPTY', 'DeepAI returned no answer', { attempts });
865
-
866
- const text = WebAnswer.render(answer, sources, { includeSources: includeSources || !answer, maxSources });
867
- return { ok: true, text, answer, sources, words, attempts, model: reply.model || null };
868
904
  } catch (err) {
869
905
  this.log.warn?.(`[AlexaAI] searchWeb failed: ${err.message}`);
870
- return failure(err.code || 'SEARCH_FAILED', err.message);
906
+ if (!grounded) return failure(err.code || 'SEARCH_FAILED', err.message, { attempts });
907
+ // The search worked even though the model did not: show the results.
908
+ const sources = AlexaAI._groundedSources(results, []);
909
+ return {
910
+ ok: true,
911
+ text: WebAnswer.render(WebAnswer.digest(results), sources, { includeSources, maxSources }),
912
+ answer: WebAnswer.digest(results),
913
+ sources,
914
+ grounded,
915
+ providers,
916
+ words: 0,
917
+ attempts,
918
+ via: 'digest',
919
+ model: null,
920
+ error: err.code || 'SEARCH_FAILED',
921
+ message: err.message,
922
+ };
871
923
  }
924
+
925
+ // ---- 3. assemble ----------------------------------------------------
926
+ const { answer, sources, words } = result;
927
+ if (!answer && !sources.length) return failure('DEEPAI_EMPTY', 'DeepAI returned no answer', { attempts, grounded, providers });
928
+
929
+ const text = WebAnswer.render(answer, sources, { includeSources: includeSources || !answer, maxSources });
930
+ return { ok: true, text, answer, sources, grounded, providers, words, attempts, via: 'model', model: reply.model || null };
872
931
  }
873
932
 
874
933
  /**
875
934
  * @private Turn one chat reply into `{ answer, sources, words }`.
876
- * Structured packet sources come first (they carry descriptions), then
877
- * whatever the model listed itself; duplicates collapse by URL.
935
+ *
936
+ * Grounded (we searched): citation markers `[n]` are removed from the
937
+ * prose and decide the order of the sources; any URL the model wrote is
938
+ * discarded. Ungrounded: DeepAI's web-results packet comes first (it
939
+ * carries descriptions), then whatever the model listed; duplicates
940
+ * collapse by URL.
878
941
  */
879
- _webAnswerFrom(reply, carried = []) {
942
+ _webAnswerFrom(reply, results = [], carried = []) {
880
943
  let formatted = ResponseFormatter.format(MemoryExtractor.strip(reply.text));
881
944
  // Keep third-party vendor names: this is research output, not the
882
945
  // assistant introducing herself. WebAnswer removes the sentences in
883
946
  // which the model talks about *itself*.
884
947
  formatted = this.identityGuard.sanitise(formatted, false, { vendors: false });
885
948
  const parsed = WebAnswer.parse(formatted);
949
+
950
+ if (results.length) {
951
+ const { text, cited } = WebAnswer.extractCitations(WebAnswer.stripUrls(parsed.text, results), results.length);
952
+ return { answer: text, sources: AlexaAI._groundedSources(results, cited), words: WebAnswer.wordCount(text) };
953
+ }
886
954
  const sources = WebAnswer.mergeSources(AlexaAI._sources(reply.webResults), parsed.sources, carried);
887
955
  return { answer: parsed.text, sources, words: WebAnswer.wordCount(parsed.text) };
888
956
  }
889
957
 
958
+ /** @private search results as sources: cited ones first, in citation order. */
959
+ static _groundedSources(results, cited) {
960
+ const order = [...cited.map((n) => n - 1), ...results.map((_, i) => i).filter((i) => !cited.includes(i + 1))];
961
+ return order
962
+ .filter((i) => results[i])
963
+ .map((i) => ({
964
+ title: results[i].title || null,
965
+ url: results[i].url,
966
+ description: results[i].description || null,
967
+ date: results[i].date || null,
968
+ provider: results[i].provider || null,
969
+ cited: cited.includes(i + 1),
970
+ }));
971
+ }
972
+
890
973
  /** Is DeepAI reachable and is the key still good? */
891
974
  async deepaiHealth() {
892
975
  const started = Date.now();
@@ -107,6 +107,15 @@ class Config {
107
107
  ]);
108
108
  this.imageModel = opts.imageModel || 'text2img';
109
109
 
110
+ // ---- Engine web search (searchWeb) ----------------------------------
111
+ // The engine searches first and hands real results to the model, so
112
+ // every URL the bot shows comes from a search, never from the model.
113
+ this.webSearch = opts.webSearch !== false;
114
+ this.webSearchProviders = Config._list(opts.webSearchProviders, ['bing', 'bing-news', 'wikipedia', 'google-news', 'duckduckgo']);
115
+ this.webSearchTimeout = Config._int(opts.webSearchTimeout, 8000, 500, 60000);
116
+ this.webSearchResults = Config._int(opts.webSearchResults, 8, 1, 30);
117
+ this.webSearchProvider = typeof opts.webSearchProvider === 'function' ? opts.webSearchProvider : null;
118
+
110
119
  // ---- Chat request feature flags (mirrors the deepai.org client) -----
111
120
  this.enabledTools = Config._list(opts.enabledTools, ['image_generator', 'image_editor']);
112
121
  this.toolActivitySupport = opts.toolActivitySupport !== false;