flashclaw 1.0.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/LICENSE +21 -0
- package/README.md +305 -0
- package/config/plugins.json +23 -0
- package/dist/agent-runner.d.ts +103 -0
- package/dist/agent-runner.d.ts.map +1 -0
- package/dist/agent-runner.js +530 -0
- package/dist/agent-runner.js.map +1 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +497 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands.d.ts +68 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +252 -0
- package/dist/commands.js.map +1 -0
- package/dist/config-schema.d.ts +21 -0
- package/dist/config-schema.d.ts.map +1 -0
- package/dist/config-schema.js +26 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +36 -0
- package/dist/config.js.map +1 -0
- package/dist/core/api-client.d.ts +236 -0
- package/dist/core/api-client.d.ts.map +1 -0
- package/dist/core/api-client.js +369 -0
- package/dist/core/api-client.js.map +1 -0
- package/dist/core/memory.d.ts +291 -0
- package/dist/core/memory.d.ts.map +1 -0
- package/dist/core/memory.js +754 -0
- package/dist/core/memory.js.map +1 -0
- package/dist/core/model-capabilities.d.ts +45 -0
- package/dist/core/model-capabilities.d.ts.map +1 -0
- package/dist/core/model-capabilities.js +85 -0
- package/dist/core/model-capabilities.js.map +1 -0
- package/dist/db.d.ts +103 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +380 -0
- package/dist/db.js.map +1 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +44 -0
- package/dist/errors.js.map +1 -0
- package/dist/health.d.ts +27 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +55 -0
- package/dist/health.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1181 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +19 -0
- package/dist/logger.js.map +1 -0
- package/dist/message-queue.d.ts +69 -0
- package/dist/message-queue.d.ts.map +1 -0
- package/dist/message-queue.js +198 -0
- package/dist/message-queue.js.map +1 -0
- package/dist/metrics.d.ts +46 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +101 -0
- package/dist/metrics.js.map +1 -0
- package/dist/paths.d.ts +81 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +127 -0
- package/dist/paths.js.map +1 -0
- package/dist/plugins/index.d.ts +9 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +13 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/installer.d.ts +120 -0
- package/dist/plugins/installer.d.ts.map +1 -0
- package/dist/plugins/installer.js +1008 -0
- package/dist/plugins/installer.js.map +1 -0
- package/dist/plugins/loader.d.ts +37 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +429 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/plugins/manager.d.ts +72 -0
- package/dist/plugins/manager.d.ts.map +1 -0
- package/dist/plugins/manager.js +187 -0
- package/dist/plugins/manager.js.map +1 -0
- package/dist/plugins/types.d.ts +101 -0
- package/dist/plugins/types.d.ts.map +1 -0
- package/dist/plugins/types.js +12 -0
- package/dist/plugins/types.js.map +1 -0
- package/dist/session-tracker.d.ts +81 -0
- package/dist/session-tracker.d.ts.map +1 -0
- package/dist/session-tracker.js +228 -0
- package/dist/session-tracker.js.map +1 -0
- package/dist/task-scheduler.d.ts +47 -0
- package/dist/task-scheduler.d.ts.map +1 -0
- package/dist/task-scheduler.js +331 -0
- package/dist/task-scheduler.js.map +1 -0
- package/dist/types.d.ts +57 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/env-substitute.d.ts +63 -0
- package/dist/utils/env-substitute.d.ts.map +1 -0
- package/dist/utils/env-substitute.js +133 -0
- package/dist/utils/env-substitute.js.map +1 -0
- package/dist/utils/log-rotate.d.ts +19 -0
- package/dist/utils/log-rotate.d.ts.map +1 -0
- package/dist/utils/log-rotate.js +85 -0
- package/dist/utils/log-rotate.js.map +1 -0
- package/dist/utils/rate-limiter.d.ts +38 -0
- package/dist/utils/rate-limiter.d.ts.map +1 -0
- package/dist/utils/rate-limiter.js +79 -0
- package/dist/utils/rate-limiter.js.map +1 -0
- package/dist/utils/retry.d.ts +10 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/retry.js +47 -0
- package/dist/utils/retry.js.map +1 -0
- package/dist/utils.d.ts +86 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +218 -0
- package/dist/utils.js.map +1 -0
- package/package.json +78 -0
- package/plugins/cancel-task/index.ts +161 -0
- package/plugins/cancel-task/plugin.json +9 -0
- package/plugins/feishu/index.ts +944 -0
- package/plugins/feishu/plugin.json +29 -0
- package/plugins/list-tasks/index.ts +150 -0
- package/plugins/list-tasks/plugin.json +9 -0
- package/plugins/memory/index.ts +190 -0
- package/plugins/memory/plugin.json +7 -0
- package/plugins/pause-task/index.ts +95 -0
- package/plugins/pause-task/plugin.json +8 -0
- package/plugins/register-group/index.ts +147 -0
- package/plugins/register-group/plugin.json +7 -0
- package/plugins/resume-task/index.ts +92 -0
- package/plugins/resume-task/plugin.json +8 -0
- package/plugins/schedule-task/index.ts +248 -0
- package/plugins/schedule-task/plugin.json +9 -0
- package/plugins/send-message/index.ts +75 -0
- package/plugins/send-message/plugin.json +9 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlashClaw 记忆系统
|
|
3
|
+
*
|
|
4
|
+
* 三层记忆架构:
|
|
5
|
+
* 1. 短期记忆 - 最近 N 条消息,保存在内存中
|
|
6
|
+
* 2. 长期记忆 - 重要信息,保存在 data/memory/{group}.md 文件
|
|
7
|
+
* 3. 上下文压缩 - 超长对话时自动摘要,减少 token 消耗
|
|
8
|
+
*
|
|
9
|
+
* 参考 OpenClaw 的 session-memory 设计
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { createLogger } from '../logger.js';
|
|
14
|
+
const logger = createLogger('MemoryManager');
|
|
15
|
+
// ==================== 记忆管理器实现 ====================
|
|
16
|
+
/**
|
|
17
|
+
* 记忆管理器
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* const memory = new MemoryManager({
|
|
22
|
+
* shortTermLimit: 50,
|
|
23
|
+
* compactThreshold: 80000,
|
|
24
|
+
* memoryDir: 'data/memory',
|
|
25
|
+
* compactKeepRecent: 10,
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* // 添加消息
|
|
29
|
+
* memory.addMessage('group1', { role: 'user', content: '你好' });
|
|
30
|
+
*
|
|
31
|
+
* // 获取上下文
|
|
32
|
+
* const context = memory.getContext('group1');
|
|
33
|
+
*
|
|
34
|
+
* // 记住重要信息
|
|
35
|
+
* memory.remember('group1', 'user_name', '张三');
|
|
36
|
+
*
|
|
37
|
+
* // 回忆信息
|
|
38
|
+
* const name = memory.recall('group1', 'user_name');
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export class MemoryManager {
|
|
42
|
+
config;
|
|
43
|
+
/** 短期记忆存储:groupId -> 消息列表 */
|
|
44
|
+
shortTermMemory = new Map();
|
|
45
|
+
/** 长期记忆缓存:groupId -> 记忆条目映射 */
|
|
46
|
+
longTermCache = new Map();
|
|
47
|
+
/** 用户级别记忆缓存:userId -> 记忆条目映射 */
|
|
48
|
+
userMemoryCache = new Map();
|
|
49
|
+
/** 压缩摘要缓存:groupId -> 摘要 */
|
|
50
|
+
summaryCache = new Map();
|
|
51
|
+
constructor(config = {}) {
|
|
52
|
+
this.config = {
|
|
53
|
+
contextTokenLimit: config.contextTokenLimit ?? 100000, // 发送给 AI 的上下文限制 100k tokens
|
|
54
|
+
compactThreshold: config.compactThreshold ?? 150000, // 自动压缩阈值 150k tokens
|
|
55
|
+
memoryDir: config.memoryDir ?? 'data/memory',
|
|
56
|
+
compactKeepTokens: config.compactKeepTokens ?? 30000, // 压缩后保留 30k tokens
|
|
57
|
+
};
|
|
58
|
+
// 确保记忆目录存在
|
|
59
|
+
this.ensureMemoryDir();
|
|
60
|
+
}
|
|
61
|
+
// ==================== 短期记忆 ====================
|
|
62
|
+
/**
|
|
63
|
+
* 获取群组的对话上下文
|
|
64
|
+
* 基于 token 限制返回消息(从最新到最旧)
|
|
65
|
+
*
|
|
66
|
+
* @param groupId - 群组 ID
|
|
67
|
+
* @param maxTokens - 最大 token 数(可选,默认使用配置值)
|
|
68
|
+
* @returns 消息列表
|
|
69
|
+
*/
|
|
70
|
+
getContext(groupId, maxTokens) {
|
|
71
|
+
const messages = this.shortTermMemory.get(groupId) || [];
|
|
72
|
+
const tokenLimit = maxTokens ?? this.config.contextTokenLimit;
|
|
73
|
+
if (messages.length === 0) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
// 从最新的消息开始,累计 token 直到达到限制
|
|
77
|
+
const result = [];
|
|
78
|
+
let totalTokens = 0;
|
|
79
|
+
// 从后往前遍历(最新的消息优先)
|
|
80
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
81
|
+
const msg = messages[i];
|
|
82
|
+
const msgTokens = this.estimateMessageTokens(msg);
|
|
83
|
+
if (totalTokens + msgTokens > tokenLimit) {
|
|
84
|
+
// 如果一条消息就超过限制,至少保留最新一条
|
|
85
|
+
if (result.length === 0) {
|
|
86
|
+
result.unshift(msg);
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
result.unshift(msg); // 添加到开头,保持顺序
|
|
91
|
+
totalTokens += msgTokens;
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 估算单条消息的 token 数
|
|
97
|
+
* 中文约 2 字符/token,英文约 4 字符/token
|
|
98
|
+
*/
|
|
99
|
+
estimateMessageTokens(message) {
|
|
100
|
+
const content = typeof message.content === 'string'
|
|
101
|
+
? message.content
|
|
102
|
+
: JSON.stringify(message.content);
|
|
103
|
+
// 保守估计:平均 2 字符/token,加上角色和格式开销
|
|
104
|
+
return Math.ceil(content.length / 2) + 10;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 添加消息到短期记忆
|
|
108
|
+
*
|
|
109
|
+
* @param groupId - 群组 ID
|
|
110
|
+
* @param message - 消息
|
|
111
|
+
*/
|
|
112
|
+
addMessage(groupId, message) {
|
|
113
|
+
if (!this.shortTermMemory.has(groupId)) {
|
|
114
|
+
this.shortTermMemory.set(groupId, []);
|
|
115
|
+
}
|
|
116
|
+
const messages = this.shortTermMemory.get(groupId);
|
|
117
|
+
messages.push({ ...message });
|
|
118
|
+
// 检查总 token 数,如果超过阈值的 2 倍,移除最旧的消息
|
|
119
|
+
// 这是一个软限制,真正的压缩在 needsCompaction 中触发
|
|
120
|
+
const maxStorageTokens = this.config.compactThreshold * 2;
|
|
121
|
+
while (this.estimateTokens(messages) > maxStorageTokens && messages.length > 10) {
|
|
122
|
+
messages.shift();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 批量添加消息
|
|
127
|
+
*
|
|
128
|
+
* @param groupId - 群组 ID
|
|
129
|
+
* @param messages - 消息列表
|
|
130
|
+
*/
|
|
131
|
+
addMessages(groupId, messages) {
|
|
132
|
+
for (const message of messages) {
|
|
133
|
+
this.addMessage(groupId, message);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 清除群组的短期记忆
|
|
138
|
+
*
|
|
139
|
+
* @param groupId - 群组 ID
|
|
140
|
+
*/
|
|
141
|
+
clearContext(groupId) {
|
|
142
|
+
this.shortTermMemory.delete(groupId);
|
|
143
|
+
this.summaryCache.delete(groupId);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* 获取消息数量
|
|
147
|
+
*
|
|
148
|
+
* @param groupId - 群组 ID
|
|
149
|
+
* @returns 消息数量
|
|
150
|
+
*/
|
|
151
|
+
getMessageCount(groupId) {
|
|
152
|
+
return this.shortTermMemory.get(groupId)?.length ?? 0;
|
|
153
|
+
}
|
|
154
|
+
// ==================== 长期记忆 ====================
|
|
155
|
+
/**
|
|
156
|
+
* 记住重要信息(持久化到文件)
|
|
157
|
+
*
|
|
158
|
+
* @param groupId - 群组 ID
|
|
159
|
+
* @param key - 记忆键
|
|
160
|
+
* @param value - 记忆值
|
|
161
|
+
*/
|
|
162
|
+
remember(groupId, key, value) {
|
|
163
|
+
// 确保缓存存在
|
|
164
|
+
if (!this.longTermCache.has(groupId)) {
|
|
165
|
+
this.loadLongTermMemory(groupId);
|
|
166
|
+
}
|
|
167
|
+
const cache = this.longTermCache.get(groupId);
|
|
168
|
+
const now = new Date().toISOString();
|
|
169
|
+
const existing = cache.get(key);
|
|
170
|
+
const entry = {
|
|
171
|
+
key,
|
|
172
|
+
value,
|
|
173
|
+
createdAt: existing?.createdAt ?? now,
|
|
174
|
+
updatedAt: now,
|
|
175
|
+
};
|
|
176
|
+
cache.set(key, entry);
|
|
177
|
+
// 保存到文件
|
|
178
|
+
this.saveLongTermMemory(groupId);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* 回忆信息
|
|
182
|
+
*
|
|
183
|
+
* @param groupId - 群组 ID
|
|
184
|
+
* @param key - 记忆键(可选,不提供则返回所有记忆)
|
|
185
|
+
* @returns 记忆值或格式化的所有记忆
|
|
186
|
+
*/
|
|
187
|
+
recall(groupId, key) {
|
|
188
|
+
// 确保缓存存在
|
|
189
|
+
if (!this.longTermCache.has(groupId)) {
|
|
190
|
+
this.loadLongTermMemory(groupId);
|
|
191
|
+
}
|
|
192
|
+
const cache = this.longTermCache.get(groupId);
|
|
193
|
+
if (key) {
|
|
194
|
+
return cache.get(key)?.value ?? '';
|
|
195
|
+
}
|
|
196
|
+
// 返回所有记忆的格式化文本
|
|
197
|
+
if (cache.size === 0) {
|
|
198
|
+
return '';
|
|
199
|
+
}
|
|
200
|
+
const lines = [];
|
|
201
|
+
for (const [k, entry] of cache) {
|
|
202
|
+
lines.push(`- ${k}: ${entry.value}`);
|
|
203
|
+
}
|
|
204
|
+
return lines.join('\n');
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 删除记忆
|
|
208
|
+
*
|
|
209
|
+
* @param groupId - 群组 ID
|
|
210
|
+
* @param key - 记忆键
|
|
211
|
+
*/
|
|
212
|
+
forget(groupId, key) {
|
|
213
|
+
if (!this.longTermCache.has(groupId)) {
|
|
214
|
+
this.loadLongTermMemory(groupId);
|
|
215
|
+
}
|
|
216
|
+
const cache = this.longTermCache.get(groupId);
|
|
217
|
+
if (cache.delete(key)) {
|
|
218
|
+
this.saveLongTermMemory(groupId);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* 获取所有记忆键
|
|
223
|
+
*
|
|
224
|
+
* @param groupId - 群组 ID
|
|
225
|
+
* @returns 记忆键列表
|
|
226
|
+
*/
|
|
227
|
+
getMemoryKeys(groupId) {
|
|
228
|
+
if (!this.longTermCache.has(groupId)) {
|
|
229
|
+
this.loadLongTermMemory(groupId);
|
|
230
|
+
}
|
|
231
|
+
return Array.from(this.longTermCache.get(groupId).keys());
|
|
232
|
+
}
|
|
233
|
+
// ==================== 用户级别记忆 ====================
|
|
234
|
+
/**
|
|
235
|
+
* 记住用户级别信息(跨会话共享)
|
|
236
|
+
*
|
|
237
|
+
* @param userId - 用户 ID
|
|
238
|
+
* @param key - 记忆键
|
|
239
|
+
* @param value - 记忆值
|
|
240
|
+
*/
|
|
241
|
+
rememberUser(userId, key, value) {
|
|
242
|
+
if (!this.userMemoryCache.has(userId)) {
|
|
243
|
+
this.loadUserMemory(userId);
|
|
244
|
+
}
|
|
245
|
+
const cache = this.userMemoryCache.get(userId);
|
|
246
|
+
const now = new Date().toISOString();
|
|
247
|
+
const existing = cache.get(key);
|
|
248
|
+
const entry = {
|
|
249
|
+
key,
|
|
250
|
+
value,
|
|
251
|
+
createdAt: existing?.createdAt ?? now,
|
|
252
|
+
updatedAt: now,
|
|
253
|
+
};
|
|
254
|
+
cache.set(key, entry);
|
|
255
|
+
this.saveUserMemory(userId);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* 回忆用户级别信息
|
|
259
|
+
*
|
|
260
|
+
* @param userId - 用户 ID
|
|
261
|
+
* @param key - 记忆键(可选,不提供则返回所有记忆)
|
|
262
|
+
* @returns 记忆值或格式化的所有记忆
|
|
263
|
+
*/
|
|
264
|
+
recallUser(userId, key) {
|
|
265
|
+
if (!this.userMemoryCache.has(userId)) {
|
|
266
|
+
this.loadUserMemory(userId);
|
|
267
|
+
}
|
|
268
|
+
const cache = this.userMemoryCache.get(userId);
|
|
269
|
+
if (key) {
|
|
270
|
+
return cache.get(key)?.value ?? '';
|
|
271
|
+
}
|
|
272
|
+
if (cache.size === 0) {
|
|
273
|
+
return '';
|
|
274
|
+
}
|
|
275
|
+
const lines = [];
|
|
276
|
+
for (const [k, entry] of cache) {
|
|
277
|
+
lines.push(`- ${k}: ${entry.value}`);
|
|
278
|
+
}
|
|
279
|
+
return lines.join('\n');
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* 删除用户级别记忆
|
|
283
|
+
*/
|
|
284
|
+
forgetUser(userId, key) {
|
|
285
|
+
if (!this.userMemoryCache.has(userId)) {
|
|
286
|
+
this.loadUserMemory(userId);
|
|
287
|
+
}
|
|
288
|
+
const cache = this.userMemoryCache.get(userId);
|
|
289
|
+
if (cache.delete(key)) {
|
|
290
|
+
this.saveUserMemory(userId);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* 获取用户文件路径
|
|
295
|
+
*/
|
|
296
|
+
getUserMemoryFilePath(userId) {
|
|
297
|
+
const safeId = userId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
298
|
+
return path.join(this.config.memoryDir, 'users', `${safeId}.md`);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* 加载用户级别记忆
|
|
302
|
+
*/
|
|
303
|
+
loadUserMemory(userId) {
|
|
304
|
+
const cache = new Map();
|
|
305
|
+
this.userMemoryCache.set(userId, cache);
|
|
306
|
+
const filePath = this.getUserMemoryFilePath(userId);
|
|
307
|
+
if (!fs.existsSync(filePath)) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
312
|
+
const entries = this.parseMemoryFile(content);
|
|
313
|
+
for (const entry of entries) {
|
|
314
|
+
cache.set(entry.key, entry);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
logger.error({ path: filePath, error }, '加载用户记忆文件失败');
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* 保存用户级别记忆
|
|
323
|
+
*/
|
|
324
|
+
saveUserMemory(userId) {
|
|
325
|
+
const cache = this.userMemoryCache.get(userId);
|
|
326
|
+
if (!cache)
|
|
327
|
+
return;
|
|
328
|
+
const filePath = this.getUserMemoryFilePath(userId);
|
|
329
|
+
// 确保目录存在
|
|
330
|
+
const dir = path.dirname(filePath);
|
|
331
|
+
if (!fs.existsSync(dir)) {
|
|
332
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
333
|
+
}
|
|
334
|
+
const content = this.formatMemoryFile(`用户 ${userId}`, cache);
|
|
335
|
+
try {
|
|
336
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
logger.error({ path: filePath, error }, '保存用户记忆文件失败');
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* 构建包含用户记忆的系统提示词
|
|
344
|
+
*/
|
|
345
|
+
buildUserSystemPrompt(userId, basePrompt) {
|
|
346
|
+
const parts = [];
|
|
347
|
+
if (basePrompt) {
|
|
348
|
+
parts.push(basePrompt);
|
|
349
|
+
}
|
|
350
|
+
// 添加用户级别记忆
|
|
351
|
+
const userMemories = this.recallUser(userId);
|
|
352
|
+
if (userMemories) {
|
|
353
|
+
parts.push(`\n## 关于这个用户的记忆(跨会话共享)\n${userMemories}`);
|
|
354
|
+
}
|
|
355
|
+
return parts.join('\n\n');
|
|
356
|
+
}
|
|
357
|
+
// ==================== 上下文压缩 ====================
|
|
358
|
+
/**
|
|
359
|
+
* 估算消息的 token 数量(简单估算)
|
|
360
|
+
* 中文约 2 字符/token,英文约 4 字符/token
|
|
361
|
+
*
|
|
362
|
+
* @param messages - 消息列表
|
|
363
|
+
* @returns 估算的 token 数
|
|
364
|
+
*/
|
|
365
|
+
estimateTokens(messages) {
|
|
366
|
+
let totalTokens = 0;
|
|
367
|
+
for (const msg of messages) {
|
|
368
|
+
totalTokens += this.estimateMessageTokens(msg);
|
|
369
|
+
}
|
|
370
|
+
return totalTokens;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* 检查是否需要压缩
|
|
374
|
+
*
|
|
375
|
+
* @param groupId - 群组 ID
|
|
376
|
+
* @returns 是否需要压缩
|
|
377
|
+
*/
|
|
378
|
+
needsCompaction(groupId) {
|
|
379
|
+
const messages = this.shortTermMemory.get(groupId) || [];
|
|
380
|
+
const estimatedTokens = this.estimateTokens(messages);
|
|
381
|
+
return estimatedTokens > this.config.compactThreshold;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* 压缩对话上下文
|
|
385
|
+
* 将旧消息总结为摘要,只保留最近的消息(基于 token 限制)
|
|
386
|
+
*
|
|
387
|
+
* @param groupId - 群组 ID
|
|
388
|
+
* @param apiClient - API 客户端(用于生成摘要)
|
|
389
|
+
* @returns 压缩结果
|
|
390
|
+
*/
|
|
391
|
+
async compact(groupId, apiClient) {
|
|
392
|
+
const messages = this.shortTermMemory.get(groupId) || [];
|
|
393
|
+
const originalCount = messages.length;
|
|
394
|
+
const originalTokens = this.estimateTokens(messages);
|
|
395
|
+
if (originalTokens <= this.config.compactKeepTokens) {
|
|
396
|
+
// token 数太少,无需压缩
|
|
397
|
+
return {
|
|
398
|
+
originalCount,
|
|
399
|
+
compactedCount: originalCount,
|
|
400
|
+
summary: '',
|
|
401
|
+
savedTokens: 0,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
// 基于 token 数量决定保留多少消息
|
|
405
|
+
// 从最新的消息开始,累计 token 直到达到 compactKeepTokens
|
|
406
|
+
const toKeep = [];
|
|
407
|
+
let keepTokens = 0;
|
|
408
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
409
|
+
const msg = messages[i];
|
|
410
|
+
const msgTokens = this.estimateMessageTokens(msg);
|
|
411
|
+
if (keepTokens + msgTokens > this.config.compactKeepTokens) {
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
toKeep.unshift(msg);
|
|
415
|
+
keepTokens += msgTokens;
|
|
416
|
+
}
|
|
417
|
+
// 要压缩的消息(旧消息)
|
|
418
|
+
const toCompress = messages.slice(0, messages.length - toKeep.length);
|
|
419
|
+
if (toCompress.length === 0) {
|
|
420
|
+
return {
|
|
421
|
+
originalCount,
|
|
422
|
+
compactedCount: originalCount,
|
|
423
|
+
summary: '',
|
|
424
|
+
savedTokens: 0,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
// 生成摘要
|
|
428
|
+
let summary = '';
|
|
429
|
+
try {
|
|
430
|
+
summary = await this.generateSummary(toCompress, apiClient);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
logger.error({ error, groupId }, '生成摘要失败,跳过压缩');
|
|
434
|
+
return {
|
|
435
|
+
originalCount,
|
|
436
|
+
compactedCount: originalCount,
|
|
437
|
+
summary: '',
|
|
438
|
+
savedTokens: 0,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
// 估算节省的 token
|
|
442
|
+
const compressedTokens = this.estimateTokens(toCompress);
|
|
443
|
+
const summaryTokens = Math.ceil(summary.length / 2);
|
|
444
|
+
const savedTokens = Math.max(0, compressedTokens - summaryTokens);
|
|
445
|
+
// 更新短期记忆
|
|
446
|
+
this.shortTermMemory.set(groupId, toKeep);
|
|
447
|
+
// 缓存摘要
|
|
448
|
+
this.summaryCache.set(groupId, summary);
|
|
449
|
+
logger.info({
|
|
450
|
+
groupId,
|
|
451
|
+
originalTokens,
|
|
452
|
+
compressedTokens,
|
|
453
|
+
keepTokens,
|
|
454
|
+
savedTokens,
|
|
455
|
+
originalCount,
|
|
456
|
+
compactedCount: toKeep.length
|
|
457
|
+
}, '📦 上下文已压缩');
|
|
458
|
+
return {
|
|
459
|
+
originalCount,
|
|
460
|
+
compactedCount: toKeep.length,
|
|
461
|
+
summary,
|
|
462
|
+
savedTokens,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* 生成对话摘要
|
|
467
|
+
*
|
|
468
|
+
* @param messages - 要压缩的消息
|
|
469
|
+
* @param apiClient - API 客户端
|
|
470
|
+
* @returns 摘要文本
|
|
471
|
+
*/
|
|
472
|
+
async generateSummary(messages, apiClient) {
|
|
473
|
+
// 格式化消息为文本
|
|
474
|
+
const conversationText = messages
|
|
475
|
+
.map(msg => `${msg.role === 'user' ? '用户' : '助手'}: ${msg.content}`)
|
|
476
|
+
.join('\n\n');
|
|
477
|
+
// 使用 AI 生成摘要
|
|
478
|
+
const response = await apiClient.chat([
|
|
479
|
+
{
|
|
480
|
+
role: 'user',
|
|
481
|
+
content: `请将以下对话内容压缩成一个简洁的摘要,保留关键信息、用户偏好、重要决定和上下文。摘要应该帮助后续对话理解之前的背景。
|
|
482
|
+
|
|
483
|
+
对话内容:
|
|
484
|
+
${conversationText}
|
|
485
|
+
|
|
486
|
+
请用中文输出摘要,格式为:
|
|
487
|
+
## 对话摘要
|
|
488
|
+
[简洁的摘要内容]`,
|
|
489
|
+
},
|
|
490
|
+
], {
|
|
491
|
+
system: '你是一个专业的对话摘要助手。你的任务是将长对话压缩成简洁但信息丰富的摘要。',
|
|
492
|
+
maxTokens: 1024,
|
|
493
|
+
temperature: 0.3,
|
|
494
|
+
});
|
|
495
|
+
return apiClient.extractText(response);
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* 获取压缩摘要
|
|
499
|
+
*
|
|
500
|
+
* @param groupId - 群组 ID
|
|
501
|
+
* @returns 摘要文本,如果没有则返回空字符串
|
|
502
|
+
*/
|
|
503
|
+
getSummary(groupId) {
|
|
504
|
+
return this.summaryCache.get(groupId) ?? '';
|
|
505
|
+
}
|
|
506
|
+
// ==================== 系统提示词构建 ====================
|
|
507
|
+
/**
|
|
508
|
+
* 构建包含长期记忆的系统提示词
|
|
509
|
+
*
|
|
510
|
+
* @param groupId - 群组 ID
|
|
511
|
+
* @param basePrompt - 基础系统提示词
|
|
512
|
+
* @returns 完整的系统提示词
|
|
513
|
+
*/
|
|
514
|
+
buildSystemPrompt(groupId, basePrompt) {
|
|
515
|
+
const parts = [];
|
|
516
|
+
// 基础提示词
|
|
517
|
+
if (basePrompt) {
|
|
518
|
+
parts.push(basePrompt);
|
|
519
|
+
}
|
|
520
|
+
// 添加压缩摘要(如果有)
|
|
521
|
+
const summary = this.getSummary(groupId);
|
|
522
|
+
if (summary) {
|
|
523
|
+
parts.push(`\n## 之前对话的摘要\n${summary}`);
|
|
524
|
+
}
|
|
525
|
+
// 添加长期记忆
|
|
526
|
+
const memories = this.recall(groupId);
|
|
527
|
+
if (memories) {
|
|
528
|
+
parts.push(`\n## 关于这个群组/用户的记忆\n${memories}`);
|
|
529
|
+
}
|
|
530
|
+
return parts.join('\n\n');
|
|
531
|
+
}
|
|
532
|
+
// ==================== 持久化 ====================
|
|
533
|
+
/**
|
|
534
|
+
* 确保记忆目录存在
|
|
535
|
+
*/
|
|
536
|
+
ensureMemoryDir() {
|
|
537
|
+
if (!fs.existsSync(this.config.memoryDir)) {
|
|
538
|
+
fs.mkdirSync(this.config.memoryDir, { recursive: true });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* 获取群组的记忆文件路径
|
|
543
|
+
*/
|
|
544
|
+
getMemoryFilePath(groupId) {
|
|
545
|
+
// 清理 groupId 中的特殊字符
|
|
546
|
+
const safeId = groupId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
547
|
+
return path.join(this.config.memoryDir, `${safeId}.md`);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* 加载长期记忆
|
|
551
|
+
*/
|
|
552
|
+
loadLongTermMemory(groupId) {
|
|
553
|
+
const cache = new Map();
|
|
554
|
+
this.longTermCache.set(groupId, cache);
|
|
555
|
+
const filePath = this.getMemoryFilePath(groupId);
|
|
556
|
+
if (!fs.existsSync(filePath)) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
561
|
+
const entries = this.parseMemoryFile(content);
|
|
562
|
+
for (const entry of entries) {
|
|
563
|
+
cache.set(entry.key, entry);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
// 解析失败,使用空缓存
|
|
568
|
+
logger.error({ path: filePath, error }, '加载记忆文件失败');
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* 保存长期记忆
|
|
573
|
+
*/
|
|
574
|
+
saveLongTermMemory(groupId) {
|
|
575
|
+
const cache = this.longTermCache.get(groupId);
|
|
576
|
+
if (!cache)
|
|
577
|
+
return;
|
|
578
|
+
const filePath = this.getMemoryFilePath(groupId);
|
|
579
|
+
const content = this.formatMemoryFile(groupId, cache);
|
|
580
|
+
try {
|
|
581
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
582
|
+
}
|
|
583
|
+
catch (error) {
|
|
584
|
+
logger.error({ path: filePath, error }, '保存记忆文件失败');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* 解析记忆文件
|
|
589
|
+
*/
|
|
590
|
+
parseMemoryFile(content) {
|
|
591
|
+
const entries = [];
|
|
592
|
+
// 解析 Markdown 格式的记忆条目
|
|
593
|
+
// 格式:### key
|
|
594
|
+
// value
|
|
595
|
+
// <!-- created: ISO, updated: ISO -->
|
|
596
|
+
const lines = content.split('\n');
|
|
597
|
+
let currentKey = null;
|
|
598
|
+
let currentValue = [];
|
|
599
|
+
let currentCreated = '';
|
|
600
|
+
let currentUpdated = '';
|
|
601
|
+
for (const line of lines) {
|
|
602
|
+
// 检查是否是新的条目标题
|
|
603
|
+
const keyMatch = line.match(/^### (.+)$/);
|
|
604
|
+
if (keyMatch) {
|
|
605
|
+
// 保存之前的条目
|
|
606
|
+
if (currentKey) {
|
|
607
|
+
entries.push({
|
|
608
|
+
key: currentKey,
|
|
609
|
+
value: currentValue.join('\n').trim(),
|
|
610
|
+
createdAt: currentCreated || new Date().toISOString(),
|
|
611
|
+
updatedAt: currentUpdated || new Date().toISOString(),
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
currentKey = keyMatch[1].trim();
|
|
615
|
+
currentValue = [];
|
|
616
|
+
currentCreated = '';
|
|
617
|
+
currentUpdated = '';
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
// 检查是否是元数据注释
|
|
621
|
+
const metaMatch = line.match(/<!-- created: (.+), updated: (.+) -->/);
|
|
622
|
+
if (metaMatch) {
|
|
623
|
+
currentCreated = metaMatch[1];
|
|
624
|
+
currentUpdated = metaMatch[2];
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
// 跳过文件头
|
|
628
|
+
if (line.startsWith('# ') || line.startsWith('> ')) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
// 添加到当前值
|
|
632
|
+
if (currentKey) {
|
|
633
|
+
currentValue.push(line);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// 保存最后一个条目
|
|
637
|
+
if (currentKey) {
|
|
638
|
+
entries.push({
|
|
639
|
+
key: currentKey,
|
|
640
|
+
value: currentValue.join('\n').trim(),
|
|
641
|
+
createdAt: currentCreated || new Date().toISOString(),
|
|
642
|
+
updatedAt: currentUpdated || new Date().toISOString(),
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
return entries;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* 格式化记忆文件
|
|
649
|
+
*/
|
|
650
|
+
formatMemoryFile(groupId, cache) {
|
|
651
|
+
const lines = [
|
|
652
|
+
`# ${groupId} 的长期记忆`,
|
|
653
|
+
'',
|
|
654
|
+
`> 最后更新: ${new Date().toISOString()}`,
|
|
655
|
+
'',
|
|
656
|
+
];
|
|
657
|
+
for (const [key, entry] of cache) {
|
|
658
|
+
lines.push(`### ${key}`);
|
|
659
|
+
lines.push('');
|
|
660
|
+
lines.push(entry.value);
|
|
661
|
+
lines.push('');
|
|
662
|
+
lines.push(`<!-- created: ${entry.createdAt}, updated: ${entry.updatedAt} -->`);
|
|
663
|
+
lines.push('');
|
|
664
|
+
}
|
|
665
|
+
return lines.join('\n');
|
|
666
|
+
}
|
|
667
|
+
// ==================== 会话导出 ====================
|
|
668
|
+
/**
|
|
669
|
+
* 导出会话历史到 Markdown 文件
|
|
670
|
+
* 类似 OpenClaw 的 session-memory hook
|
|
671
|
+
*
|
|
672
|
+
* @param groupId - 群组 ID
|
|
673
|
+
* @param filename - 文件名(可选,自动生成)
|
|
674
|
+
* @returns 保存的文件路径
|
|
675
|
+
*/
|
|
676
|
+
exportSession(groupId, filename) {
|
|
677
|
+
const messages = this.shortTermMemory.get(groupId) || [];
|
|
678
|
+
if (messages.length === 0) {
|
|
679
|
+
throw new Error('没有可导出的会话消息');
|
|
680
|
+
}
|
|
681
|
+
// 生成文件名
|
|
682
|
+
const date = new Date().toISOString().split('T')[0];
|
|
683
|
+
const safeName = filename
|
|
684
|
+
? filename.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
685
|
+
: `session_${Date.now()}`;
|
|
686
|
+
const exportFilename = `${date}-${safeName}.md`;
|
|
687
|
+
const exportPath = path.join(this.config.memoryDir, 'sessions', exportFilename);
|
|
688
|
+
// 确保目录存在
|
|
689
|
+
const sessionsDir = path.dirname(exportPath);
|
|
690
|
+
if (!fs.existsSync(sessionsDir)) {
|
|
691
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
692
|
+
}
|
|
693
|
+
// 格式化内容
|
|
694
|
+
const lines = [
|
|
695
|
+
`# 会话记录: ${groupId}`,
|
|
696
|
+
'',
|
|
697
|
+
`> 导出时间: ${new Date().toISOString()}`,
|
|
698
|
+
`> 消息数量: ${messages.length}`,
|
|
699
|
+
'',
|
|
700
|
+
'---',
|
|
701
|
+
'',
|
|
702
|
+
];
|
|
703
|
+
for (const msg of messages) {
|
|
704
|
+
const role = msg.role === 'user' ? '👤 用户' : '🤖 助手';
|
|
705
|
+
lines.push(`## ${role}`);
|
|
706
|
+
lines.push('');
|
|
707
|
+
// content 可能是字符串或数组
|
|
708
|
+
if (typeof msg.content === 'string') {
|
|
709
|
+
lines.push(msg.content);
|
|
710
|
+
}
|
|
711
|
+
else if (Array.isArray(msg.content)) {
|
|
712
|
+
// 提取文本内容
|
|
713
|
+
const textContent = msg.content
|
|
714
|
+
.filter((block) => block.type === 'text')
|
|
715
|
+
.map(block => block.text)
|
|
716
|
+
.join('\n');
|
|
717
|
+
lines.push(textContent || '[包含图片/媒体内容]');
|
|
718
|
+
}
|
|
719
|
+
lines.push('');
|
|
720
|
+
}
|
|
721
|
+
fs.writeFileSync(exportPath, lines.join('\n'), 'utf-8');
|
|
722
|
+
return exportPath;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* 获取全局记忆管理器实例
|
|
727
|
+
* 确保所有模块(包括 jiti 加载的插件)使用同一个实例
|
|
728
|
+
*/
|
|
729
|
+
export function getMemoryManager() {
|
|
730
|
+
if (!global.__flashclaw_memory_manager) {
|
|
731
|
+
global.__flashclaw_memory_manager = new MemoryManager({
|
|
732
|
+
memoryDir: 'data/memory',
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
return global.__flashclaw_memory_manager;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* 创建默认记忆管理器
|
|
739
|
+
*
|
|
740
|
+
* @param baseDir - 基础目录(可选)
|
|
741
|
+
* @returns 记忆管理器实例
|
|
742
|
+
* @deprecated 使用 getMemoryManager() 获取全局单例
|
|
743
|
+
*/
|
|
744
|
+
export function createMemoryManager(baseDir) {
|
|
745
|
+
// 如果已有全局实例,返回它
|
|
746
|
+
if (global.__flashclaw_memory_manager) {
|
|
747
|
+
return global.__flashclaw_memory_manager;
|
|
748
|
+
}
|
|
749
|
+
// 否则创建新实例
|
|
750
|
+
return new MemoryManager({
|
|
751
|
+
memoryDir: baseDir ? path.join(baseDir, 'memory') : 'data/memory',
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
//# sourceMappingURL=memory.js.map
|