mioku-plugin-chat 2.3.1 → 2.4.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/core/tools.ts CHANGED
@@ -1,97 +1,24 @@
1
1
  import { logger } from "mioki";
2
2
  import type { AITool } from "mioku";
3
- import type { ChatMessage, ToolContext } from "../types";
3
+ import type { ToolContext } from "../types";
4
4
  import type { SkillSessionManager } from "../manage/skill-session";
5
- import type { MemoryUserHistoryChunk } from "../humanize/memory";
6
- import { MemoryRetrieval } from "../humanize";
7
- import { searchWebWithSearxng } from "./web/searxng";
8
- import { readWebPage } from "./web/web-reader";
9
- import { TOOL_RESULT_FOLLOWUP_KEY } from "mioku";
5
+ import { filterAllowedExternalSkills } from "./external-skills";
6
+ import { createInfoTools } from "./tools/info";
7
+ import { createLoadSkillTool } from "./tools/load-skill";
10
8
  import {
11
- filterAllowedExternalSkills,
12
- getSkillRequiredPermissionRole,
13
- isExternalSkillAllowed,
14
- hasSkillPermission,
15
- } from "./external-skills";
16
- import {
17
- isFeatureEnabled,
18
- isBuiltinFeature,
19
- getFeatureMeta,
20
- type FeatureName,
21
- } from "./feature-prompts";
22
- import {
23
- buildWebSearchFeatureSection,
24
- buildWebReadFeatureSection,
25
- buildRecallMemoryFeatureSection,
26
- } from "./prompt";
27
-
28
- const DEFAULT_GROUP_RECALL_LIMIT = 800;
29
- const DEFAULT_USER_HISTORY_LIMIT = 100;
30
-
31
- type ConstraintStrength = "low" | "medium" | "high";
32
-
33
- function normalizeConstraintStrength(value: unknown): ConstraintStrength {
34
- if (value === "low" || value === "high" || value === "medium") {
35
- return value;
36
- }
37
- return "medium";
38
- }
39
-
40
- interface CreateToolsResult {
41
- tools: AITool[];
42
- }
43
-
44
- async function createImageFollowupResult(
45
- imageUrl: string,
46
- text: string,
47
- note: string,
48
- ): Promise<Record<string, any>> {
49
- let imageUrls = [imageUrl];
50
- let gifFrameNote = "";
51
-
52
- try {
53
- const { isGifUrl, extractGifFrames } =
54
- await import("./media/gif-extractor");
55
- if (await isGifUrl(imageUrl)) {
56
- const result = await extractGifFrames(imageUrl);
57
- if (result && result.frames.length > 0) {
58
- imageUrls = result.frames;
59
- gifFrameNote = ` The original image is an animated GIF; ${result.frames.length} extracted frame(s) are attached in order.`;
60
- } else {
61
- logger.warn(
62
- "[view_image] Failed to extract GIF frames, attaching original image",
63
- );
64
- }
65
- }
66
- } catch (err) {
67
- logger.warn(`[view_image] Failed to prepare image attachment: ${err}`);
68
- }
69
-
70
- return {
71
- success: true,
72
- image_attached: true,
73
- note: `${note}${gifFrameNote}`,
74
- [TOOL_RESULT_FOLLOWUP_KEY]: {
75
- text: `${text}${gifFrameNote}`,
76
- images: imageUrls.map((url) => ({ url, detail: "auto" })),
77
- },
78
- };
79
- }
9
+ createWebSearchTool,
10
+ createWebReadPageTool,
11
+ createRecallMemoryTool,
12
+ } from "./tools/web";
80
13
 
81
- /**
82
- * Create all tools
83
- */
84
14
  export function createTools(
85
15
  toolCtx: ToolContext,
86
16
  skillManager: SkillSessionManager,
87
- ): CreateToolsResult {
17
+ ): { tools: AITool[] } {
88
18
  const tools: AITool[] = [];
89
19
 
90
- // === Info query tools (always available) ===
91
20
  tools.push(...createInfoTools(toolCtx));
92
21
 
93
- // === Meta tools (conditional) ===
94
- // load_skill tool is always available when external skills are enabled
95
22
  if (toolCtx.config.enableExternalSkills) {
96
23
  const allSkills = toolCtx.aiService.getAllSkills?.();
97
24
  const allowedSkills = allSkills
@@ -107,11 +34,7 @@ export function createTools(
107
34
  }
108
35
  }
109
36
 
110
- // === Optional feature tools (dynamically registered based on loaded features) ===
111
- const activeFeatureTools = skillManager.getActiveFeatureTools(
112
- toolCtx.sessionId,
113
- );
114
-
37
+ const activeFeatureTools = skillManager.getActiveFeatureTools(toolCtx.sessionId);
115
38
  if (activeFeatureTools.includes("web_search")) {
116
39
  tools.push(createWebSearchTool(toolCtx));
117
40
  }
@@ -125,738 +48,4 @@ export function createTools(
125
48
  return { tools };
126
49
  }
127
50
 
128
- // ==================== Info Tools ====================
129
-
130
- function createInfoTools(toolCtx: ToolContext): AITool[] {
131
- const tools: AITool[] = [];
132
-
133
- if (toolCtx.groupId) {
134
- tools.push({
135
- name: "get_group_member_info",
136
- description:
137
- "Get detailed info about a group member,including gender, age, QQ rating, group level, group title, etc",
138
- parameters: {
139
- type: "object",
140
- properties: {
141
- user_id: {
142
- type: "number",
143
- description: "QQ number of the member",
144
- },
145
- },
146
- required: ["user_id"],
147
- },
148
- handler: async (args) => {
149
- try {
150
- const info = await toolCtx.ctx
151
- .pickBot(toolCtx.event.self_id)
152
- .getGroupMemberInfo(toolCtx.groupId!, args.user_id);
153
- return {
154
- nickname: info.nickname,
155
- card: info.card,
156
- sex: info.sex,
157
- age: info.age,
158
- area: info.area,
159
- level: info.level,
160
- qq_level: info.qq_level,
161
- title: info.title,
162
- };
163
- } catch (err) {
164
- return { error: `Failed to get member info: ${err}` };
165
- }
166
- },
167
- });
168
-
169
- tools.push({
170
- name: "get_group_member_list",
171
- description: "Get the list of group members (returns name and role only)",
172
- parameters: {
173
- type: "object",
174
- properties: {},
175
- },
176
- handler: async () => {
177
- try {
178
- const list = await toolCtx.ctx
179
- .pickBot(toolCtx.event.self_id)
180
- .getGroupMemberList(toolCtx.groupId!);
181
- const members = (list as any[]).map((m) => ({
182
- user_id: m.user_id,
183
- nickname: m.card || m.nickname,
184
- role: m.role,
185
- }));
186
- return { members: members.slice(0, 50), total: members.length };
187
- } catch (err) {
188
- return { error: `Failed to get member list: ${err}` };
189
- }
190
- },
191
- });
192
- }
193
-
194
- // 查看图片工具
195
- {
196
- tools.push({
197
- name: "view_image",
198
- description:
199
- "View and analyze an image by its message ID. Use this when you need to see what's in an image to answer the user's question. The image will be analyzed and described to you.",
200
- parameters: {
201
- type: "object",
202
- properties: {
203
- message_id: {
204
- type: "number",
205
- description:
206
- "The message ID (message_id) of the image. You can get this from the original message that contains the image.",
207
- },
208
- },
209
- required: ["message_id"],
210
- },
211
- handler: async (args) => {
212
- try {
213
- // 通过 message_id 获取消息中的图片
214
- const { getImageUrlByMessageId } = await import("./multimodal");
215
- const imageUrl = await getImageUrlByMessageId(
216
- toolCtx.ctx,
217
- args.message_id,
218
- toolCtx.event,
219
- );
220
-
221
- if (!imageUrl) {
222
- return { error: "Image not found in the specified message" };
223
- }
224
-
225
- if (toolCtx.config.isMultimodal) {
226
- return await createImageFollowupResult(
227
- imageUrl,
228
- `The image from message #${args.message_id} is attached. Inspect it directly and answer the user's question from the visual content.`,
229
- "The image has been attached to the next main model request. Inspect it directly instead of relying on a worker-model description.",
230
- );
231
- }
232
-
233
- // 主模型不支持视觉时,使用多模态工作模型描述图片。
234
- const ai = toolCtx.aiService.getDefault();
235
- if (!ai) {
236
- return { error: "AI instance not available" };
237
- }
238
- const { describeImage } = await import("./multimodal");
239
-
240
- const result = await describeImage(
241
- ai,
242
- imageUrl,
243
- toolCtx.config.multimodalWorkingModel,
244
- toolCtx.event?.raw_message || undefined,
245
- );
246
-
247
- if (!result.success) {
248
- return { error: result.error || "Failed to analyze image" };
249
- }
250
-
251
- return {
252
- success: true,
253
- description: result.description,
254
- note: "The image has been analyzed. Use the description above to answer the user's question.",
255
- };
256
- } catch (err) {
257
- return { error: `Failed to analyze image: ${err}` };
258
- }
259
- },
260
- });
261
-
262
- tools.push({
263
- name: "view_member_avatar",
264
- description:
265
- "View and analyze a group member's QQ avatar. Use this when you need to see what someone's avatar looks like. The avatar will be analyzed and described to you.",
266
- parameters: {
267
- type: "object",
268
- properties: {
269
- user_id: {
270
- type: "number",
271
- description:
272
- "QQ number of the member whose avatar you want to view",
273
- },
274
- },
275
- required: ["user_id"],
276
- },
277
- handler: async (args) => {
278
- try {
279
- const avatarUrl = `https://q1.qlogo.cn/g?b=qq&nk=${args.user_id}&s=640`;
280
-
281
- logger.info(`[view_member_avatar] Analyzing avatar: ${avatarUrl}`);
282
-
283
- if (toolCtx.config.isMultimodal) {
284
- return await createImageFollowupResult(
285
- avatarUrl,
286
- `User ${args.user_id}'s QQ avatar is attached. Inspect it directly and answer the user's question from the visual content.`,
287
- "The avatar has been attached to the next main model request. Inspect it directly instead of relying on a worker-model description.",
288
- );
289
- }
290
-
291
- // 主模型不支持视觉时,使用多模态工作模型描述头像。
292
- const { describeImage } = await import("./multimodal");
293
- const ai = toolCtx.aiService.getDefault();
294
- if (!ai) {
295
- return { error: "AI instance not available" };
296
- }
297
-
298
- const result = await describeImage(
299
- ai,
300
- avatarUrl,
301
- toolCtx.config.multimodalWorkingModel,
302
- `User ${args.user_id}'s QQ avatar`,
303
- );
304
-
305
- if (!result.success) {
306
- return { error: result.error || "Failed to analyze avatar" };
307
- }
308
-
309
- return {
310
- success: true,
311
- description: result.description,
312
- note: "The avatar has been analyzed. Use the description above to answer the user's question.",
313
- };
314
- } catch (err) {
315
- return { error: `Failed to analyze avatar: ${err}` };
316
- }
317
- },
318
- });
319
- }
320
-
321
- return tools;
322
- }
323
-
324
- function resolveGroupRecallLimit(value: unknown): number {
325
- const parsed = Number(value);
326
- if (!Number.isFinite(parsed)) {
327
- return DEFAULT_GROUP_RECALL_LIMIT;
328
- }
329
- const normalized = Math.floor(parsed);
330
- return Math.max(1, normalized);
331
- }
332
-
333
- function resolveUserHistoryLimit(value: unknown): number {
334
- const parsed = Number(value);
335
- if (!Number.isFinite(parsed)) {
336
- return DEFAULT_USER_HISTORY_LIMIT;
337
- }
338
- const normalized = Math.floor(parsed);
339
- return Math.max(1, normalized);
340
- }
341
-
342
- function extractTargetUserIdsFromQuestion(
343
- question: string,
344
- requesterUserId: number,
345
- ): number[] {
346
- const matches = question.match(/\b\d{5,12}\b/g) || [];
347
- const parsed = matches
348
- .map((item) => Number(item))
349
- .filter((item) => Number.isFinite(item) && item > 0);
350
- const ids = [requesterUserId, ...parsed].filter(
351
- (item) => Number.isFinite(item) && item > 0,
352
- );
353
- return [...new Set(ids)].slice(0, 3);
354
- }
355
-
356
- async function fetchGroupHistoryByMessageIdPaging(
357
- toolCtx: ToolContext,
358
- limit: number,
359
- ): Promise<ChatMessage[]> {
360
- if (!toolCtx.groupId || limit <= 0) {
361
- return [];
362
- }
363
-
364
- const selfId = Number(toolCtx.event?.self_id || 0);
365
- if (!selfId) {
366
- return [];
367
- }
368
-
369
- const bot = toolCtx.ctx.pickBot(selfId);
370
- if (!bot) {
371
- return [];
372
- }
373
-
374
- const collected: ChatMessage[] = [];
375
- const seenMessageIds = new Set<string>();
376
- let cursorMessageId = 0;
377
- const maxPages = Math.max(1, Math.ceil(limit / 200) + 5);
378
- let page = 0;
379
-
380
- while (collected.length < limit && page < maxPages) {
381
- const remaining = limit - collected.length;
382
- const pageSize = Math.min(200, remaining);
383
-
384
- let response: any;
385
- try {
386
- response = await (bot as any).api("get_group_msg_history", {
387
- group_id: String(toolCtx.groupId),
388
- message_seq: String(cursorMessageId),
389
- count: pageSize,
390
- reverse_order: false,
391
- disable_get_url: true,
392
- parse_mult_msg: false,
393
- quick_reply: false,
394
- });
395
- } catch (err) {
396
- const errText = String(err);
397
- if (
398
- cursorMessageId > 0 &&
399
- (errText.includes("不存在") ||
400
- errText.toLowerCase().includes("not exist"))
401
- ) {
402
- logger.info(
403
- `[recall_memory] get_group_msg_history stop at cursor ${cursorMessageId}: ${errText}`,
404
- );
405
- } else {
406
- logger.warn(
407
- `[recall_memory] get_group_msg_history failed at cursor ${cursorMessageId}: ${errText}`,
408
- );
409
- }
410
- break;
411
- }
412
-
413
- const rawMessages = response?.messages || response?.data?.messages || [];
414
- if (!Array.isArray(rawMessages) || rawMessages.length === 0) {
415
- break;
416
- }
417
-
418
- let oldestMessageId: number | null = null;
419
- let newAdded = 0;
420
-
421
- for (const raw of rawMessages) {
422
- const messageId = Number(raw?.message_id || raw?.message_seq || 0);
423
- const key =
424
- messageId > 0
425
- ? `mid:${messageId}`
426
- : `${String(raw?.user_id || "unknown")}:${String(raw?.time || "0")}:${String(raw?.raw_message || "")}`;
427
- if (seenMessageIds.has(key)) {
428
- continue;
429
- }
430
- seenMessageIds.add(key);
431
-
432
- const content = extractGroupHistoryText(raw);
433
- if (!content.trim()) {
434
- continue;
435
- }
436
-
437
- const ts = typeof raw?.time === "number" ? raw.time * 1000 : Date.now();
438
- collected.push({
439
- sessionId: toolCtx.sessionId,
440
- role: String(raw?.user_id) === String(selfId) ? "assistant" : "user",
441
- content,
442
- userId:
443
- typeof raw?.user_id === "number" ? raw.user_id : Number(raw?.user_id),
444
- userName:
445
- raw?.sender?.card ||
446
- raw?.sender?.nickname ||
447
- String(raw?.user_id || "unknown"),
448
- userRole: raw?.sender?.role || "member",
449
- groupId: toolCtx.groupId,
450
- timestamp: ts,
451
- messageId: messageId > 0 ? messageId : undefined,
452
- });
453
- newAdded += 1;
454
-
455
- if (messageId > 0) {
456
- if (oldestMessageId === null || messageId < oldestMessageId) {
457
- oldestMessageId = messageId;
458
- }
459
- }
460
- }
461
-
462
- if (newAdded === 0 || oldestMessageId === null || oldestMessageId <= 1) {
463
- break;
464
- }
465
-
466
- // NapCat message_seq expects an existing message sequence/id.
467
- // Do not subtract 1 (IDs may be non-contiguous and trigger "message not exist").
468
- const nextCursor = oldestMessageId;
469
- if (nextCursor === cursorMessageId) {
470
- break;
471
- }
472
- cursorMessageId = nextCursor;
473
- page += 1;
474
- }
475
-
476
- collected.sort((a, b) => {
477
- if (a.timestamp !== b.timestamp) {
478
- return a.timestamp - b.timestamp;
479
- }
480
- const aId = a.messageId || 0;
481
- const bId = b.messageId || 0;
482
- return aId - bId;
483
- });
484
-
485
- if (collected.length > limit) {
486
- return collected.slice(-limit);
487
- }
488
- return collected;
489
- }
490
-
491
- function extractGroupHistoryText(raw: any): string {
492
- const segments = Array.isArray(raw?.message) ? raw.message : [];
493
- if (segments.length === 0) {
494
- return String(raw?.raw_message || "").trim();
495
- }
496
-
497
- const parts: string[] = [];
498
-
499
- for (const seg of segments) {
500
- if (seg?.type === "text") {
501
- const text = String(seg?.data?.text || "");
502
- if (text) parts.push(text);
503
- continue;
504
- }
505
-
506
- if (seg?.type === "at") {
507
- const target =
508
- seg?.qq || seg?.data?.qq || seg?.data?.id || seg?.data?.user_id;
509
- if (target === "all" || target === "everyone") {
510
- parts.push("@全体成员");
511
- } else if (target) {
512
- parts.push(`@${target}`);
513
- }
514
- continue;
515
- }
516
-
517
- if (seg?.type === "image") {
518
- parts.push("[image]");
519
- continue;
520
- }
521
- }
522
-
523
- const joined = parts.join(" ").trim();
524
- if (joined) {
525
- return joined;
526
- }
527
-
528
- return String(raw?.raw_message || "").trim();
529
- }
530
-
531
- // ==================== Meta Tools ====================
532
-
533
- function createLoadSkillTool(
534
- toolCtx: ToolContext,
535
- skillManager: SkillSessionManager,
536
- ): AITool {
537
- return {
538
- name: "load_skill",
539
- description:
540
- "Load an external skill's tools into the current session. Tools will be available for 1 hour.",
541
- parameters: {
542
- type: "object",
543
- properties: {
544
- skill_name: {
545
- type: "string",
546
- description: "Skill name to load",
547
- },
548
- },
549
- required: ["skill_name"],
550
- },
551
- handler: async (args) => {
552
- const skillName = String(args?.skill_name || "").trim();
553
-
554
- // Handle builtin features (markdown, audio, web_search, web_read_page, recall_memory)
555
- if (isBuiltinFeature(skillName)) {
556
- const feature = getFeatureMeta(skillName);
557
- if (!feature) {
558
- return { error: `Feature "${skillName}" not found` };
559
- }
560
- if (!isFeatureEnabled(toolCtx.config, feature)) {
561
- return { error: `Feature "${skillName}" is not enabled in config` };
562
- }
563
-
564
- // For features with tools, delegate to createFeatureTools
565
- const featureTools: AITool[] = feature.hasTools
566
- ? createFeatureTools(toolCtx, skillName)
567
- : [];
568
- if (featureTools.length > 0) {
569
- skillManager.loadSkill(toolCtx.sessionId, skillName, featureTools);
570
- } else {
571
- // For features without tools (markdown, audio), just record the feature state
572
- skillManager.loadFeature(
573
- toolCtx.sessionId,
574
- skillName,
575
- 60 * 60 * 1000,
576
- );
577
- }
578
-
579
- // Build usage instructions for all builtin features
580
- const toolStrength = normalizeConstraintStrength(
581
- toolCtx.config.toolCallConstraintStrength,
582
- );
583
- const audioStrength = normalizeConstraintStrength(
584
- toolCtx.config.audioUsageConstraintStrength,
585
- );
586
- const markdownStrength = normalizeConstraintStrength(
587
- toolCtx.config.markdownUsageConstraintStrength,
588
- );
589
-
590
- const sections: string[] = [];
591
-
592
- if (skillName === "web_search") {
593
- const s = buildWebSearchFeatureSection(toolCtx.config, toolStrength);
594
- if (s) sections.push(s);
595
- } else if (skillName === "web_read_page") {
596
- const s = buildWebReadFeatureSection(toolCtx.config, toolStrength);
597
- if (s) sections.push(s);
598
- } else if (skillName === "recall_memory") {
599
- const s = buildRecallMemoryFeatureSection(toolCtx.config);
600
- if (s) sections.push(s);
601
- }
602
-
603
- const usageHint = sections.length > 0 ? sections.join("\n") : "";
604
-
605
- return {
606
- success: true,
607
- skill_name: skillName,
608
- feature: true,
609
- expires_in: "1 hour",
610
- tools: featureTools.map((t) => ({
611
- name: `${skillName}.${t.name}`,
612
- description: t.description,
613
- parameters: t.parameters,
614
- })),
615
- ...(usageHint ? { usage: usageHint } : {}),
616
- };
617
- }
618
-
619
- // External skill loading (original logic)
620
- if (!isExternalSkillAllowed(toolCtx.config, skillName)) {
621
- const allSkills = toolCtx.aiService.getAllSkills?.();
622
- const allowedSkills = allSkills
623
- ? filterAllowedExternalSkills(
624
- toolCtx.config,
625
- [...allSkills.values()],
626
- toolCtx.triggerSkillRole,
627
- )
628
- : [];
629
- const allowedNames = allowedSkills.map((skill) => skill.name);
630
-
631
- return {
632
- error:
633
- allowedNames.length > 0
634
- ? `Skill "${skillName}" is not allowed. Allowed skills: ${allowedNames.join(", ")}`
635
- : "No external skills are allowed in current config",
636
- };
637
- }
638
-
639
- const skill = toolCtx.aiService.getSkill(skillName);
640
- if (!skill) {
641
- return { error: `Skill "${skillName}" does not exist` };
642
- }
643
- const requiredRole = getSkillRequiredPermissionRole(skill);
644
- if (!hasSkillPermission(toolCtx.triggerSkillRole, requiredRole)) {
645
- return {
646
- error: `Permission denied: loading skill "${skill.name}" requires role "${requiredRole}", current role is "${toolCtx.triggerSkillRole}"`,
647
- };
648
- }
649
-
650
- skillManager.loadSkill(toolCtx.sessionId, skill.name, skill.tools);
651
-
652
- const loadedTools = skill.tools.map((t) => ({
653
- name: `${skill.name}.${t.name}`,
654
- description: t.description,
655
- parameters: t.parameters,
656
- }));
657
-
658
- return {
659
- success: true,
660
- skill_name: skill.name,
661
- expires_in: "1 hour",
662
- tools: loadedTools,
663
- };
664
- },
665
- };
666
- }
667
-
668
- function createFeatureTools(
669
- toolCtx: ToolContext,
670
- featureName: FeatureName,
671
- ): AITool[] {
672
- switch (featureName) {
673
- case "web_search":
674
- return [createWebSearchTool(toolCtx)];
675
- case "web_read_page":
676
- return [createWebReadPageTool(toolCtx)];
677
- case "recall_memory":
678
- return [createRecallMemoryTool(toolCtx)];
679
- default:
680
- return [];
681
- }
682
- }
683
-
684
- function createWebSearchTool(toolCtx: ToolContext): AITool {
685
- return {
686
- name: "web_search",
687
- description:
688
- "Search the web using SearXNG. Use this for current events, external facts, documentation, or anything not in chat history. This tool can only be called a limited number of times per conversation.",
689
- parameters: {
690
- type: "object",
691
- properties: {
692
- query: {
693
- type: "string",
694
- description: "Search query",
695
- },
696
- queries: {
697
- type: "array",
698
- items: { type: "string" },
699
- description:
700
- "Alternative input. Multiple search queries; only the first non-empty query will be used.",
701
- },
702
- limit: {
703
- type: "number",
704
- description:
705
- "Max number of results to return. Will be clamped by config maxLimit.",
706
- },
707
- time_range: {
708
- type: "string",
709
- enum: ["day", "month", "year"],
710
- description: "Optional time filter for recent results",
711
- },
712
- categories: {
713
- type: "array",
714
- items: { type: "string" },
715
- description:
716
- 'Optional categories, e.g. ["general"], ["news"], ["science"]',
717
- },
718
- engines: {
719
- type: "array",
720
- items: { type: "string" },
721
- description:
722
- 'Optional engines, e.g. ["google"], ["bing"], ["duckduckgo"]',
723
- },
724
- },
725
- required: [],
726
- },
727
- handler: async (args) => {
728
- return searchWebWithSearxng(toolCtx.config.searxng, args || {});
729
- },
730
- };
731
- }
732
-
733
- function createWebReadPageTool(toolCtx: ToolContext): AITool {
734
- return {
735
- name: "web_read_page",
736
- description:
737
- "Read a webpage by URL, extract its main content, and compress the content into a short, information-dense passage. Use this directly when the user already provides a URL, or combine with web_search when you need to discover relevant pages first.",
738
- parameters: {
739
- type: "object",
740
- properties: {
741
- url: {
742
- type: "string",
743
- description: "The http/https URL of the webpage to read",
744
- },
745
- render_js: {
746
- type: "boolean",
747
- description:
748
- "Set true only if the page likely requires JavaScript rendering. This uses much more CPU and memory.",
749
- },
750
- question: {
751
- type: "string",
752
- description:
753
- "Optional question or focus. The tool will prioritize webpage details relevant to this question.",
754
- },
755
- },
756
- required: ["url"],
757
- },
758
- handler: async (args) => {
759
- try {
760
- const ai = toolCtx.config.webReader.useWorkingModel
761
- ? toolCtx.aiService.getDefault()
762
- : undefined;
763
- if (toolCtx.config.webReader.useWorkingModel && !ai) {
764
- return { success: false, error: "AI instance not available" };
765
- }
766
-
767
- return await readWebPage(
768
- ai,
769
- toolCtx.config.workingModel || toolCtx.config.model,
770
- toolCtx.config.webReader,
771
- args || {},
772
- );
773
- } catch (err) {
774
- return { success: false, error: `Failed to read webpage: ${err}` };
775
- }
776
- },
777
- };
778
- }
779
-
780
- function createRecallMemoryTool(toolCtx: ToolContext): AITool {
781
- return {
782
- name: "recall_memory",
783
- description:
784
- "Ask the memory worker model to retrieve historical chat context for a recall question. Use only when recall is explicitly needed and the answer is not already in current context.",
785
- parameters: {
786
- type: "object",
787
- properties: {
788
- question: {
789
- type: "string",
790
- description:
791
- "The recall question to investigate, e.g. 'What did user 123 mention about travel plans before?'",
792
- },
793
- },
794
- required: ["question"],
795
- },
796
- handler: async (args) => {
797
- const question = String(args?.question || "").trim();
798
- if (!question) {
799
- return {
800
- success: false,
801
- error: "question is required",
802
- };
803
- }
804
-
805
- const ai = toolCtx.aiService.getDefault();
806
- if (!ai) {
807
- return { success: false, error: "AI instance not available" };
808
- }
809
-
810
- const groupHistoryLimit = resolveGroupRecallLimit(
811
- toolCtx.config.memory?.groupHistoryLimit,
812
- );
813
- const userHistoryLimit = resolveUserHistoryLimit(
814
- toolCtx.config.memory?.userHistoryLimit,
815
- );
816
- const groupHistoryMessages = await fetchGroupHistoryByMessageIdPaging(
817
- toolCtx,
818
- groupHistoryLimit,
819
- );
820
- const targetUserIds = extractTargetUserIdsFromQuestion(
821
- question,
822
- toolCtx.userId,
823
- );
824
- const userHistories: MemoryUserHistoryChunk[] = targetUserIds.map(
825
- (userId) => ({
826
- userId,
827
- messages: toolCtx.db.getMessagesByUser(
828
- userId,
829
- toolCtx.sessionId,
830
- userHistoryLimit,
831
- ),
832
- }),
833
- );
834
-
835
- const retriever = new MemoryRetrieval(ai, toolCtx.config, toolCtx.db);
836
- const answer = await retriever.retrieveByQuestion({
837
- sessionId: toolCtx.sessionId,
838
- question,
839
- nowTimestamp: Date.now(),
840
- groupHistoryMessages,
841
- userHistories,
842
- });
843
- const queriedAt = new Date().toLocaleString("zh-CN", {
844
- hour12: false,
845
- });
846
- return {
847
- success: true,
848
- queried_at: queriedAt,
849
- question,
850
- found: Boolean(answer),
851
- answer: answer || "",
852
- group_history_count: groupHistoryMessages.length,
853
- group_history_limit: groupHistoryLimit,
854
- user_history_limit: userHistoryLimit,
855
- user_history_targets: targetUserIds,
856
- note: answer
857
- ? "Memory worker retrieved historical context."
858
- : "Memory worker did not find useful historical context.",
859
- };
860
- },
861
- };
862
- }
51
+ export { logger };