chat-agent-toolkit 1.2.425 → 1.2.427

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chat-agent-toolkit",
3
- "version": "1.2.425",
3
+ "version": "1.2.427",
4
4
  "description": "Multi-provider AI agent toolkit: generate language responses, search the web, extract content, and manage memory across 10+ LLM providers.",
5
5
  "author": "vtempest <grokthiscontact@gmail.com>",
6
6
  "license": "rights.institute/PROSPER",
@@ -94,11 +94,10 @@
94
94
  "@mastra/memory": "^1.28.3",
95
95
  "ai": "^7.0.94",
96
96
  "drizzle-orm": "^0.45.2",
97
- "prismjs": "^1.30.0",
98
97
  "html-entities": "^2.6.0",
99
98
  "manage-storage": "^0.0.47",
100
99
  "marked": "^18.0.12",
101
- "qwksearch-api-client": "^1.0.97",
100
+ "qwksearch-api-client": "^1.0.237",
102
101
  "write-language": "^0.1.153",
103
102
  "zod": "^4.5.4"
104
103
  },
@@ -20,7 +20,9 @@ export interface DrainableEmitter extends EventEmitter {
20
20
  export interface MetaSearchAgentType {
21
21
  searchAndAnswer: (message: string, history: ChatTurnMessage[], llm: LanguageModel, optimizationMode: "speed" | "balanced" | "quality", fileIds: string[], systemInstructions: string, category?: string, sourceExtractionEnabled?: boolean, thinkingTimeLimit?: number,
22
22
  /** User-authored replacement for the focus mode's query-expansion prompt. */
23
- queryExpansionPrompt?: string) => Promise<DrainableEmitter>;
23
+ queryExpansionPrompt?: string,
24
+ /** Rephrase the message with the LLM before searching. Off by default. */
25
+ queryExpansionEnabled?: boolean) => Promise<DrainableEmitter>;
24
26
  }
25
27
  /** Emitted on the EventEmitter data channel to report live search progress. */
26
28
  export interface SearchingEvent {
@@ -41,6 +41,11 @@ export interface MetaSearchAgentType {
41
41
  thinkingTimeLimit?: number,
42
42
  /** User-authored replacement for the focus mode's query-expansion prompt. */
43
43
  queryExpansionPrompt?: string,
44
+ /**
45
+ * Rephrase the message with the LLM before searching. Off by default: the
46
+ * message is searched as typed, which saves a model round trip.
47
+ */
48
+ queryExpansionEnabled?: boolean,
44
49
  ) => Promise<DrainableEmitter>;
45
50
  }
46
51
 
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * @fileoverview Orchestrator for complex research queries.
3
- * Manages query expansion, web search execution, and result synthesis using
4
- * LLMs through the Vercel AI SDK (generateText/streamText).
3
+ * Runs the web search, streams the result list to the client, and synthesizes
4
+ * an answer from the result titles and snippets. LLM query expansion before
5
+ * the search is opt-in.
5
6
  */
6
7
  import { type LanguageModel } from "ai";
7
8
  import type { Config, ChatTurnMessage, DrainableEmitter, MetaSearchAgentType } from "./meta-search-types";
@@ -10,16 +11,25 @@ declare class MetaSearchAgent implements MetaSearchAgentType {
10
11
  private config;
11
12
  constructor(config: Config);
12
13
  /**
13
- * Rephrases the user's query into a standalone search question (and any
14
- * URLs to summarize), runs the web search, and returns the documents to
15
- * use as answer context.
14
+ * Asks the LLM to rephrase the message into a standalone search question,
15
+ * and to pick out any URLs to summarize. Only runs when query expansion is on.
16
+ */
17
+ private expandQuery;
18
+ /**
19
+ * Runs the web search and returns its results as answer-context documents:
20
+ * each one the result's title and search snippet, nothing fetched yet.
16
21
  */
17
22
  private retrieveSearchDocs;
23
+ /**
24
+ * Replaces the search snippets of the top web results with text fetched
25
+ * from their pages, after the result list has been sent to the client.
26
+ */
27
+ private extractTopSources;
18
28
  /**
19
29
  * Runs the full search-and-answer pipeline, emitting "sources",
20
30
  * "response", "searching", "end", and "error" events on the emitter.
21
31
  */
22
32
  private runPipeline;
23
- searchAndAnswer(message: string, history: ChatTurnMessage[], llm: LanguageModel, optimizationMode: "speed" | "balanced" | "quality", fileIds: string[], systemInstructions: string, category?: string, sourceExtractionEnabled?: boolean, thinkingTimeLimit?: number, queryExpansionPrompt?: string): Promise<DrainableEmitter>;
33
+ searchAndAnswer(message: string, history: ChatTurnMessage[], llm: LanguageModel, optimizationMode: "speed" | "balanced" | "quality", fileIds: string[], systemInstructions: string, category?: string, sourceExtractionEnabled?: boolean, thinkingTimeLimit?: number, queryExpansionPrompt?: string, queryExpansionEnabled?: boolean): Promise<DrainableEmitter>;
24
34
  }
25
35
  export default MetaSearchAgent;
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * @fileoverview Orchestrator for complex research queries.
3
- * Manages query expansion, web search execution, and result synthesis using
4
- * LLMs through the Vercel AI SDK (generateText/streamText).
3
+ * Runs the web search, streams the result list to the client, and synthesizes
4
+ * an answer from the result titles and snippets using LLMs through the Vercel
5
+ * AI SDK (generateText/streamText). LLM query expansion before the search is
6
+ * opt-in: by default the user's message is searched as typed, so the first
7
+ * model call is the answer itself.
5
8
  */
6
9
  import { generateText, streamText, type LanguageModel } from "ai";
7
10
  import { LineOutputParser, LineListOutputParser } from "../../utils/outputParser";
@@ -82,6 +85,30 @@ const interpolatePrompt = (
82
85
  );
83
86
  };
84
87
 
88
+ /** Matches the http(s) URLs a user pastes into a message. */
89
+ const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/gi;
90
+
91
+ /**
92
+ * Keeps a search query to something a search engine can use. A rephraser that
93
+ * answered instead of rephrasing, or a pasted essay, becomes its first
94
+ * sentence — or, failing that, the start of the user's own message.
95
+ */
96
+ const toSearchQuery = (question: string, fallback: string): string => {
97
+ question = question.replace(/<think>.*?<\/think>/g, "").trim();
98
+ if (question.length === 0) return "latest information";
99
+
100
+ if (question.length > 500 || question.split(/[.!?]\s+/).length > 5) {
101
+ console.warn(`[MetaSearchAgent] Query is too long (${question.length} chars), truncating or using fallback`);
102
+ const firstSentence = question.split(/[.!?]\s+/)[0].trim();
103
+ if (firstSentence.length > 0 && firstSentence.length < 200) {
104
+ return firstSentence;
105
+ }
106
+ return fallback.slice(0, 200);
107
+ }
108
+
109
+ return question;
110
+ };
111
+
85
112
  class MetaSearchAgent implements MetaSearchAgentType {
86
113
  private config: Config;
87
114
 
@@ -90,20 +117,16 @@ class MetaSearchAgent implements MetaSearchAgentType {
90
117
  }
91
118
 
92
119
  /**
93
- * Rephrases the user's query into a standalone search question (and any
94
- * URLs to summarize), runs the web search, and returns the documents to
95
- * use as answer context.
120
+ * Asks the LLM to rephrase the message into a standalone search question,
121
+ * and to pick out any URLs to summarize. This is a full model round trip
122
+ * before the search can start, so it only runs when query expansion is on.
96
123
  */
97
- private async retrieveSearchDocs(
124
+ private async expandQuery(
98
125
  llm: LanguageModel,
99
126
  chatHistory: string,
100
127
  query: string,
101
- category: string = "general",
102
- sourceExtractionEnabled = false,
103
- thinkingTimeLimit = 0,
104
- emitter?: EventEmitter,
105
128
  queryGeneratorPromptOverride?: string,
106
- ): Promise<{ query: string; docs: Document[] }> {
129
+ ): Promise<{ question: string; links: string[] }> {
107
130
  // A user-authored query-expansion prompt (Settings → Search Settings)
108
131
  // replaces the focus mode's built-in one. Blank or whitespace-only text
109
132
  // means "use the built-in prompt".
@@ -146,37 +169,55 @@ class MetaSearchAgent implements MetaSearchAgentType {
146
169
  question = "latest information";
147
170
  }
148
171
 
172
+ return { question, links };
173
+ }
174
+
175
+ /**
176
+ * Runs the web search and returns its results as answer-context documents:
177
+ * each one the result's title and search snippet, nothing fetched yet.
178
+ *
179
+ * The message is searched as typed unless `queryExpansionEnabled` is set,
180
+ * in which case the LLM rephrases it first (see {@link expandQuery}).
181
+ * `fromLinks` marks documents built from URLs in the message rather than a
182
+ * search, which already carry their pages' text.
183
+ */
184
+ private async retrieveSearchDocs(
185
+ llm: LanguageModel,
186
+ chatHistory: string,
187
+ query: string,
188
+ category: string = "general",
189
+ emitter?: EventEmitter,
190
+ queryGeneratorPromptOverride?: string,
191
+ queryExpansionEnabled = false,
192
+ ): Promise<{ query: string; docs: Document[]; fromLinks?: boolean }> {
193
+ let question: string;
194
+ let links: string[];
195
+
196
+ if (queryExpansionEnabled) {
197
+ ({ question, links } = await this.expandQuery(
198
+ llm,
199
+ chatHistory,
200
+ query,
201
+ queryGeneratorPromptOverride,
202
+ ));
203
+ } else {
204
+ links = query.match(URL_PATTERN) ?? [];
205
+ question =
206
+ links.length > 0 && this.config.getDocumentsFromLinks
207
+ ? query.replace(URL_PATTERN, " ").replace(/\s+/g, " ").trim()
208
+ : query;
209
+ }
210
+
149
211
  if (links.length > 0 && this.config.getDocumentsFromLinks) {
150
212
  if (question.length === 0) question = "summarize";
151
213
 
152
214
  const linkDocs = await this.config.getDocumentsFromLinks({ links });
153
215
  const docs = await groupAndSummarizeDocs(llm, linkDocs, question);
154
216
 
155
- return { query: question, docs };
156
- }
157
-
158
- question = question.replace(/<think>.*?<\/think>/g, "");
159
- if (!question || question.trim().length === 0) {
160
- question = "latest information";
217
+ return { query: question, docs, fromLinks: true };
161
218
  }
162
219
 
163
- // Validate and sanitize the query before sending to search APIs
164
- // If the LLM returned a long response instead of a concise query, extract the first sentence
165
- // or use the original user query as fallback
166
- question = question.trim();
167
- if (question.length > 500 || question.split(/[.!?]\s+/).length > 5) {
168
- // Query is too long or contains too many sentences - likely the LLM returned a full response
169
- console.warn(`[MetaSearchAgent] Query is too long (${question.length} chars), truncating or using fallback`);
170
-
171
- // Try to extract first sentence as the query
172
- const firstSentence = question.split(/[.!?]\s+/)[0].trim();
173
- if (firstSentence.length > 0 && firstSentence.length < 200) {
174
- question = firstSentence;
175
- } else {
176
- // Fallback to original user query
177
- question = query.slice(0, 200);
178
- }
179
- }
220
+ question = toSearchQuery(question, query);
180
221
 
181
222
  // Emit "searching" progress event so the client can show live status
182
223
  const categoryLabel = this.config.activeEngines.length > 0
@@ -288,6 +329,23 @@ class MetaSearchAgent implements MetaSearchAgentType {
288
329
 
289
330
  emitSearching("done", question);
290
331
 
332
+ return { query: question, docs: documents };
333
+ }
334
+
335
+ /**
336
+ * Replaces the search snippets of the top web results with text fetched
337
+ * from their pages, in place, when the user has turned source extraction on
338
+ * or set a thinking-time budget. Uploaded files are left alone.
339
+ *
340
+ * This runs after the result list has been sent to the client, so the
341
+ * sources appear while the pages are still loading.
342
+ */
343
+ private async extractTopSources(
344
+ documents: Document[],
345
+ sourceExtractionEnabled: boolean,
346
+ thinkingTimeLimit: number,
347
+ emitter: EventEmitter,
348
+ ): Promise<void> {
291
349
  // Determine extraction budget from thinkingTimeLimit (seconds).
292
350
  // thinkingTimeLimit === 0 means unlimited; use server config.
293
351
  let scrapeCount: number;
@@ -307,10 +365,18 @@ class MetaSearchAgent implements MetaSearchAgentType {
307
365
  }
308
366
 
309
367
  if (scrapeCount > 0) {
310
- const docsToScrape = documents.slice(0, scrapeCount);
311
- emitSearching("running", `Extracting top ${docsToScrape.length} sources`, "extract");
368
+ const emitExtracting = (status: SearchingEvent["status"], query: string) => {
369
+ emitter.emit("data", JSON.stringify({
370
+ type: "searching",
371
+ data: { query, category: "extract", status } satisfies SearchingEvent,
372
+ }));
373
+ };
374
+ const docsToScrape = documents
375
+ .filter((doc) => doc.metadata?.url && doc.metadata.url !== "File")
376
+ .slice(0, scrapeCount);
377
+ emitExtracting("running", `Extracting top ${docsToScrape.length} sources`);
312
378
 
313
- const extractionTasks = this.config.scrapeURL ? docsToScrape.map(async (doc, idx) => {
379
+ const extractionTasks = this.config.scrapeURL ? docsToScrape.map(async (doc) => {
314
380
  const url = doc.metadata?.url;
315
381
  if (!url || !this.config.scrapeURL) return;
316
382
  try {
@@ -325,7 +391,7 @@ class MetaSearchAgent implements MetaSearchAgentType {
325
391
  .trim()
326
392
  .slice(0, 5000);
327
393
  if (text.length > 100) {
328
- documents[idx].pageContent = text;
394
+ doc.pageContent = text;
329
395
  }
330
396
  }
331
397
  } catch {
@@ -334,10 +400,8 @@ class MetaSearchAgent implements MetaSearchAgentType {
334
400
  }) : [];
335
401
 
336
402
  await Promise.allSettled(extractionTasks);
337
- emitSearching("done", `Extracting top ${docsToScrape.length} sources`, "extract");
403
+ emitExtracting("done", `Extracting top ${docsToScrape.length} sources`);
338
404
  }
339
-
340
- return { query: question, docs: documents };
341
405
  }
342
406
 
343
407
  /**
@@ -356,10 +420,28 @@ class MetaSearchAgent implements MetaSearchAgentType {
356
420
  sourceExtractionEnabled: boolean,
357
421
  thinkingTimeLimit: number,
358
422
  queryExpansionPrompt?: string,
423
+ queryExpansionEnabled = false,
359
424
  ): Promise<void> {
360
425
  try {
426
+ const r2Credentials: R2CredentialsInput | undefined = process.env.R2_ACCOUNT_ID
427
+ ? {
428
+ accountId: process.env.R2_ACCOUNT_ID,
429
+ accessKeyId: process.env.R2_ACCESS_KEY_ID || "",
430
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || "",
431
+ bucket: process.env.R2_UPLOADS_BUCKET || "qwksearch-uploads",
432
+ }
433
+ : undefined;
434
+
435
+ // Resolved once and shared below: the answer context needs the text and
436
+ // the message needs the images, and fetching each attachment twice is how
437
+ // a handful of large uploads used to reach the isolate's memory ceiling.
438
+ // Started before the search so the two waits overlap; loadUploads never
439
+ // rejects (a file it cannot resolve is dropped).
440
+ const uploadsPromise = loadUploads(fileIds, r2Credentials);
441
+
361
442
  let docs: Document[] | null = null;
362
443
  let query = message;
444
+ let fromLinks = false;
363
445
 
364
446
  if (this.config.searchWeb) {
365
447
  const result = await this.retrieveSearchDocs(
@@ -367,28 +449,16 @@ class MetaSearchAgent implements MetaSearchAgentType {
367
449
  formatChatHistoryAsString(history),
368
450
  message,
369
451
  category,
370
- sourceExtractionEnabled,
371
- thinkingTimeLimit,
372
452
  emitter,
373
453
  queryExpansionPrompt,
454
+ queryExpansionEnabled,
374
455
  );
375
456
  query = result.query;
376
457
  docs = result.docs;
458
+ fromLinks = result.fromLinks ?? false;
377
459
  }
378
460
 
379
- const r2Credentials: R2CredentialsInput | undefined = process.env.R2_ACCOUNT_ID
380
- ? {
381
- accountId: process.env.R2_ACCOUNT_ID,
382
- accessKeyId: process.env.R2_ACCESS_KEY_ID || "",
383
- secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || "",
384
- bucket: process.env.R2_UPLOADS_BUCKET || "qwksearch-uploads",
385
- }
386
- : undefined;
387
-
388
- // Resolved once and shared below: the answer context needs the text and
389
- // the message needs the images, and fetching each attachment twice is how
390
- // a handful of large uploads used to reach the isolate's memory ceiling.
391
- const uploads = await loadUploads(fileIds, r2Credentials);
461
+ const uploads = await uploadsPromise;
392
462
 
393
463
  const sortedDocs = await rerankDocs(
394
464
  query,
@@ -403,6 +473,18 @@ class MetaSearchAgent implements MetaSearchAgentType {
403
473
  console.log("[MetaSearchAgent] emitting sources:", sources.length);
404
474
  emitter.emit("data", JSON.stringify({ type: "sources", data: sources }));
405
475
 
476
+ // The result list is on screen; only now spend time fetching pages, and
477
+ // only when the user asked for it. By default the answer is written from
478
+ // the result titles and snippets alone.
479
+ if (this.config.searchWeb && !fromLinks) {
480
+ await this.extractTopSources(
481
+ sortedDocs,
482
+ sourceExtractionEnabled,
483
+ thinkingTimeLimit,
484
+ emitter,
485
+ );
486
+ }
487
+
406
488
  const systemPrompt = interpolatePrompt(this.config.responsePrompt, {
407
489
  systemInstructions,
408
490
  context: processDocs(sortedDocs),
@@ -490,6 +572,7 @@ class MetaSearchAgent implements MetaSearchAgentType {
490
572
  sourceExtractionEnabled = false,
491
573
  thinkingTimeLimit = 0,
492
574
  queryExpansionPrompt?: string,
575
+ queryExpansionEnabled = false,
493
576
  ) {
494
577
  const emitter = new EventEmitter();
495
578
 
@@ -508,6 +591,7 @@ class MetaSearchAgent implements MetaSearchAgentType {
508
591
  sourceExtractionEnabled,
509
592
  thinkingTimeLimit,
510
593
  queryExpansionPrompt,
594
+ queryExpansionEnabled,
511
595
  );
512
596
  }, 0);
513
597