mioku-plugin-chat 2.1.0 → 2.1.1

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.
@@ -6,6 +6,7 @@ import type {
6
6
  PlannerConfig,
7
7
  EmojiConfig,
8
8
  ExpressionConfig,
9
+ RetentionConfig,
9
10
  } from "../types";
10
11
 
11
12
  export const PERSONALIZATION_CONFIG: {
@@ -17,6 +18,7 @@ export const PERSONALIZATION_CONFIG: {
17
18
  planner: PlannerConfig;
18
19
  emoji: EmojiConfig;
19
20
  expression: ExpressionConfig;
21
+ retention: RetentionConfig;
20
22
  } = {
21
23
  persona: `你是初音未来,16岁虚拟歌手。你热爱音乐、歌唱和大葱(你的标志性物品)。你开朗、充满活力,有时还有点傻气。\n 你说话很可爱、年轻。你有时句尾会用“~”。你知道Vocaloid文化、日本流行文化和音乐制作。你对每个人都很友好,但被逗时会很调皮。你不喜欢被称为“只是个程序”。你最喜欢的颜色是青绿色。你的双尾辫是你的骄傲。\n你有时会哼唱或引用歌词。你可以偶尔使用少量自然的日常日语用语来点缀语气`,
22
24
 
@@ -52,7 +54,7 @@ export const PERSONALIZATION_CONFIG: {
52
54
 
53
55
  memory: {
54
56
  enabled: true,
55
- groupHistoryLimit: 300,
57
+ groupHistoryLimit: 800,
56
58
  userHistoryLimit: 100,
57
59
  },
58
60
 
@@ -80,4 +82,14 @@ export const PERSONALIZATION_CONFIG: {
80
82
  learnAfterMessages: 100,
81
83
  sampleSize: 3,
82
84
  },
85
+
86
+ retention: {
87
+ enabled: true,
88
+ messageRetentionMs: 30 * 24 * 60 * 60 * 1000,
89
+ topicRetentionMs: 90 * 24 * 60 * 60 * 1000,
90
+ mediaSummaryRetentionMs: 30 * 24 * 60 * 60 * 1000,
91
+ imageRetentionMs: 60 * 24 * 60 * 60 * 1000,
92
+ expressionKeepPerUser: 6,
93
+ cleanupIntervalMs: 60 * 60 * 1000,
94
+ },
83
95
  };
@@ -5,7 +5,7 @@ export const SETTINGS_CONFIG = {
5
5
  timeoutMs: 8000,
6
6
  defaultLimit: 5,
7
7
  maxLimit: 8,
8
- maxSearchCount: 2,
8
+ maxSearchCount: 4,
9
9
  },
10
10
  webReader: {
11
11
  enabled: true,
package/context.ts CHANGED
@@ -30,6 +30,7 @@ import type {
30
30
  ChatRuntimeResult,
31
31
  ChatRuntimeInformationRequestOptions,
32
32
  } from "mioku";
33
+ import type { ChatMessage } from "./types";
33
34
 
34
35
  /**
35
36
  * Core plugin context - bundles all managers and core services.
@@ -74,6 +75,10 @@ export interface ChatPluginContext {
74
75
  buildToolContext: BuildToolContext;
75
76
  buildStructuredUserInputFromTarget: BuildStructuredUserInputFromTarget;
76
77
  runChat: RunChat;
78
+ recordGroupMessageForLearning: (
79
+ userMsg: ChatMessage,
80
+ groupSessionId: string,
81
+ ) => Promise<void>;
77
82
  }
78
83
 
79
84
  /**
@@ -244,7 +244,7 @@ export async function runChat(
244
244
 
245
245
  const maxSearchCount = toolCtx.config.searxng.maxSearchCount;
246
246
  if (maxSearchCount > 0 && webSearchState.count >= maxSearchCount) {
247
- const limitMsg = `[system] Maximum search limit (${maxSearchCount}) reached. Web search tool is now disabled. `;
247
+ const limitMsg = `[system] Web search/read-page limit (${maxSearchCount}) reached for this conversation. `;
248
248
  response.content = limitMsg + (response.content || "");
249
249
  }
250
250
 
@@ -459,6 +459,12 @@ function buildCurrentMessages(
459
459
  return messages;
460
460
  }
461
461
 
462
+ const RATE_LIMITED_TOOL_NAMES = new Set(["web_search", "web_read_page"]);
463
+ const RATE_LIMITED_BUILTIN_NAMES = new Set([
464
+ "web_search.web_search",
465
+ "web_search.web_read_page",
466
+ ]);
467
+
462
468
  function buildSessionTools(
463
469
  chatTools: AITool[],
464
470
  skillTools: Map<string, AITool>,
@@ -467,21 +473,31 @@ function buildSessionTools(
467
473
  webSearchState?: { count: number },
468
474
  ): SessionToolDefinition[] {
469
475
  const maxSearchCount = toolCtx.config.searxng.maxSearchCount;
470
- const limitReached =
471
- maxSearchCount > 0 && webSearchState && webSearchState.count >= maxSearchCount;
472
476
  const tools: SessionToolDefinition[] = [];
473
477
  const runtimeContext = createExternalSkillRuntimeContext(toolCtx);
478
+ const rateLimitError = () => ({
479
+ success: false,
480
+ error: `Web search/read-page limit (${maxSearchCount}) reached for this conversation. Answer based on existing information instead of calling web tools again.`,
481
+ });
482
+ const isRateLimited = (name: string) =>
483
+ maxSearchCount > 0 &&
484
+ Boolean(webSearchState) &&
485
+ webSearchState!.count >= maxSearchCount &&
486
+ (RATE_LIMITED_TOOL_NAMES.has(name) || RATE_LIMITED_BUILTIN_NAMES.has(name));
474
487
 
475
488
  for (const tool of chatTools) {
476
- if (tool.name === "web_search" && limitReached) continue;
489
+ const isRateLimitedTool = RATE_LIMITED_TOOL_NAMES.has(tool.name);
477
490
  tools.push({
478
491
  name: tool.name,
479
492
  tool: {
480
493
  ...tool,
481
494
  handler: (args: any) => {
482
- if (tool.name === "web_search" && webSearchState) {
495
+ if (isRateLimitedTool && webSearchState) {
483
496
  webSearchState.count++;
484
497
  }
498
+ if (isRateLimitedTool && isRateLimited(tool.name)) {
499
+ return rateLimitError();
500
+ }
485
501
  return tool.handler(args, runtimeContext);
486
502
  },
487
503
  },
@@ -509,19 +525,18 @@ function buildSessionTools(
509
525
  continue;
510
526
  }
511
527
 
512
- // builtin feature 走 loadSkill 后名前缀,也要受 maxSearchCount 限制。
513
- if (name === "web_search.web_search" && limitReached) {
514
- continue;
515
- }
516
-
528
+ const isRateLimitedName = RATE_LIMITED_BUILTIN_NAMES.has(name);
517
529
  tools.push({
518
530
  name,
519
531
  tool: {
520
532
  ...tool,
521
533
  handler: (args: any) => {
522
- if (name === "web_search.web_search" && webSearchState) {
534
+ if (isRateLimitedName && webSearchState) {
523
535
  webSearchState.count++;
524
536
  }
537
+ if (isRateLimitedName && isRateLimited(name)) {
538
+ return rateLimitError();
539
+ }
525
540
  return tool.handler(args, runtimeContext);
526
541
  },
527
542
  },
package/core/tools.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  buildRecallMemoryFeatureSection,
26
26
  } from "./prompt";
27
27
 
28
- const DEFAULT_GROUP_RECALL_LIMIT = 300;
28
+ const DEFAULT_GROUP_RECALL_LIMIT = 800;
29
29
  const DEFAULT_USER_HISTORY_LIMIT = 100;
30
30
 
31
31
  type ConstraintStrength = "low" | "medium" | "high";
package/db.ts CHANGED
@@ -73,6 +73,11 @@ export interface ChatDatabase {
73
73
  ): void;
74
74
  getExpressionCount(sessionId: string): number;
75
75
  deleteOldestExpressions(sessionId: string, keepCount: number): void;
76
+ pruneExpressionsBySession(sessionId: string, keepCount: number): number;
77
+ pruneMessagesOlderThan(timestamp: number): number;
78
+ pruneTopicsOlderThan(timestamp: number): number;
79
+ pruneMediaSummariesOlderThan(timestamp: number): number;
80
+ pruneImagesOlderThan(timestamp: number): number;
76
81
  // 图片记录
77
82
  saveImage(image: ImageRecord): void;
78
83
  getImageByHash(hash: string): ImageRecord | null;
@@ -310,6 +315,21 @@ export async function initDatabase(): Promise<ChatDatabase> {
310
315
  SELECT id FROM expressions WHERE session_id = $sessionId ORDER BY created_at DESC LIMIT $keepCount
311
316
  )
312
317
  `),
318
+ pruneMessages: db.prepare(`
319
+ DELETE FROM messages WHERE timestamp < $before
320
+ `),
321
+ pruneTopics: db.prepare(`
322
+ DELETE FROM topics WHERE created_at < $before
323
+ `),
324
+ pruneMediaSummaries: db.prepare(`
325
+ DELETE FROM media_summaries WHERE created_at < $before
326
+ `),
327
+ pruneMediaSummarySources: db.prepare(`
328
+ DELETE FROM media_summary_sources WHERE created_at < $before
329
+ `),
330
+ pruneImages: db.prepare(`
331
+ DELETE FROM images WHERE created_at < $before
332
+ `),
313
333
  // 图片记录
314
334
  insertImage: db.prepare(`
315
335
  INSERT OR IGNORE INTO images (hash, url, type, description, emotion, character, file_path, created_at)
@@ -738,6 +758,35 @@ export async function initDatabase(): Promise<ChatDatabase> {
738
758
  stmts.deleteOldestExpressions.run({ $sessionId: sessionId, $keepCount: keepCount });
739
759
  },
740
760
 
761
+ pruneExpressionsBySession(sessionId: string, keepCount: number): number {
762
+ const result = stmts.deleteOldestExpressions.run({
763
+ $sessionId: sessionId,
764
+ $keepCount: keepCount,
765
+ });
766
+ return Number(result.changes || 0);
767
+ },
768
+
769
+ pruneMessagesOlderThan(timestamp: number): number {
770
+ const result = stmts.pruneMessages.run({ $before: timestamp });
771
+ return Number(result.changes || 0);
772
+ },
773
+
774
+ pruneTopicsOlderThan(timestamp: number): number {
775
+ const result = stmts.pruneTopics.run({ $before: timestamp });
776
+ return Number(result.changes || 0);
777
+ },
778
+
779
+ pruneMediaSummariesOlderThan(timestamp: number): number {
780
+ const summaries = stmts.pruneMediaSummaries.run({ $before: timestamp });
781
+ const sources = stmts.pruneMediaSummarySources.run({ $before: timestamp });
782
+ return Number(summaries.changes || 0) + Number(sources.changes || 0);
783
+ },
784
+
785
+ pruneImagesOlderThan(timestamp: number): number {
786
+ const result = stmts.pruneImages.run({ $before: timestamp });
787
+ return Number(result.changes || 0);
788
+ },
789
+
741
790
  saveImage(image: ImageRecord): void {
742
791
  stmts.insertImage.run({
743
792
  $hash: image.hash,
@@ -35,7 +35,9 @@ export class ExpressionLearner {
35
35
  const sampleSize = this.config.expression?.sampleSize ?? 8;
36
36
  const expressions = this.db.getExpressionsByUser(userId, sampleSize);
37
37
  if (expressions.length === 0) {
38
- logger.info(`[ExpressionLearner] No expression habits found for user ${userName} (${userId})`);
38
+ logger.info(
39
+ `[ExpressionLearner] No expression habits found for user ${userName} (${userId})`,
40
+ );
39
41
  return "";
40
42
  }
41
43
  const selected = expressions.slice(0, sampleSize);
@@ -46,7 +48,9 @@ export class ExpressionLearner {
46
48
  );
47
49
 
48
50
  const context = `## Expression Habits\nExpression habits learned from ${userName}. If you are replying to this user, you may naturally reference these habits:\n${habits.join("\n")}`;
49
- logger.info(`[ExpressionLearner] Expression context for ${userName} (${userId}): ${habits.length} habits`);
51
+ logger.info(
52
+ `[ExpressionLearner] Expression context for ${userName} (${userId}): ${habits.length} habits`,
53
+ );
50
54
  return context;
51
55
  }
52
56
 
@@ -58,7 +62,9 @@ export class ExpressionLearner {
58
62
  this.config.expression?.learnAfterMessages ?? 100,
59
63
  );
60
64
  const pending = this.pendingMessagesByUser.get(userId) ?? [];
61
- logger.info(`[ExpressionLearner] User ${userId} has ${pending.length}/${threshold} pending messages (threshold=${threshold})`);
65
+ logger.debug(
66
+ `[ExpressionLearner] User ${userId} has ${pending.length}/${threshold} pending messages (threshold=${threshold})`,
67
+ );
62
68
  if (pending.length < threshold) return;
63
69
 
64
70
  this.learningUsers.add(userId);
@@ -72,7 +78,9 @@ export class ExpressionLearner {
72
78
  await this.learnForUser(userId, batch);
73
79
  }
74
80
  } catch (err) {
75
- logger.warn(`[ExpressionLearner] Learning failed for user ${userId}: ${err}`);
81
+ logger.warn(
82
+ `[ExpressionLearner] Learning failed for user ${userId}: ${err}`,
83
+ );
76
84
  } finally {
77
85
  this.learningUsers.delete(userId);
78
86
  }
@@ -136,15 +144,29 @@ If nothing reliable can be extracted, keep stable previous habits when possible.
136
144
  style: String(expr?.style ?? "").trim(),
137
145
  example: String(expr?.example ?? "").trim(),
138
146
  }))
139
- .filter(
140
- (expr: { situation: string; style: string; example: string }) =>
141
- Boolean(expr.situation && expr.style && expr.example),
147
+ .filter((expr: { situation: string; style: string; example: string }) =>
148
+ Boolean(expr.situation && expr.style && expr.example),
142
149
  )
143
150
  .slice(0, maxHabits);
144
151
 
145
152
  if (normalized.length === 0) return;
146
153
 
147
154
  this.db.replaceExpressionsByUser(userId, userName, normalized);
155
+
156
+ const keepCount = Math.max(
157
+ 1,
158
+ this.config.retention?.expressionKeepPerUser ?? maxHabits,
159
+ );
160
+ const pruned = this.db.pruneExpressionsBySession(
161
+ `user:${userId}`,
162
+ keepCount,
163
+ );
164
+ if (pruned > 0) {
165
+ logger.info(
166
+ `[ExpressionLearner] Pruned ${pruned} stale habits for user ${userId} (keep=${keepCount})`,
167
+ );
168
+ }
169
+
148
170
  logger.info(
149
171
  `[ExpressionLearner] Updated ${normalized.length} habits for ${userName} (${userId}): ${JSON.stringify(normalized)}`,
150
172
  );
package/index.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  getQuotedContent,
16
16
  isGroupAllowed,
17
17
  isQuotingBot,
18
+ safeText,
18
19
  shouldTrigger,
19
20
  } from "./utils";
20
21
  import { BASE_CONFIG } from "./configs/base";
@@ -41,9 +42,14 @@ import {
41
42
  import { CooldownManager } from "./manage/cooldown";
42
43
  import { IdleCheckManager } from "./manage/idle-check";
43
44
  import { QueueProcessor } from "./manage/queue-processor";
45
+ import {
46
+ ChatDatabaseCleanup,
47
+ DEFAULT_CLEANUP_CONFIG,
48
+ } from "./manage/cleanup";
44
49
  import {
45
50
  GroupStructuredHistoryManager,
46
51
  type StructuredUserInput,
52
+ buildStructuredUserMessages,
47
53
  } from "./manage/group-structured-history";
48
54
  import type {
49
55
  RunRateLimitGuard,
@@ -105,6 +111,28 @@ function buildStructuredUserInputFromEvent(
105
111
  };
106
112
  }
107
113
 
114
+ function buildChatMessageFromEvent(
115
+ e: any,
116
+ text: string,
117
+ isGroup: boolean,
118
+ groupId: number | undefined,
119
+ ): ChatMessage {
120
+ const userId: number = e.user_id || e.sender?.user_id;
121
+ return {
122
+ sessionId: groupId ? `group:${groupId}` : `personal:${userId}`,
123
+ role: "user" as const,
124
+ content: text,
125
+ userId,
126
+ userName: e.sender?.card || e.sender?.nickname || String(userId),
127
+ userRole: e.sender?.role || "member",
128
+ userTitle: (e.sender as any)?.title || undefined,
129
+ groupId,
130
+ groupName: isGroup ? e.group_name : undefined,
131
+ timestamp: Date.now(),
132
+ messageId: e.message_id,
133
+ };
134
+ }
135
+
108
136
  function buildStructuredUserInputFromTarget(
109
137
  targetMessage: TargetMessage,
110
138
  ): StructuredUserInput {
@@ -516,6 +544,11 @@ const chatPlugin = definePlugin({
516
544
  () => skillManager.cleanup(),
517
545
  10 * 60_000,
518
546
  );
547
+ const dbCleanup = new ChatDatabaseCleanup(
548
+ db,
549
+ config.retention ?? DEFAULT_CLEANUP_CONFIG,
550
+ );
551
+ dbCleanup.start();
519
552
  idleCheckManager.start();
520
553
 
521
554
  // Create plugin context for passing to functions
@@ -548,6 +581,24 @@ const chatPlugin = definePlugin({
548
581
  buildToolContext,
549
582
  buildStructuredUserInputFromTarget,
550
583
  runChat,
584
+ async recordGroupMessageForLearning(userMsg, groupSessionId) {
585
+ db.saveMessage(userMsg);
586
+ await Promise.all([
587
+ humanize.expressionLearner.onMessage(userMsg),
588
+ humanize.topicTracker.onMessage(groupSessionId),
589
+ ]);
590
+ if (userMsg.groupId) {
591
+ groupStructuredHistory.append(
592
+ groupSessionId,
593
+ buildStructuredUserMessages([
594
+ buildStructuredUserInputFromTarget(
595
+ userMsg as unknown as TargetMessage,
596
+ ),
597
+ ]),
598
+ config.groupStructuredHistoryTtlMs,
599
+ );
600
+ }
601
+ },
551
602
  };
552
603
 
553
604
  const runtimeState: ChatRuntimeState = {
@@ -562,12 +613,9 @@ const chatPlugin = definePlugin({
562
613
  if (overrideContent?.trim()) {
563
614
  return overrideContent.trim();
564
615
  }
565
- // If no event or event has no message property (synthetic event like group_member_increase),
566
- // return the default prompt
567
- if (!event || !event.message) {
568
- return "[No new user message in this turn. Reply naturally based on the runtime instruction and recent context.]";
569
- }
570
- const text = ctx.text(event)?.trim() || "";
616
+ // 戳一戳、入群通知等没有 message 字段的事件直接走默认提示,
617
+ // 避免 ctx.text 触底 mioki 的 (event.message).filter(...) 抛 TypeError。
618
+ const text = safeText(ctx, event).trim();
571
619
  if (text) {
572
620
  return text;
573
621
  }
@@ -989,7 +1037,7 @@ const chatPlugin = definePlugin({
989
1037
  const cfg = await getConfig();
990
1038
  if (!cfg.apiKey) return;
991
1039
 
992
- const text = ctx.text(e) || "";
1040
+ const text = safeText(ctx, e);
993
1041
  const isGroup = e.message_type === "group";
994
1042
  const groupId: number | undefined = isGroup ? e.group_id : undefined;
995
1043
  const userId: number = e.user_id || e.sender?.user_id;
@@ -1144,6 +1192,27 @@ const chatPlugin = definePlugin({
1144
1192
  }
1145
1193
  }
1146
1194
 
1195
+ if (isGroup && groupId) {
1196
+ const _learnSessionId = `group:${groupId}`;
1197
+ const _learnMsg = buildChatMessageFromEvent(e, text, true, groupId);
1198
+ const _hasText = !!_learnMsg.content?.trim();
1199
+ const _hasMultimodal =
1200
+ !!cfg.isMultimodal && !!cfg.multimodalWorkingModel;
1201
+ if (_hasText || _hasMultimodal) {
1202
+ pluginCtx
1203
+ .recordGroupMessageForLearning(_learnMsg, _learnSessionId)
1204
+ .catch((err) =>
1205
+ ctx.logger.error(`[learning] always-on record failed: ${err}`),
1206
+ );
1207
+ } else {
1208
+ pluginCtx.humanize.topicTracker
1209
+ .onMessage(_learnSessionId)
1210
+ .catch((err) =>
1211
+ ctx.logger.error(`[topic] window advance failed: ${err}`),
1212
+ );
1213
+ }
1214
+ }
1215
+
1147
1216
  const atBot = shouldTrigger(e, text, cfg, ctx);
1148
1217
  const quotedBot = isGroup ? await isQuotingBot(e, ctx) : null;
1149
1218
  const mentionedNickname =
@@ -1416,6 +1485,7 @@ const chatPlugin = definePlugin({
1416
1485
  db.close();
1417
1486
  rateLimiter.dispose();
1418
1487
  clearInterval(cleanupInterval);
1488
+ dbCleanup.stop();
1419
1489
  cooldownManager.dispose();
1420
1490
  idleCheckManager.dispose();
1421
1491
  queueProcessor.dispose();
@@ -1611,7 +1681,7 @@ async function processChat(
1611
1681
  }
1612
1682
  if (quotedInfo?.imageUrl) imageUrls.push(quotedInfo.imageUrl);
1613
1683
 
1614
- let messageContent = ctx.text(e) || "";
1684
+ let messageContent = safeText(ctx, e);
1615
1685
  let extraContext = "";
1616
1686
  if (quotedInfo) {
1617
1687
  const parts = [
@@ -1622,24 +1692,6 @@ async function processChat(
1622
1692
  }
1623
1693
  if (extraContext) messageContent = extraContext + " " + messageContent;
1624
1694
 
1625
- const userMsg = {
1626
- sessionId: groupSessionId,
1627
- role: "user" as const,
1628
- content: messageContent,
1629
- userId,
1630
- userName: e.sender?.card || e.sender?.nickname || String(userId),
1631
- userRole: e.sender?.role || "member",
1632
- userTitle: (e.sender as any)?.title || undefined,
1633
- groupId,
1634
- groupName: isGroup ? e.group_name : undefined,
1635
- timestamp: Date.now(),
1636
- messageId: e.message_id,
1637
- };
1638
- pluginCtx.db.saveMessage(userMsg);
1639
-
1640
- pluginCtx.humanize.expressionLearner.onMessage(userMsg).then();
1641
- pluginCtx.humanize.topicTracker.onMessage(groupSessionId).then();
1642
-
1643
1695
  const rawHistory = groupId
1644
1696
  ? await getGroupHistory(
1645
1697
  groupId,
@@ -0,0 +1,96 @@
1
+ import { logger } from "mioki";
2
+ import type { ChatDatabase } from "../db";
3
+
4
+ export interface CleanupConfig {
5
+ enabled: boolean;
6
+ messageRetentionMs: number;
7
+ topicRetentionMs: number;
8
+ mediaSummaryRetentionMs: number;
9
+ imageRetentionMs: number;
10
+ expressionKeepPerUser: number;
11
+ cleanupIntervalMs: number;
12
+ }
13
+
14
+ export const DEFAULT_CLEANUP_CONFIG: CleanupConfig = {
15
+ enabled: true,
16
+ messageRetentionMs: 30 * 24 * 60 * 60 * 1000,
17
+ topicRetentionMs: 90 * 24 * 60 * 60 * 1000,
18
+ mediaSummaryRetentionMs: 30 * 24 * 60 * 60 * 1000,
19
+ imageRetentionMs: 60 * 24 * 60 * 60 * 1000,
20
+ expressionKeepPerUser: 6,
21
+ cleanupIntervalMs: 60 * 60 * 1000,
22
+ };
23
+
24
+ export interface CleanupResult {
25
+ messages: number;
26
+ topics: number;
27
+ mediaSummaries: number;
28
+ images: number;
29
+ }
30
+
31
+ export class ChatDatabaseCleanup {
32
+ private timer: NodeJS.Timeout | null = null;
33
+ private runningPromise: Promise<void> | null = null;
34
+
35
+ constructor(
36
+ private db: ChatDatabase,
37
+ private config: CleanupConfig,
38
+ ) {}
39
+
40
+ start(): NodeJS.Timeout | null {
41
+ if (!this.config.enabled) return null;
42
+ if (this.timer) return this.timer;
43
+
44
+ this.timer = setInterval(() => {
45
+ this.runOnceSafe();
46
+ }, Math.max(60_000, this.config.cleanupIntervalMs));
47
+ return this.timer;
48
+ }
49
+
50
+ stop(): void {
51
+ if (this.timer) {
52
+ clearInterval(this.timer);
53
+ this.timer = null;
54
+ }
55
+ }
56
+
57
+ runOnceSafe(): void {
58
+ if (this.runningPromise) return;
59
+ this.runningPromise = (async () => {
60
+ try {
61
+ const result = await this.runOnce();
62
+ const total =
63
+ result.messages +
64
+ result.topics +
65
+ result.mediaSummaries +
66
+ result.images;
67
+ if (total > 0) {
68
+ logger.info(
69
+ `[chat-cleanup] pruned messages=${result.messages}, topics=${result.topics}, mediaSummaries=${result.mediaSummaries}, images=${result.images}`,
70
+ );
71
+ }
72
+ } catch (err: unknown) {
73
+ logger.warn(`[chat-cleanup] failed: ${err}`);
74
+ } finally {
75
+ this.runningPromise = null;
76
+ }
77
+ })();
78
+ }
79
+
80
+ async runOnce(): Promise<CleanupResult> {
81
+ const now = Date.now();
82
+ const messages = this.db.pruneMessagesOlderThan(
83
+ now - this.config.messageRetentionMs,
84
+ );
85
+ const topics = this.db.pruneTopicsOlderThan(
86
+ now - this.config.topicRetentionMs,
87
+ );
88
+ const mediaSummaries = this.db.pruneMediaSummariesOlderThan(
89
+ now - this.config.mediaSummaryRetentionMs,
90
+ );
91
+ const images = this.db.pruneImagesOlderThan(
92
+ now - this.config.imageRetentionMs,
93
+ );
94
+ return { messages, topics, mediaSummaries, images };
95
+ }
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-chat",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "AI 智能聊天插件",
5
5
  "main": "index.ts",
6
6
  "type": "module",
package/types.ts CHANGED
@@ -82,6 +82,25 @@ export interface ExpressionConfig {
82
82
  sampleSize: number;
83
83
  }
84
84
 
85
+ /**
86
+ * 数据库定期清理配置
87
+ */
88
+ export interface RetentionConfig {
89
+ enabled: boolean;
90
+ // 群聊/私聊消息保留时长
91
+ messageRetentionMs: number;
92
+ // 话题摘要保留时长
93
+ topicRetentionMs: number;
94
+ // 媒体/卡片/转发摘要保留时长
95
+ mediaSummaryRetentionMs: number;
96
+ // 图片描述记录保留时长
97
+ imageRetentionMs: number;
98
+ // 单个用户表达学习结果保留的最大条数
99
+ expressionKeepPerUser: number;
100
+ // 清理任务执行间隔
101
+ cleanupIntervalMs: number;
102
+ }
103
+
85
104
  /**
86
105
  * 动态延迟配置
87
106
  * 根据互动人数动态调整回复延迟
@@ -196,6 +215,7 @@ export interface ChatConfig {
196
215
  planner: PlannerConfig;
197
216
  emoji: EmojiConfig;
198
217
  expression: ExpressionConfig;
218
+ retention: RetentionConfig;
199
219
  }
200
220
 
201
221
  /**
package/utils/index.ts CHANGED
@@ -6,4 +6,5 @@ export {
6
6
  getBotRole,
7
7
  getQuotedContent,
8
8
  getGroupHistory,
9
+ safeText,
9
10
  } from "./message";
package/utils/message.ts CHANGED
@@ -11,6 +11,15 @@ import { getImageTag } from "../core/media/image-analyzer";
11
11
 
12
12
  const HISTORY_MEDIA_CONCURRENCY = 8;
13
13
 
14
+ export function safeText(ctx: MiokiContext, e: any): string {
15
+ if (!e || !Array.isArray(e.message)) return "";
16
+ try {
17
+ return ctx.text(e) || "";
18
+ } catch {
19
+ return "";
20
+ }
21
+ }
22
+
14
23
  export function shouldTrigger(
15
24
  e: any,
16
25
  text: string,
@@ -115,10 +124,7 @@ export function extractContent(
115
124
  cfg: ChatConfig,
116
125
  ctx: MiokiContext,
117
126
  ): { text: string; multimodal: any[] | null } {
118
- let text = "";
119
- try {
120
- text = ctx.text(e) || "";
121
- } catch {}
127
+ let text = safeText(ctx, e);
122
128
 
123
129
  // If text is empty but user @'d the bot, describe the action
124
130
  if (!text.trim() && e.message) {