Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,10 +386,12 @@ MCP 管理命令:
| `maxFactsPerGroup` | int | `50` | **每群最大全局记忆条数**(所有类别总计) |
| `importanceThreshold` | float | `0.5` | **重要性阈值**:低于此值的事实不会保存 |
| `memoryDecayDays` | int | `7` | 记忆召回时参考的时效天数 |
| `userExtractDebounceSeconds` | int | `45` | 同一用户停止发言后等待多久再批量提取,`0` 为立即处理 |
| `userExtractMaxBatchMessages` | int | `6` | 同一用户累计多少条消息后立即提取 |
| `groupExtractMinIntervalMinutes` | int | `10` | 群记忆最小整理间隔 |
| `groupExtractMaxBatchMessages` | int | `12` | 群累计多少条消息后立即整理 |
| `promptMaxUserFacts` | int | `8` | 注入 prompt 的用户记忆最大条数 |
| `promptMaxGroupFacts` | int | `6` | 注入 prompt 的群记忆最大条数 |
| `promptMaxUserFacts` | int | `8` | 注入 prompt 的用户记忆最大条数,`0` 为不注入 |
| `promptMaxGroupFacts` | int | `6` | 注入 prompt 的群记忆最大条数,`0` 为不注入 |
| `promptMaxChars` | int | `1200` | 记忆 prompt 总字符上限 |
| `semanticRecallEnabled` | boolean | `false` | 是否启用 embedding 语义召回,默认关闭 |
| `semanticRecallTopK` | int | `20` | 语义召回时先筛选多少条候选记忆,只有开启语义召回时才会用到 |
Expand All @@ -399,8 +401,9 @@ MCP 管理命令:

**使用说明**:
- 记忆整理是后台异步执行的,不会阻塞正常聊天。
- 用户记忆会在一次对话回复完成后立即异步提取;群记忆会按上面的时间间隔和累计条数批量整理。
- 工具调用结果、机器人自己的回复、系统提示不会被保存进记忆。
- 白名单群里的真实文本消息会进入后台缓冲:用户记忆按用户批量提取,群记忆按上面的时间间隔和累计条数批量整理,不要求机器人必须回复。
- 工具调用结果、机器人自己的回复、系统提示和明显的 `#`/`/` 机器人命令不会被保存进记忆。
- 记忆 AI 请求有固定并发上限和超时;临时失败的批次会保留并在退避后重试。
- 旧版记忆会自动兼容迁移,不需要手动清空。

**用户记忆分类**:提取的记忆会自动归类到以下类别:
Expand All @@ -424,7 +427,7 @@ MCP 管理命令:
- `#我的记忆`:查看自己的用户记忆,结果会以合并转发返回,避免刷屏。
- `#群记忆`:查看本群群记忆,结果会以合并转发返回,避免刷屏。
- `#搜索记忆 <关键词>`:搜索自己的用户记忆和本群群记忆,结果会以合并转发返回,避免刷屏。
- `#删除记忆 <ID>`:ID 可以从 `#我的记忆`、`#群记忆` 或 `#搜索记忆 <关键词>` 的结果里看到,例如 `ID:1a2b3c4d`,删除时发送 `#删除记忆 1a2b3c4d`。普通用户只能删除自己的记忆;群主、管理员、主人可删除群记忆。
- `#删除记忆 <ID>`:ID 可以从 `#我的记忆`、`#群记忆` 或 `#搜索记忆 <关键词>` 的结果里看到,例如 `ID:1a2b3c4d`,删除时至少发送前 8 位;如果前缀不唯一,需要输入更完整的 ID。普通用户只能删除自己的记忆;群主、管理员、主人可删除群记忆。
- `#清空我的记忆` / `#禁用我的记忆` / `#启用我的记忆`:管理自己的用户记忆。
- `#清空群记忆`:仅群主、管理员、主人可用。

Expand Down Expand Up @@ -1007,7 +1010,7 @@ memoryAiModel: "claude-3-5-haiku-20241022"
memoryAiApikey: "sk-ant-xxxxx"
```

> **说明**:用户记忆会在对话结束后立即异步调用此模型;群记忆会批量整理后再调用此模型,提取值得长期保存的事实。推荐使用 `gpt-4o-mini`、`gemini-2.0-flash`、`claude-3-5-haiku` 等小模型。插件会根据 URL 自动识别 API 格式。
> **说明**:白名单群中的真实文本会先进入后台缓冲,用户记忆按用户防抖批量调用此模型,群记忆按群批量整理。推荐使用 `gpt-4o-mini`、`gemini-2.0-flash`、`claude-3-5-haiku` 等小模型。插件会根据 URL 自动识别 API 格式。

### Embedding 模型配置 (`embeddingAiConfig`)

Expand Down
7 changes: 6 additions & 1 deletion apps/MessageManager.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { MessageManager } from '../utils/MessageManager.js'
import { emojiPackManager } from '../utils/EmojiPackManager.js'
import { getSharedState } from '../core/sharedState.js'
import fs from 'fs';
import YAML from 'yaml';
export class MessageRecordPlugin extends plugin {
Expand Down Expand Up @@ -43,6 +44,10 @@ export class MessageRecordPlugin extends plugin {
async onMessage(e) {
await this.messageManager.recordMessage(e);
emojiPackManager.maybeAutoCollect(e).catch(() => {});
const memoryManager = getSharedState()?.memoryManager;
memoryManager?.enqueueGroupEvent(e).catch(error => {
logger.error(`[MemoryManager] 全局消息入队失败: ${error.stack || error}`);
});
return false;
}

Expand Down Expand Up @@ -209,4 +214,4 @@ export class MessageRecordPlugin extends plugin {
return false
}
}
}
}
30 changes: 4 additions & 26 deletions apps/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -810,15 +810,15 @@ export class ChatPlugin extends plugin {
session.groupUserMessages = this.trimMessageHistory(messages)
await this.saveGroupUserMessages(e.group_id, e.user_id, messages)

// 更新情感、记忆、表达学习(异步,不阻塞)
// 更新情感、关系分、表达学习(异步,不阻塞)
// 使用 e.msg 纯消息内容,而不是格式化的 userContent
this.updateEnhancedSystems(e, e.msg || '', output).catch(err => {
logger.error('[增强系统] 更新失败:', err)
})
}

/**
* 异步更新情感系统、长期记忆
* 异步更新情感系统和关系分;用户/群记忆由全局消息记录器处理
*/
async updateEnhancedSystems(e, userMessage, botReply) {
const { group_id: groupId, user_id: userId } = e
Expand All @@ -830,16 +830,9 @@ export class ChatPlugin extends plugin {
emotionState = await this.emotionManager.updateEmotionFromMessage(groupId, userMessage, isAtBot)
}

// 2. 提取并保存长期记忆(后台异步)
// 2. 更新关系分。用户记忆和群记忆都由全局消息记录器统一入队,
// 覆盖机器人未回复的真实群消息,并避免在回复路径重复抽取。
if (this.config.memorySystem?.enabled) {
// 不 await,让它在后台执行
this.memoryManager.extractAndSaveMemories(groupId, userId, userMessage, botReply, {
source: "user",
messageId: e.message_id,
senderName: e.sender?.card || e.sender?.nickname
}).catch(err => {
logger.error('[MemoryManager] 用户记忆提取异常:', err)
})
const latestEmotionEvent = emotionState?.recentEvents?.[0]
if (latestEmotionEvent && Number.isFinite(latestEmotionEvent.delta)) {
const relationDelta = Math.max(-0.03, Math.min(0.03, latestEmotionEvent.delta * 0.2))
Expand All @@ -849,21 +842,6 @@ export class ChatPlugin extends plugin {
})
}
}
// 提取群全局记忆(传入聊天记录)
if (groupId) {
const history = await this.messageManager.getMessages('group', groupId)
const chatHistory = (history || []).slice(0, 40).map(msg => ({
role: msg.sender?.user_id === Bot.uin ? 'assistant' : 'user',
source: msg.source || (msg.sender?.user_id === Bot.uin ? "send" : "user"),
userId: msg.sender?.user_id,
user_id: msg.sender?.user_id,
senderName: msg.sender?.nickname || msg.sender?.card || '群成员',
content: msg.content
}))
this.memoryManager.extractAndSaveGroupMemories(groupId, chatHistory).catch(err => {
logger.error('[MemoryManager] 群记忆提取异常:', err)
})
}
}

// 表达学习已移至 handleRandomReply 静默收集,不在此处调用
Expand Down
9 changes: 8 additions & 1 deletion apps/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,14 @@ export class MemoryCommands extends plugin {
})
}

await e.reply(result.deleted ? `已删除记忆 ${id}` : "没有找到可删除的记忆,普通用户只能删除自己的记忆")
const failureMessages = {
"id-too-short": "记忆 ID 至少需要输入前 8 位",
"ambiguous-id": "这个 ID 前缀匹配到多条记忆,请输入更完整的 ID",
"not-found": "没有找到可删除的记忆,普通用户只能删除自己的记忆"
}
await e.reply(result.deleted
? `已删除记忆 ${id}`
: failureMessages[result.reason] || "没有找到可删除的记忆,普通用户只能删除自己的记忆")
} catch (error) {
logger.error("[记忆管理] 删除记忆失败:", error)
await e.reply("删除记忆失败,请看日志")
Expand Down
2 changes: 1 addition & 1 deletion config_default/message.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ pluginSettings:
maxFactsPerGroup: 50
importanceThreshold: 0.5
memoryDecayDays: 7
userExtractDebounceSeconds: 90
userExtractDebounceSeconds: 45
userExtractMaxBatchMessages: 6
groupExtractMinIntervalMinutes: 10
groupExtractMaxBatchMessages: 12
Expand Down
3 changes: 3 additions & 0 deletions core/sharedState.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export function buildMemoryConfig(config) {
...memorySystem,
memoryAiConfig: config.memoryAiConfig || null,
embeddingAiConfig: config.embeddingAiConfig || null,
pluginEnabled: config.enabled !== false,
enableGroupWhitelist: Boolean(config.enableGroupWhitelist),
allowedGroups: Array.isArray(config.allowedGroups) ? config.allowedGroups : [],
groupExtractMinIntervalMinutes:
memorySystem.groupExtractMinIntervalMinutes ?? memorySystem.groupExtractMinInterval ?? 10
}
Expand Down
2 changes: 1 addition & 1 deletion models/Guoba/schemas/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export default [
label: "用户记忆提取防抖(秒)",
component: "InputNumber",
bottomHelpMessage: "用户对话结束后,等待 N 秒再触发记忆提取,避免短时间重复调用",
componentProps: { min: 0, max: 600, placeholder: "90" }
componentProps: { min: 0, max: 600, placeholder: "45" }
},
{
field: "memorySystem.userExtractMaxBatchMessages",
Expand Down
28 changes: 28 additions & 0 deletions tests/memory-helpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import {
normalizeText,
compactText,
containsToolFeedback,
isLikelyBotCommand,
isRealUserSource,
charJaccard,
isSimilarContent,
extractJsonArray,
parseJsonArrayResult,
keywordSet,
cosineSimilarity,
normalizeConfig
Expand Down Expand Up @@ -58,6 +60,12 @@ test("containsToolFeedback:识别工具痕迹标记", () => {
assert.equal(containsToolFeedback("今天天气不错"), false)
})

test("isLikelyBotCommand:过滤常见机器人命令前缀", () => {
assert.equal(isLikelyBotCommand("#表情包列表"), true)
assert.equal(isLikelyBotCommand("/help"), true)
assert.equal(isLikelyBotCommand("今天聊聊 #AI"), false)
})

test("isRealUserSource:仅用户来源为真", () => {
assert.equal(isRealUserSource(undefined), true)
assert.equal(isRealUserSource(""), true)
Expand Down Expand Up @@ -86,6 +94,15 @@ test("extractJsonArray:容忍围栏与解释文字", () => {
assert.deepEqual(extractJsonArray("完全不是 JSON"), [])
})

test("parseJsonArrayResult:区分空结果、非法格式并兼容包装对象", () => {
assert.deepEqual(parseJsonArrayResult("[]"), { items: [], status: "empty" })
assert.deepEqual(parseJsonArrayResult("完全不是 JSON"), { items: [], status: "invalid" })
assert.deepEqual(parseJsonArrayResult('{"operations":[{"operation":"upsert"}]}'), {
items: [{ operation: "upsert" }],
status: "ok"
})
})

test("keywordSet:分词并生成中文 2-gram", () => {
const set = keywordSet("hello 世界真好")
assert.equal(set.has("hello"), true)
Expand All @@ -109,6 +126,17 @@ test("normalizeConfig:默认值合并与数值兜底", () => {
const clamped = normalizeConfig({ importanceThreshold: 5, maxFactsPerUser: -3 })
assert.equal(clamped.importanceThreshold, 1)
assert.equal(clamped.maxFactsPerUser, 1)

const zeroValues = normalizeConfig({
userExtractDebounceSeconds: 0,
promptMaxUserFacts: 0,
promptMaxGroupFacts: 0,
minFactsPerCategory: 0
})
assert.equal(zeroValues.userExtractDebounceSeconds, 0)
assert.equal(zeroValues.promptMaxUserFacts, 0)
assert.equal(zeroValues.promptMaxGroupFacts, 0)
assert.equal(zeroValues.minFactsPerCategory, 0)
})

test("normalizeConfig:兼容旧字段 groupExtractMinInterval(毫秒/分钟自适应)", () => {
Expand Down
Loading
Loading