dsh-qqchat 0.2.4 → 0.2.5

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.
Files changed (36) hide show
  1. package/client-src/settings.cts +18 -1
  2. package/client-src/shared.cts +2 -2
  3. package/docs/MEMORY.md +32 -4
  4. package/docs/MEMORY_CONTEXT.md +16 -0
  5. package/docs/README.md +2 -0
  6. package/docs/prds/PRD-MEM-1-/347/276/244/350/256/260/345/277/206/344/270/216/347/276/244/345/217/213/350/256/260/345/277/206/347/255/226/347/225/245/350/260/203/346/225/264.md +396 -0
  7. package/docs/prds/README.md +62 -0
  8. package/lib/client.js +19 -2
  9. package/lib/config.js +3 -2
  10. package/lib/index.js +0 -1
  11. package/lib/session/agent-bridge.d.ts +0 -4
  12. package/lib/session/agent-bridge.js +0 -35
  13. package/lib/session/memory-snapshot.d.ts +4 -0
  14. package/lib/session/memory-snapshot.js +6 -2
  15. package/lib/session/runtime.js +14 -5
  16. package/lib/storage/db.d.ts +6 -1
  17. package/lib/storage/db.js +57 -0
  18. package/lib/storage/memory.d.ts +28 -2
  19. package/lib/storage/memory.js +375 -101
  20. package/lib/transport/rpc.js +2 -0
  21. package/lib/types.d.ts +26 -0
  22. package/package.json +2 -1
  23. package/prompts/group-compression.md +5 -0
  24. package/prompts/group-reflection.md +39 -0
  25. package/prompts/member-batch-reflection.md +39 -0
  26. package/prompts/member-compression.md +5 -0
  27. package/prompts/private-reflection.md +32 -0
  28. package/src/config.ts +3 -2
  29. package/src/index.ts +0 -1
  30. package/src/session/agent-bridge.ts +0 -35
  31. package/src/session/memory-snapshot.ts +6 -3
  32. package/src/session/runtime.ts +13 -4
  33. package/src/storage/db.ts +70 -0
  34. package/src/storage/memory.ts +343 -84
  35. package/src/transport/rpc.ts +1 -0
  36. package/src/types.ts +30 -0
@@ -1,6 +1,7 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
2
  import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm'
3
3
  import { SessionId } from '@deepseek-ai/dsh-session'
4
+ import { readFileSync } from 'node:fs'
4
5
  import type { QQChatDatabase } from './db.js'
5
6
  import type {
6
7
  GroupMemberRow,
@@ -15,6 +16,7 @@ import type {
15
16
  QQChatConfig,
16
17
  DailyEntry,
17
18
  ReflectionPayload,
19
+ MemberBatchReflectionPayload,
18
20
  ProfileEntry,
19
21
  ProfileEntryType,
20
22
  QQNormalizedMessage,
@@ -23,6 +25,10 @@ import type {
23
25
  const DOCS = ['profile', 'summary', 'daily', 'memory', 'pattern'] as const satisfies readonly MemoryDocType[]
24
26
 
25
27
  interface MemberReflectionPayload {
28
+ profile_add?: unknown
29
+ profile_remove?: unknown
30
+ pattern_add?: unknown
31
+ pattern_remove?: unknown
26
32
  profile?: unknown
27
33
  pattern?: unknown
28
34
  summary?: unknown
@@ -32,16 +38,25 @@ interface MemberReflectionPayload {
32
38
 
33
39
  const MEMBER_DAILY_COMPACT_AT = 100
34
40
  const MEMBER_DAILY_KEEP_RECENT = 50
35
- const GROUP_DAILY_COMPACT_AT = 1000
36
- const GROUP_DAILY_KEEP_RECENT = 500
41
+ const GROUP_DAILY_COMPACT_AT = 200
42
+ const GROUP_DAILY_KEEP_RECENT = 100
43
+ const MEMBER_BATCH_SIZE = 50
44
+ const REFLECTION_RETRY_COOLDOWN_MS = 60_000
45
+ /** 与咕咕一致:每个记忆 scope 的本轮直注入预算(按字符计)。 */
46
+ export const MEMORY_INJECT_CHARS = 2_000
37
47
 
38
48
  export class MemoryEngine {
39
49
  private readonly timers = new Map<number, ReturnType<typeof setTimeout>>()
40
50
  private readonly memberTimers = new Map<number, ReturnType<typeof setTimeout>>()
51
+ private readonly memberBatchTimers = new Map<number, ReturnType<typeof setTimeout>>()
41
52
  private readonly routes = new Map<number, ModelRoute>()
42
53
  private readonly memberRoutes = new Map<number, ModelRoute>()
43
54
  private readonly reflectingGroups = new Map<number, Promise<MemoryView>>()
44
55
  private readonly reflectingMembers = new Map<number, Promise<MemoryDocuments>>()
56
+ private readonly reflectingMemberBatches = new Map<number, Promise<MemoryView>>()
57
+ private readonly groupRetryAt = new Map<number, number>()
58
+ private readonly memberRetryAt = new Map<number, number>()
59
+ private readonly memberBatchRetryAt = new Map<number, number>()
45
60
 
46
61
  constructor(
47
62
  private readonly ctx: Context,
@@ -53,10 +68,16 @@ export class MemoryEngine {
53
68
  dispose(): void {
54
69
  for (const timer of this.timers.values()) clearTimeout(timer)
55
70
  for (const timer of this.memberTimers.values()) clearTimeout(timer)
71
+ for (const timer of this.memberBatchTimers.values()) clearTimeout(timer)
56
72
  this.timers.clear()
57
73
  this.memberTimers.clear()
74
+ this.memberBatchTimers.clear()
58
75
  this.reflectingGroups.clear()
59
76
  this.reflectingMembers.clear()
77
+ this.reflectingMemberBatches.clear()
78
+ this.groupRetryAt.clear()
79
+ this.memberRetryAt.clear()
80
+ this.memberBatchRetryAt.clear()
60
81
  }
61
82
 
62
83
  setRoute(groupId: number, provider: string, model: string, sessionId: string): void {
@@ -74,14 +95,11 @@ export class MemoryEngine {
74
95
  if (!this.routes.has(groupId)) return
75
96
  const existing = this.timers.get(groupId)
76
97
  if (existing) clearTimeout(existing)
77
- if (this.db.unreflectedCount(groupId) >= this.config.reflectionBatchSize) {
78
- void this.reflectNow(groupId).catch(error => this.logger.warn?.(`[dsh-qqchat] memory reflection: ${errorMessage(error)}`))
79
- return
80
- }
81
98
  const timer = setTimeout(() => {
82
99
  this.timers.delete(groupId)
83
- void this.reflectNow(groupId).catch(error => this.logger.warn?.(`[dsh-qqchat] memory reflection: ${errorMessage(error)}`))
84
- }, this.config.reflectionIdleMs)
100
+ if (this.inRetryCooldown(this.groupRetryAt, groupId)) this.schedule(groupId)
101
+ else void this.runScheduledGroupReflection(groupId)
102
+ }, this.reflectionDelay(this.groupRetryAt, groupId))
85
103
  timer.unref?.()
86
104
  this.timers.set(groupId, timer)
87
105
  }
@@ -91,18 +109,80 @@ export class MemoryEngine {
91
109
  if (!this.memberRoutes.has(memberId)) return
92
110
  const existing = this.memberTimers.get(memberId)
93
111
  if (existing) clearTimeout(existing)
94
- if (this.unreflectedDirectMessages(memberId, this.config.reflectionBatchSize).length >= this.config.reflectionBatchSize) {
95
- void this.reflectMemberNow(memberId).catch(error => this.logger.warn?.(`[dsh-qqchat] member memory reflection: ${errorMessage(error)}`))
112
+ if (this.unreflectedDirectMessages(memberId, this.config.reflectionBatchSize).length >= this.config.reflectionBatchSize && !this.inRetryCooldown(this.memberRetryAt, memberId)) {
113
+ void this.runScheduledMemberReflection(memberId)
96
114
  return
97
115
  }
98
116
  const timer = setTimeout(() => {
99
117
  this.memberTimers.delete(memberId)
100
- void this.reflectMemberNow(memberId).catch(error => this.logger.warn?.(`[dsh-qqchat] member memory reflection: ${errorMessage(error)}`))
101
- }, this.config.reflectionIdleMs)
118
+ if (this.inRetryCooldown(this.memberRetryAt, memberId)) this.scheduleMember(memberId)
119
+ else void this.runScheduledMemberReflection(memberId)
120
+ }, this.reflectionDelay(this.memberRetryAt, memberId))
102
121
  timer.unref?.()
103
122
  this.memberTimers.set(memberId, timer)
104
123
  }
105
124
 
125
+ scheduleGroupMembers(groupId: number): void {
126
+ groupId = Number(groupId)
127
+ if (!this.routes.has(groupId)) return
128
+ const existing = this.memberBatchTimers.get(groupId)
129
+ if (existing) clearTimeout(existing)
130
+ if (this.db.unreflectedMemberCount(groupId) >= MEMBER_BATCH_SIZE && !this.inRetryCooldown(this.memberBatchRetryAt, groupId)) {
131
+ void this.runScheduledMemberBatchReflection(groupId)
132
+ return
133
+ }
134
+ const timer = setTimeout(() => {
135
+ this.memberBatchTimers.delete(groupId)
136
+ if (this.inRetryCooldown(this.memberBatchRetryAt, groupId)) this.scheduleGroupMembers(groupId)
137
+ else void this.runScheduledMemberBatchReflection(groupId)
138
+ }, this.reflectionDelay(this.memberBatchRetryAt, groupId))
139
+ timer.unref?.()
140
+ this.memberBatchTimers.set(groupId, timer)
141
+ }
142
+
143
+ private inRetryCooldown(retryAt: Map<number, number>, id: number): boolean {
144
+ const until = retryAt.get(id) || 0
145
+ if (until <= Date.now()) {
146
+ retryAt.delete(id)
147
+ return false
148
+ }
149
+ return true
150
+ }
151
+
152
+ private reflectionDelay(retryAt: Map<number, number>, id: number): number {
153
+ return Math.max(this.config.reflectionIdleMs, (retryAt.get(id) || 0) - Date.now())
154
+ }
155
+
156
+ private async runScheduledGroupReflection(groupId: number): Promise<void> {
157
+ try {
158
+ await this.reflectNow(groupId)
159
+ this.groupRetryAt.delete(groupId)
160
+ } catch (error) {
161
+ this.groupRetryAt.set(groupId, Date.now() + REFLECTION_RETRY_COOLDOWN_MS)
162
+ this.logger.warn?.(`[dsh-qqchat] memory reflection: ${errorMessage(error)}`)
163
+ }
164
+ }
165
+
166
+ private async runScheduledMemberReflection(memberId: number): Promise<void> {
167
+ try {
168
+ await this.reflectMemberNow(memberId)
169
+ this.memberRetryAt.delete(memberId)
170
+ } catch (error) {
171
+ this.memberRetryAt.set(memberId, Date.now() + REFLECTION_RETRY_COOLDOWN_MS)
172
+ this.logger.warn?.(`[dsh-qqchat] member memory reflection: ${errorMessage(error)}`)
173
+ }
174
+ }
175
+
176
+ private async runScheduledMemberBatchReflection(groupId: number): Promise<void> {
177
+ try {
178
+ await this.reflectMembersNow(groupId)
179
+ this.memberBatchRetryAt.delete(groupId)
180
+ } catch (error) {
181
+ this.memberBatchRetryAt.set(groupId, Date.now() + REFLECTION_RETRY_COOLDOWN_MS)
182
+ this.logger.warn?.(`[dsh-qqchat] member batch reflection: ${errorMessage(error)}`)
183
+ }
184
+ }
185
+
106
186
  async reflectNow(groupId: number): Promise<MemoryView> {
107
187
  groupId = Number(groupId)
108
188
  const existing = this.reflectingGroups.get(groupId)
@@ -126,21 +206,20 @@ export class MemoryEngine {
126
206
  if (!view) throw new Error('群不存在')
127
207
  return view
128
208
  }
209
+ const taskKey = `group:${groupId}:${Number(messages[0]?.id)}:${Number(messages.at(-1)?.id)}`
210
+ if (!this.db.startReflectionTask({
211
+ scopeType: 'group', scopeKey: groupId, taskType: 'group',
212
+ startMessageId: Number(messages[0]?.id), endMessageId: Number(messages.at(-1)?.id), idempotencyKey: taskKey,
213
+ })) {
214
+ const view = this.memoryView(groupId)
215
+ if (!view) throw new Error('群不存在')
216
+ return view
217
+ }
218
+ const startedAt = Date.now()
219
+ try {
129
220
 
130
- const members = this.db.listGroupMembers(groupId)
131
- const currentGroup = this.db.memoryDocs('group', groupId)
132
- const memberMemory = Object.fromEntries(
133
- members.map(member => [member.platform_user_id, {
134
- profile: this.db.memoryDocs('member', Number(member.id)).profile || '',
135
- pattern: this.db.memoryDocs('member', Number(member.id)).pattern || '',
136
- summary: this.db.memoryDocs('member', Number(member.id)).summary || '',
137
- memory: this.db.memoryDocs('member', Number(member.id)).memory || '',
138
- name: member.display_name || '',
139
- aliases: parseStringList(member.aliases_json),
140
- nicknames: parseStringList(member.nicknames_json),
141
- }]),
142
- )
143
- const transcript = messages.map(message => ({
221
+ const currentGroup = this.db.memoryDocs('group', groupId)
222
+ const transcript = messages.map(message => ({
144
223
  id: Number(message.id),
145
224
  at: message.created_at,
146
225
  direction: message.direction,
@@ -149,21 +228,103 @@ export class MemoryEngine {
149
228
  text: message.content,
150
229
  quotedText: message.quoted_text || '',
151
230
  ...(parseReflectionQuote(message.quote_json) ? { quote: parseReflectionQuote(message.quote_json) } : {}),
152
- }))
231
+ }))
153
232
 
233
+ const input = {
234
+ group: { id: group.platform_group_id, name: group.name || '' },
235
+ existing: { group: pickDocs(currentGroup) },
236
+ messages: transcript,
237
+ }
238
+ const reflected = await this.generateReflection(route, memorySystemPrompt(), input, `qqchat-memory-${groupId}`) as ReflectionPayload
239
+ this.applyGroupReflection(groupId, reflected)
240
+ await this.compactDaily('group', groupId, route, GROUP_DAILY_COMPACT_AT, GROUP_DAILY_KEEP_RECENT, `qqchat-group-compress-${groupId}`)
241
+ const lastMessage = messages.at(-1)
242
+ if (lastMessage) this.db.markReflected(groupId, Number(lastMessage.id))
243
+ this.db.finishReflectionTask(taskKey, 'completed')
244
+ this.logger.info?.(`[dsh-qqchat] memory reflection completed task=group messages=${messages.length} durationMs=${Date.now() - startedAt}`)
245
+ const view = this.memoryView(groupId)
246
+ if (!view) throw new Error('群不存在')
247
+ return view
248
+ } catch (error) {
249
+ this.db.finishReflectionTask(taskKey, 'failed')
250
+ this.logger.warn?.(`[dsh-qqchat] memory reflection failed task=group messages=${messages.length} durationMs=${Date.now() - startedAt}`)
251
+ throw error
252
+ }
253
+ }
254
+
255
+ async reflectMembersNow(groupId: number): Promise<MemoryView> {
256
+ groupId = Number(groupId)
257
+ const existing = this.reflectingMemberBatches.get(groupId)
258
+ if (existing) return existing
259
+ const current = this.reflectMembersNowInternal(groupId)
260
+ this.reflectingMemberBatches.set(groupId, current)
261
+ return current.finally(() => {
262
+ if (this.reflectingMemberBatches.get(groupId) === current) this.reflectingMemberBatches.delete(groupId)
263
+ })
264
+ }
265
+
266
+ private async reflectMembersNowInternal(groupId: number): Promise<MemoryView> {
267
+ groupId = Number(groupId)
268
+ const route = this.routes.get(groupId)
269
+ if (!route) throw new Error('这个群还没有可复用的 DSH 模型路由;先让 Agent 在群里完成一次回复')
270
+ const group = this.db.groupById(groupId)
271
+ if (!group) throw new Error('群不存在')
272
+ const messages = this.db.unreflectedMemberMessages(groupId, this.config.reflectionMaxMessages)
273
+ if (messages.length === 0) {
274
+ const view = this.memoryView(groupId)
275
+ if (!view) throw new Error('群不存在')
276
+ return view
277
+ }
278
+ const taskKey = `member-batch:${groupId}:${Number(messages[0]?.id)}:${Number(messages.at(-1)?.id)}`
279
+ if (!this.db.startReflectionTask({
280
+ scopeType: 'group', scopeKey: groupId, taskType: 'member-batch',
281
+ startMessageId: Number(messages[0]?.id), endMessageId: Number(messages.at(-1)?.id), idempotencyKey: taskKey,
282
+ })) {
283
+ const view = this.memoryView(groupId)
284
+ if (!view) throw new Error('群不存在')
285
+ return view
286
+ }
287
+ const startedAt = Date.now()
288
+ try {
289
+ const senderIds = new Set(messages
290
+ .filter(message => message.direction !== 'outbound' && message.platform_user_id)
291
+ .map(message => message.platform_user_id as string))
292
+ const members = this.db.listGroupMembers(groupId).filter(member => senderIds.has(member.platform_user_id))
154
293
  const input = {
155
294
  group: { id: group.platform_group_id, name: group.name || '' },
156
- existing: { group: pickDocs(currentGroup), members: memberMemory },
157
- messages: transcript,
295
+ members: members.map(member => ({
296
+ senderId: member.platform_user_id,
297
+ displayName: member.display_name || '',
298
+ existing: pickMemberDocs(this.db.memoryDocs('member', Number(member.id))),
299
+ })),
300
+ messages: messages.map(message => ({
301
+ id: Number(message.id),
302
+ at: message.created_at,
303
+ direction: message.direction,
304
+ senderId: message.direction === 'outbound' ? 'BOT' : message.platform_user_id,
305
+ senderName: message.direction === 'outbound' ? 'DSH Agent' : (message.display_name || ''),
306
+ text: message.content,
307
+ quotedText: message.quoted_text || '',
308
+ ...(parseReflectionQuote(message.quote_json) ? { quote: parseReflectionQuote(message.quote_json) } : {}),
309
+ })),
310
+ }
311
+ const reflected = await this.generateReflection(route, memberBatchMemorySystemPrompt(), input, `qqchat-member-batch-${groupId}`)
312
+ if (!validateMemberBatchPayload(reflected, new Set(members.map(member => member.platform_user_id)))) {
313
+ throw new Error('成员批量反思输出包含无效成员或格式')
314
+ }
315
+ this.applyMemberBatchReflection(groupId, members, reflected, messages.at(-1)?.created_at)
316
+ const lastMessage = messages.at(-1)
317
+ if (lastMessage) this.db.markMembersReflected(groupId, Number(lastMessage.id))
318
+ this.db.finishReflectionTask(taskKey, 'completed')
319
+ this.logger.info?.(`[dsh-qqchat] memory reflection completed task=member-batch messages=${messages.length} members=${members.length} durationMs=${Date.now() - startedAt}`)
320
+ const view = this.memoryView(groupId)
321
+ if (!view) throw new Error('群不存在')
322
+ return view
323
+ } catch (error) {
324
+ this.db.finishReflectionTask(taskKey, 'failed')
325
+ this.logger.warn?.(`[dsh-qqchat] memory reflection failed task=member-batch messages=${messages.length} durationMs=${Date.now() - startedAt}`)
326
+ throw error
158
327
  }
159
- const reflected = await this.generateReflection(route, memorySystemPrompt(), input, `qqchat-memory-${groupId}`) as ReflectionPayload
160
- this.applyReflection(groupId, members, reflected)
161
- await this.compactDaily('group', groupId, route, GROUP_DAILY_COMPACT_AT, GROUP_DAILY_KEEP_RECENT, `qqchat-group-compress-${groupId}`)
162
- const lastMessage = messages.at(-1)
163
- if (lastMessage) this.db.markReflected(groupId, Number(lastMessage.id))
164
- const view = this.memoryView(groupId)
165
- if (!view) throw new Error('群不存在')
166
- return view
167
328
  }
168
329
 
169
330
  async reflectMemberNow(memberId: number): Promise<MemoryDocuments> {
@@ -203,19 +364,37 @@ export class MemoryEngine {
203
364
  ...(parseReflectionQuote(message.quote_json) ? { quote: parseReflectionQuote(message.quote_json) } : {}),
204
365
  })),
205
366
  }
206
- const reflected = await this.generateReflection(route, memberMemorySystemPrompt(), input, `qqchat-member-memory-${memberId}`) as MemberReflectionPayload
207
- if (reflected.profile !== undefined) this.db.setMemoryDoc('member', memberId, 'profile', stringifyProfile(reflected.profile))
208
- if (reflected.pattern !== undefined) this.db.setMemoryDoc('member', memberId, 'pattern', stringifyDoc(reflected.pattern))
209
- if (reflected.summary !== undefined) this.db.setMemoryDoc('member', memberId, 'summary', stringifyDoc(reflected.summary))
210
- if (reflected.memory !== undefined) this.db.setMemoryDoc('member', memberId, 'memory', stringifyMemory(reflected.memory))
211
- if (reflected.daily !== undefined) {
212
- const last = messages.at(-1)
213
- this.db.appendDailyDoc('member', memberId, dateForTimestamp(last?.created_at), stringifyDoc(reflected.daily))
367
+ const taskKey = `private-owner:${memberId}:${Number(messages[0]?.id)}:${Number(messages.at(-1)?.id)}`
368
+ if (!this.db.startReflectionTask({
369
+ scopeType: 'member', scopeKey: memberId, taskType: 'private-owner',
370
+ startMessageId: Number(messages[0]?.id), endMessageId: Number(messages.at(-1)?.id), idempotencyKey: taskKey,
371
+ })) return this.db.memoryDocs('member', memberId)
372
+ const startedAt = Date.now()
373
+ try {
374
+ const reflected = await this.generateReflection(route, privateMemorySystemPrompt(), input, `qqchat-private-memory-${memberId}`) as MemberReflectionPayload
375
+ if (reflected.profile_add !== undefined || reflected.profile_remove !== undefined) {
376
+ this.db.setMemoryDoc('member', memberId, 'profile', mergeProfile(this.db.memoryDocs('member', memberId).profile, reflected.profile_add, reflected.profile_remove))
377
+ } else if (reflected.profile !== undefined) this.db.setMemoryDoc('member', memberId, 'profile', stringifyProfile(reflected.profile))
378
+ if (reflected.pattern_add !== undefined || reflected.pattern_remove !== undefined) {
379
+ this.db.setMemoryDoc('member', memberId, 'pattern', mergeTextDoc(this.db.memoryDocs('member', memberId).pattern, reflected.pattern_add, reflected.pattern_remove))
380
+ } else if (reflected.pattern !== undefined) this.db.setMemoryDoc('member', memberId, 'pattern', stringifyDoc(reflected.pattern))
381
+ if (reflected.summary !== undefined) this.db.setMemoryDoc('member', memberId, 'summary', stringifyDoc(reflected.summary))
382
+ if (reflected.memory !== undefined) this.db.setMemoryDoc('member', memberId, 'memory', stringifyMemory(reflected.memory))
383
+ if (reflected.daily !== undefined) {
384
+ const last = messages.at(-1)
385
+ this.db.appendDailyDoc('member', memberId, dateForTimestamp(last?.created_at), stringifyDoc(reflected.daily))
386
+ }
387
+ await this.compactDaily('member', memberId, route, MEMBER_DAILY_COMPACT_AT, MEMBER_DAILY_KEEP_RECENT, `qqchat-member-compress-${memberId}`)
388
+ const lastMessage = messages.at(-1)
389
+ if (lastMessage) this.db.setSetting(memberCursorKey(memberId), Number(lastMessage.id))
390
+ this.db.finishReflectionTask(taskKey, 'completed')
391
+ this.logger.info?.(`[dsh-qqchat] memory reflection completed task=private-owner messages=${messages.length} durationMs=${Date.now() - startedAt}`)
392
+ return this.db.memoryDocs('member', memberId)
393
+ } catch (error) {
394
+ this.db.finishReflectionTask(taskKey, 'failed')
395
+ this.logger.warn?.(`[dsh-qqchat] memory reflection failed task=private-owner messages=${messages.length} durationMs=${Date.now() - startedAt}`)
396
+ throw error
214
397
  }
215
- await this.compactDaily('member', memberId, route, MEMBER_DAILY_COMPACT_AT, MEMBER_DAILY_KEEP_RECENT, `qqchat-member-compress-${memberId}`)
216
- const lastMessage = messages.at(-1)
217
- if (lastMessage) this.db.setSetting(memberCursorKey(memberId), Number(lastMessage.id))
218
- return this.db.memoryDocs('member', memberId)
219
398
  }
220
399
 
221
400
  private async compactDaily(
@@ -280,7 +459,7 @@ export class MemoryEngine {
280
459
  .slice(-limit)
281
460
  }
282
461
 
283
- private applyReflection(groupId: number, members: GroupMemberRow[], reflected: ReflectionPayload): void {
462
+ private applyGroupReflection(groupId: number, reflected: ReflectionPayload): void {
284
463
  const group = reflected.group || {}
285
464
  if (group.profile !== undefined) this.db.setMemoryDoc('group', groupId, 'profile', stringifyDoc(group.profile))
286
465
  if (group.summary !== undefined) this.db.setMemoryDoc('group', groupId, 'summary', stringifyDoc(group.summary))
@@ -289,15 +468,25 @@ export class MemoryEngine {
289
468
  const stamp = new Date().toISOString().slice(0, 10)
290
469
  this.db.appendDailyDoc('group', groupId, stamp, stringifyDoc(group.daily))
291
470
  }
471
+ }
472
+
473
+ private applyMemberBatchReflection(groupId: number, members: GroupMemberRow[], reflected: MemberBatchReflectionPayload, createdAt?: number): void {
292
474
  const byOpenid = new Map(members.map(member => [member.platform_user_id, Number(member.id)]))
293
475
  const updates = Array.isArray(reflected.members) ? reflected.members : []
294
476
  for (const update of updates) {
295
477
  const memberId = byOpenid.get(String(update.senderId || ''))
296
478
  if (!memberId) continue
297
- if (update.profile !== undefined) this.db.setMemoryDoc('member', memberId, 'profile', stringifyProfile(update.profile))
298
- if (update.pattern !== undefined) this.db.setMemoryDoc('member', memberId, 'pattern', stringifyDoc(update.pattern))
479
+ if (update.profile_add !== undefined || update.profile_remove !== undefined) {
480
+ this.db.setMemoryDoc('member', memberId, 'profile', mergeProfile(this.db.memoryDocs('member', memberId).profile, update.profile_add, update.profile_remove))
481
+ }
482
+ if (update.pattern_add !== undefined || update.pattern_remove !== undefined) {
483
+ this.db.setMemoryDoc('member', memberId, 'pattern', mergeTextDoc(this.db.memoryDocs('member', memberId).pattern, update.pattern_add, update.pattern_remove))
484
+ }
299
485
  if (update.summary !== undefined) this.db.setMemoryDoc('member', memberId, 'summary', stringifyDoc(update.summary))
300
486
  if (update.memory !== undefined) this.db.setMemoryDoc('member', memberId, 'memory', stringifyMemory(update.memory))
487
+ if (update.daily !== undefined && stringifyDoc(update.daily).trim()) {
488
+ this.db.appendDailyDoc('member', memberId, dateForTimestamp(createdAt), stringifyDoc(update.daily))
489
+ }
301
490
  if (Array.isArray(update.nicknames)) {
302
491
  for (const nickname of update.nicknames) {
303
492
  if (typeof nickname === 'string') this.db.addGroupMemberNickname(groupId, memberId, nickname)
@@ -312,20 +501,26 @@ export class MemoryEngine {
312
501
  ): string {
313
502
  const groupDocs = this.db.memoryDocs('group', Number(group.id))
314
503
  const memberDocs = currentMember ? this.db.memoryDocs('member', Number(currentMember.id)) : {}
504
+ const groupMemory = boundedMemorySections([
505
+ ['群画像', groupDocs.profile],
506
+ ['群摘要', groupDocs.summary],
507
+ ['群长期记忆', groupDocs.memory],
508
+ ['群最近沉淀', groupDocs.daily],
509
+ ])
510
+ const memberMemory = currentMember ? boundedMemorySections([
511
+ ['当前成员画像', memberDocs.profile],
512
+ ['当前成员模式', memberDocs.pattern],
513
+ ['当前成员摘要', memberDocs.summary],
514
+ ['当前成员长期记忆', memberDocs.memory],
515
+ ]) : []
315
516
  return [
316
517
  '[QQ 群聊上下文快照]',
317
518
  '以下 sender ID / group ID 是可靠平台元数据。不要根据昵称猜身份,不要把其他群成员的兴趣、关系或记忆归到当前发言人。',
318
519
  `群ID=${group.platform_group_id}`,
319
520
  group.name ? `群名=${group.name}` : '',
320
- section('群画像', groupDocs.profile),
321
- section('群摘要', groupDocs.summary),
322
- section('群长期记忆', groupDocs.memory),
323
- section('群最近沉淀', groupDocs.daily),
521
+ ...groupMemory,
324
522
  currentMember ? `当前成员ID=${currentMember.platform_user_id}\n当前成员显示名=${currentMember.display_name || ''}` : '',
325
- section('当前成员画像', memberDocs.profile),
326
- section('当前成员模式', memberDocs.pattern),
327
- section('当前成员摘要', memberDocs.summary),
328
- section('当前成员长期记忆', memberDocs.memory),
523
+ ...memberMemory,
329
524
  ].filter(Boolean).join('\n')
330
525
  }
331
526
 
@@ -336,11 +531,13 @@ export class MemoryEngine {
336
531
  `成员ID=${member.platform_user_id}`,
337
532
  `显示名=${member.display_name || ''}`,
338
533
  '成员ID 是可靠平台身份;昵称仅供展示。',
339
- section('成员画像', docs.profile),
340
- section('行为模式', docs.pattern),
341
- section('成员摘要', docs.summary),
342
- section('成员近期沉淀', docs.daily),
343
- section('成员长期记忆', docs.memory),
534
+ ...boundedMemorySections([
535
+ ['成员画像', docs.profile],
536
+ ['行为模式', docs.pattern],
537
+ ['成员摘要', docs.summary],
538
+ ['成员近期沉淀', docs.daily],
539
+ ['成员长期记忆', docs.memory],
540
+ ]),
344
541
  ].filter(Boolean).join('\n')
345
542
  }
346
543
 
@@ -378,17 +575,12 @@ export class MemoryEngine {
378
575
  }
379
576
  }
380
577
 
381
- function memorySystemPrompt(): string {
382
- return `你负责整理一个 QQ 群的长期记忆。目标是稳定、克制、可追溯地维护群 scope 与成员 scope,规则:
383
- 1. senderId 是唯一可靠身份,不根据昵称猜身份;不同 senderId 的个人事实绝不能混写。成员记录中的 name/aliases/nicknames 只用于称呼和检索。引用内容属于 quote.senderId;除非 quote.senderId 与当前 senderId 相同,否则不得把引用中的个人事实归给当前发言人。
384
- 2. 群 scope 只写群级角色、关系、共同决定、长期话题、群内约定;个人稳定信息写到对应 member。
385
- 3. 不把一次性寒暄、玩笑、临时情绪上升为长期事实;不确定信息宁可不记。
386
- 4. existing 是已有记忆,应在其基础上更新,而不是无条件推翻。
387
- 5. daily 只写本批消息值得留档的新进展;memory 保留真正长期有用的项目、关系、约定和背景。
388
- 6. 输出严格 JSON,不要 Markdown 代码块、解释或额外文字。
389
- 输出结构:
390
- {"group":{"profile":{},"summary":"","daily":"","memory":["..."]},"members":[{"senderId":"可靠ID","profile":[{"type":"name|address|pronoun|background|preference|note","text":"...","ts":0}],"pattern":{},"summary":"","memory":"","nicknames":["群内称呼"]}]}
391
- 没有变化的成员可省略;不要为 BOT 创建成员记忆。`
578
+ export function memorySystemPrompt(): string {
579
+ return loadReflectionPrompt('group-reflection.md')
580
+ }
581
+
582
+ export function memberBatchMemorySystemPrompt(): string {
583
+ return loadReflectionPrompt('member-batch-reflection.md')
392
584
  }
393
585
 
394
586
  function memberMemorySystemPrompt(): string {
@@ -401,6 +593,14 @@ function memberMemorySystemPrompt(): string {
401
593
  输出结构:{"profile":[{"type":"name|address|pronoun|background|preference|note","text":"...","ts":0}],"pattern":{},"summary":"","memory":"","daily":""}`
402
594
  }
403
595
 
596
+ export function privateMemorySystemPrompt(): string {
597
+ return loadReflectionPrompt('private-reflection.md')
598
+ }
599
+
600
+ function loadReflectionPrompt(filename: string): string {
601
+ return readFileSync(new URL(`../../prompts/${filename}`, import.meta.url), 'utf8').trim()
602
+ }
603
+
404
604
  function parseReflectionQuote(value: string | null): { senderId?: string; senderName?: string; messageId?: string; text: string; attachments: Array<Record<string, unknown>> } | undefined {
405
605
  if (!value) return undefined
406
606
  try {
@@ -421,12 +621,12 @@ function parseReflectionQuote(value: string | null): { senderId?: string; sender
421
621
  }
422
622
  }
423
623
 
424
- function groupCompressionSystemPrompt(): string {
425
- return `你负责维护 QQ 群长期记忆主档。把近期 daily 记录合并进已有 memory,合并重复、修正矛盾,但保留有价值的历史、日期、事件背景和变化过程;不要因为旧或细而删除历史,不评判、不推测。严格只输出 JSON:{"memory":"更新后的长期记忆全文;不能清空"}`
624
+ export function groupCompressionSystemPrompt(): string {
625
+ return loadReflectionPrompt('group-compression.md')
426
626
  }
427
627
 
428
- function memberCompressionSystemPrompt(): string {
429
- return `你负责维护 QQ 成员长期记忆主档。把近期 daily 记录合并进已有 memory,结合 profile 和 pattern,保留有价值的历史、日期、项目背景和变化过程;不要重复抄写 profile/pattern,不评判、不推测,不能清空已有长期记忆。严格只输出 JSON:{"memory":"更新后的长期记忆全文;不能清空"}`
628
+ export function memberCompressionSystemPrompt(): string {
629
+ return loadReflectionPrompt('member-compression.md')
430
630
  }
431
631
 
432
632
  function renderDailyEntries(entries: DailyEntry[]): string {
@@ -454,8 +654,12 @@ function dateForTimestamp(value: number | null | undefined): string {
454
654
 
455
655
  export function parseJsonObject(text: string): ReflectionPayload | MemberReflectionPayload {
456
656
  const trimmed = String(text || '').trim()
457
- const unfenced = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim()
458
- for (const candidate of [trimmed, unfenced]) {
657
+ const withoutThinking = trimmed
658
+ .replace(/<think>[\s\S]*?<\/think>/gi, '')
659
+ .replace(/^<thinking>[\s\S]*?<\/thinking>/i, '')
660
+ .trim()
661
+ const unfenced = withoutThinking.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim()
662
+ for (const candidate of [trimmed, withoutThinking, unfenced]) {
459
663
  try {
460
664
  const parsed = JSON.parse(candidate) as unknown
461
665
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as ReflectionPayload | MemberReflectionPayload
@@ -496,6 +700,20 @@ export function parseJsonObject(text: string): ReflectionPayload | MemberReflect
496
700
  throw new Error('记忆反思模型没有返回有效 JSON')
497
701
  }
498
702
 
703
+ export function validateMemberBatchPayload(value: unknown, allowedSenderIds: ReadonlySet<string>): value is MemberBatchReflectionPayload {
704
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
705
+ const members = (value as { members?: unknown }).members
706
+ if (!Array.isArray(members)) return false
707
+ const seen = new Set<string>()
708
+ return members.every(item => {
709
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return false
710
+ const senderId = (item as { senderId?: unknown }).senderId
711
+ if (typeof senderId !== 'string' || !allowedSenderIds.has(senderId) || seen.has(senderId)) return false
712
+ seen.add(senderId)
713
+ return true
714
+ })
715
+ }
716
+
499
717
  function stringifyDoc(value: unknown): string {
500
718
  if (typeof value === 'string') return value
501
719
  return JSON.stringify(value, null, 2) ?? ''
@@ -506,6 +724,35 @@ function stringifyProfile(value: unknown): string {
506
724
  return JSON.stringify(entries, null, 2)
507
725
  }
508
726
 
727
+ function mergeProfile(existing: string | undefined, additions: unknown, removals: unknown): string {
728
+ const current = normalizeProfileEntries(existing || '')
729
+ const removeTexts = new Set(normalizeProfileEntries(removals).map(entry => entry.text))
730
+ const additionsList = normalizeProfileEntries(additions).filter(entry => !removeTexts.has(entry.text))
731
+ const merged = [...current.filter(entry => !removeTexts.has(entry.text)), ...additionsList]
732
+ const unique = merged.filter((entry, index, all) => all.findIndex(item => item.type === entry.type && item.text === entry.text) === index)
733
+ return JSON.stringify(unique, null, 2)
734
+ }
735
+
736
+ function mergeTextDoc(existing: string | undefined, additions: unknown, removals: unknown): string {
737
+ const remove = new Set(normalizeTextList(removals))
738
+ const lines = String(existing || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean).filter(line => !remove.has(line))
739
+ const added = normalizeTextList(additions).filter(line => !lines.includes(line) && !remove.has(line))
740
+ return [...lines, ...added].join('\n')
741
+ }
742
+
743
+ function normalizeTextList(value: unknown): string[] {
744
+ if (typeof value === 'string') return value.trim() ? [value.trim()] : []
745
+ if (!Array.isArray(value)) return []
746
+ return value.flatMap(item => {
747
+ if (typeof item === 'string') return item.trim() ? [item.trim()] : []
748
+ if (item && typeof item === 'object') {
749
+ const text = (item as Record<string, unknown>).text
750
+ return typeof text === 'string' && text.trim() ? [text.trim()] : []
751
+ }
752
+ return []
753
+ })
754
+ }
755
+
509
756
  function normalizeProfileEntries(value: unknown): ProfileEntry[] {
510
757
  let input = value
511
758
  if (typeof input === 'string') {
@@ -572,8 +819,20 @@ function memberCursorKey(memberId: number): string {
572
819
  return `memberReflection:${memberId}`
573
820
  }
574
821
 
575
- function section(title: string, content: string | undefined): string {
576
- return content ? `[${title}]\n${content}` : ''
822
+ function boundedMemorySections(parts: Array<[title: string, content: string | undefined]>, budget = MEMORY_INJECT_CHARS): string[] {
823
+ let used = 0
824
+ const output: string[] = []
825
+ for (const [title, content] of parts) {
826
+ const value = content?.trim() || ''
827
+ if (!value || used >= budget) continue
828
+ const remaining = budget - used
829
+ const isRecent = title.includes('近期沉淀') || title.includes('最近沉淀')
830
+ const bounded = (isRecent ? value.slice(-remaining) : value.slice(0, remaining)).trim()
831
+ if (!bounded) continue
832
+ output.push(`[${title}]\n${bounded}`)
833
+ used += bounded.length
834
+ }
835
+ return output
577
836
  }
578
837
 
579
838
  function errorMessage(error: unknown): string {
@@ -307,6 +307,7 @@ function asSettingsPatch(value: unknown): QQChatRuntimeSettingsPatch {
307
307
  if (!record) return {}
308
308
  const patch: QQChatRuntimeSettingsPatch = {}
309
309
  if (typeof record.memoryEnabled === 'boolean') patch.memoryEnabled = record.memoryEnabled
310
+ if (typeof record.memoryMemberBatchEnabled === 'boolean') patch.memoryMemberBatchEnabled = record.memoryMemberBatchEnabled
310
311
  if (record.groupReceiveMode === 'auto' || record.groupReceiveMode === 'mention' || record.groupReceiveMode === 'silent') {
311
312
  patch.groupReceiveMode = record.groupReceiveMode
312
313
  }