dsh-qqchat 0.2.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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +276 -0
  3. package/README.md +211 -0
  4. package/client-src/plugin.cts +15 -0
  5. package/client-src/settings.cts +216 -0
  6. package/client-src/shared.cts +104 -0
  7. package/client-src/workspace.cts +164 -0
  8. package/cordis.patch.yml +7 -0
  9. package/docs/ARCHITECTURE.en.md +346 -0
  10. package/docs/ARCHITECTURE.md +411 -0
  11. package/docs/DEVELOPMENT.md +69 -0
  12. package/docs/MEDIA_AND_QUOTES.md +644 -0
  13. package/docs/MEMORY.md +99 -0
  14. package/docs/MEMORY_CONTEXT.md +254 -0
  15. package/docs/QUOTES.md +355 -0
  16. package/docs/README.md +22 -0
  17. package/docs/SECURITY.md +62 -0
  18. package/docs/STREAMING.md +347 -0
  19. package/docs/assets//346/211/253/347/240/201/351/223/276/346/216/245.jpg +0 -0
  20. package/docs/assets//347/276/244/350/201/212/347/225/214/351/235/242.jpg +0 -0
  21. package/docs/assets//350/256/260/345/277/206/347/263/273/347/273/237.jpg +0 -0
  22. package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
  23. package/docs/devlog.md +24 -0
  24. package/lib/client.js +433 -0
  25. package/lib/commands/dispatch.d.ts +16 -0
  26. package/lib/commands/dispatch.js +48 -0
  27. package/lib/config.d.ts +3 -0
  28. package/lib/config.js +63 -0
  29. package/lib/gateway/api.d.ts +30 -0
  30. package/lib/gateway/api.js +202 -0
  31. package/lib/gateway/auth.d.ts +35 -0
  32. package/lib/gateway/auth.js +80 -0
  33. package/lib/gateway/crypto.d.ts +2 -0
  34. package/lib/gateway/crypto.js +17 -0
  35. package/lib/gateway/gateway.d.ts +32 -0
  36. package/lib/gateway/gateway.js +219 -0
  37. package/lib/gateway/normalize.d.ts +6 -0
  38. package/lib/gateway/normalize.js +248 -0
  39. package/lib/index.d.ts +14 -0
  40. package/lib/index.js +35 -0
  41. package/lib/media/image-tool.d.ts +4 -0
  42. package/lib/media/image-tool.js +62 -0
  43. package/lib/media/media-tools.d.ts +3 -0
  44. package/lib/media/media-tools.js +68 -0
  45. package/lib/media/store.d.ts +14 -0
  46. package/lib/media/store.js +176 -0
  47. package/lib/session/agent-bridge.d.ts +56 -0
  48. package/lib/session/agent-bridge.js +613 -0
  49. package/lib/session/memory-snapshot.d.ts +23 -0
  50. package/lib/session/memory-snapshot.js +27 -0
  51. package/lib/session/runtime.d.ts +41 -0
  52. package/lib/session/runtime.js +395 -0
  53. package/lib/shared/augmentations.d.ts +40 -0
  54. package/lib/shared/augmentations.js +1 -0
  55. package/lib/shared/logging.d.ts +14 -0
  56. package/lib/shared/logging.js +41 -0
  57. package/lib/storage/db.d.ts +82 -0
  58. package/lib/storage/db.js +669 -0
  59. package/lib/storage/memory.d.ts +50 -0
  60. package/lib/storage/memory.js +566 -0
  61. package/lib/transport/rpc.d.ts +18 -0
  62. package/lib/transport/rpc.js +318 -0
  63. package/lib/types.d.ts +416 -0
  64. package/lib/types.js +1 -0
  65. package/package.json +115 -0
  66. package/scripts/wrap-client.mjs +20 -0
  67. package/src/commands/dispatch.ts +75 -0
  68. package/src/config.ts +70 -0
  69. package/src/gateway/api.ts +260 -0
  70. package/src/gateway/auth.ts +99 -0
  71. package/src/gateway/crypto.ts +16 -0
  72. package/src/gateway/gateway.ts +207 -0
  73. package/src/gateway/normalize.ts +236 -0
  74. package/src/index.ts +46 -0
  75. package/src/media/image-tool.ts +62 -0
  76. package/src/media/media-tools.ts +66 -0
  77. package/src/media/store.ts +157 -0
  78. package/src/session/agent-bridge.ts +624 -0
  79. package/src/session/memory-snapshot.ts +41 -0
  80. package/src/session/runtime.ts +368 -0
  81. package/src/shared/augmentations.ts +45 -0
  82. package/src/shared/logging.ts +36 -0
  83. package/src/storage/db.ts +760 -0
  84. package/src/storage/memory.ts +581 -0
  85. package/src/transport/rpc.ts +316 -0
  86. package/src/types.ts +456 -0
  87. package/tsconfig.build.json +12 -0
  88. package/tsconfig.client.json +14 -0
  89. package/tsconfig.json +23 -0
@@ -0,0 +1,581 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm'
3
+ import { SessionId } from '@deepseek-ai/dsh-session'
4
+ import type { QQChatDatabase } from './db.js'
5
+ import type {
6
+ GroupMemberRow,
7
+ GroupRow,
8
+ LoggerLike,
9
+ MemberRow,
10
+ MemoryDocType,
11
+ MemoryDocuments,
12
+ MemoryView,
13
+ MessageRow,
14
+ ModelRoute,
15
+ QQChatConfig,
16
+ DailyEntry,
17
+ ReflectionPayload,
18
+ ProfileEntry,
19
+ ProfileEntryType,
20
+ QQNormalizedMessage,
21
+ } from '../types.js'
22
+
23
+ const DOCS = ['profile', 'summary', 'daily', 'memory', 'pattern'] as const satisfies readonly MemoryDocType[]
24
+
25
+ interface MemberReflectionPayload {
26
+ profile?: unknown
27
+ pattern?: unknown
28
+ summary?: unknown
29
+ memory?: unknown
30
+ daily?: unknown
31
+ }
32
+
33
+ const MEMBER_DAILY_COMPACT_AT = 100
34
+ const MEMBER_DAILY_KEEP_RECENT = 50
35
+ const GROUP_DAILY_COMPACT_AT = 1000
36
+ const GROUP_DAILY_KEEP_RECENT = 500
37
+
38
+ export class MemoryEngine {
39
+ private readonly timers = new Map<number, ReturnType<typeof setTimeout>>()
40
+ private readonly memberTimers = new Map<number, ReturnType<typeof setTimeout>>()
41
+ private readonly routes = new Map<number, ModelRoute>()
42
+ private readonly memberRoutes = new Map<number, ModelRoute>()
43
+ private readonly reflectingGroups = new Map<number, Promise<MemoryView>>()
44
+ private readonly reflectingMembers = new Map<number, Promise<MemoryDocuments>>()
45
+
46
+ constructor(
47
+ private readonly ctx: Context,
48
+ private readonly db: QQChatDatabase,
49
+ private readonly config: QQChatConfig,
50
+ private readonly logger: LoggerLike = console,
51
+ ) {}
52
+
53
+ dispose(): void {
54
+ for (const timer of this.timers.values()) clearTimeout(timer)
55
+ for (const timer of this.memberTimers.values()) clearTimeout(timer)
56
+ this.timers.clear()
57
+ this.memberTimers.clear()
58
+ this.reflectingGroups.clear()
59
+ this.reflectingMembers.clear()
60
+ }
61
+
62
+ setRoute(groupId: number, provider: string, model: string, sessionId: string): void {
63
+ if (!provider || !model) return
64
+ this.routes.set(Number(groupId), { provider, model, sessionId })
65
+ }
66
+
67
+ setMemberRoute(memberId: number, provider: string, model: string, sessionId: string): void {
68
+ if (!provider || !model) return
69
+ this.memberRoutes.set(Number(memberId), { provider, model, sessionId })
70
+ }
71
+
72
+ schedule(groupId: number): void {
73
+ groupId = Number(groupId)
74
+ if (!this.routes.has(groupId)) return
75
+ const existing = this.timers.get(groupId)
76
+ 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
+ const timer = setTimeout(() => {
82
+ this.timers.delete(groupId)
83
+ void this.reflectNow(groupId).catch(error => this.logger.warn?.(`[dsh-qqchat] memory reflection: ${errorMessage(error)}`))
84
+ }, this.config.reflectionIdleMs)
85
+ timer.unref?.()
86
+ this.timers.set(groupId, timer)
87
+ }
88
+
89
+ scheduleMember(memberId: number): void {
90
+ memberId = Number(memberId)
91
+ if (!this.memberRoutes.has(memberId)) return
92
+ const existing = this.memberTimers.get(memberId)
93
+ 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)}`))
96
+ return
97
+ }
98
+ const timer = setTimeout(() => {
99
+ 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)
102
+ timer.unref?.()
103
+ this.memberTimers.set(memberId, timer)
104
+ }
105
+
106
+ async reflectNow(groupId: number): Promise<MemoryView> {
107
+ groupId = Number(groupId)
108
+ const existing = this.reflectingGroups.get(groupId)
109
+ if (existing) return existing
110
+ const current = this.reflectNowInternal(groupId)
111
+ this.reflectingGroups.set(groupId, current)
112
+ return current.finally(() => {
113
+ if (this.reflectingGroups.get(groupId) === current) this.reflectingGroups.delete(groupId)
114
+ })
115
+ }
116
+
117
+ private async reflectNowInternal(groupId: number): Promise<MemoryView> {
118
+ groupId = Number(groupId)
119
+ const route = this.routes.get(groupId)
120
+ if (!route) throw new Error('这个群还没有可复用的 DSH 模型路由;先让 Agent 在群里完成一次回复')
121
+ const group = this.db.groupById(groupId)
122
+ if (!group) throw new Error('群不存在')
123
+ const messages = this.db.unreflectedMessages(groupId, this.config.reflectionMaxMessages)
124
+ if (messages.length === 0) {
125
+ const view = this.memoryView(groupId)
126
+ if (!view) throw new Error('群不存在')
127
+ return view
128
+ }
129
+
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 => ({
144
+ id: Number(message.id),
145
+ at: message.created_at,
146
+ direction: message.direction,
147
+ senderId: message.direction === 'outbound' ? 'BOT' : message.platform_user_id,
148
+ senderName: message.direction === 'outbound' ? 'DSH Agent' : (message.display_name || ''),
149
+ text: message.content,
150
+ quotedText: message.quoted_text || '',
151
+ ...(parseReflectionQuote(message.quote_json) ? { quote: parseReflectionQuote(message.quote_json) } : {}),
152
+ }))
153
+
154
+ const input = {
155
+ group: { id: group.platform_group_id, name: group.name || '' },
156
+ existing: { group: pickDocs(currentGroup), members: memberMemory },
157
+ messages: transcript,
158
+ }
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
+ }
168
+
169
+ async reflectMemberNow(memberId: number): Promise<MemoryDocuments> {
170
+ memberId = Number(memberId)
171
+ const existing = this.reflectingMembers.get(memberId)
172
+ if (existing) return existing
173
+ const current = this.reflectMemberNowInternal(memberId)
174
+ this.reflectingMembers.set(memberId, current)
175
+ return current.finally(() => {
176
+ if (this.reflectingMembers.get(memberId) === current) this.reflectingMembers.delete(memberId)
177
+ })
178
+ }
179
+
180
+ private async reflectMemberNowInternal(memberId: number): Promise<MemoryDocuments> {
181
+ memberId = Number(memberId)
182
+ const route = this.memberRoutes.get(memberId)
183
+ if (!route) throw new Error('这个私聊还没有可复用的 DSH 模型路由;先让 Agent 完成一次回复')
184
+ const member = this.db.memberById(memberId)
185
+ if (!member) throw new Error('成员不存在')
186
+ const messages = this.unreflectedDirectMessages(memberId, this.config.reflectionMaxMessages)
187
+ if (messages.length === 0) return this.db.memoryDocs('member', memberId)
188
+
189
+ const input = {
190
+ member: {
191
+ senderId: member.platform_user_id,
192
+ displayName: member.display_name || '',
193
+ },
194
+ existing: pickMemberDocs(this.db.memoryDocs('member', memberId)),
195
+ messages: messages.map(message => ({
196
+ id: Number(message.id),
197
+ at: message.created_at,
198
+ direction: message.direction,
199
+ senderId: message.direction === 'outbound' ? 'BOT' : member.platform_user_id,
200
+ senderName: message.direction === 'outbound' ? 'DSH Agent' : (member.display_name || ''),
201
+ text: message.content,
202
+ quotedText: message.quoted_text || '',
203
+ ...(parseReflectionQuote(message.quote_json) ? { quote: parseReflectionQuote(message.quote_json) } : {}),
204
+ })),
205
+ }
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))
214
+ }
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
+ }
220
+
221
+ private async compactDaily(
222
+ scopeType: 'group' | 'member',
223
+ scopeKey: number,
224
+ route: ModelRoute,
225
+ threshold: number,
226
+ keepRecent: number,
227
+ fallbackSessionId: string,
228
+ ): Promise<boolean> {
229
+ const entries = this.db.dailyEntries(scopeType, scopeKey)
230
+ if (entries.length < threshold) return false
231
+ const overflow = entries.slice(0, -keepRecent)
232
+ const recent = entries.slice(-keepRecent)
233
+ if (!overflow.length) return false
234
+ const existingMemory = this.db.memoryDocs(scopeType, scopeKey).memory || ''
235
+ const profile = this.db.memoryDocs(scopeType === 'member' ? 'member' : 'group', scopeKey).profile || ''
236
+ const pattern = this.db.memoryDocs('member', scopeKey).pattern || ''
237
+ const system = scopeType === 'group' ? groupCompressionSystemPrompt() : memberCompressionSystemPrompt()
238
+ const input = scopeType === 'group'
239
+ ? `已有长期记忆:\n${existingMemory || '(暂无)'}\n\n近期群聊记录(按日期保留历史,不要丢日期):\n${renderDailyEntries(overflow)}\n\n请输出整理后的完整长期记忆主档。`
240
+ : `已有长期记忆:\n${existingMemory || '(暂无)'}\n\n结构化 profile:\n${profile || '(暂无)'}\n\n行为模式:\n${pattern || '(暂无)'}\n\n要沉淀的近期记录(按日期保留历史,不要丢日期):\n${renderDailyEntries(overflow)}\n\n请输出整理后的完整长期记忆主档。`
241
+ try {
242
+ const result = await this.generateReflection(route, system, { text: input }, fallbackSessionId, this.config.memoryCompressionMaxTokens) as { memory?: unknown }
243
+ const memory = typeof result?.memory === 'string' ? result.memory.trim() : ''
244
+ if (!memory || !preservesDailyDates(overflow, memory)) return false
245
+ this.db.setMemoryDoc(scopeType, scopeKey, 'memory', memory)
246
+ this.db.setDailyEntries(scopeType, scopeKey, recent)
247
+ return true
248
+ } catch (error) {
249
+ this.logger.warn?.(`[dsh-qqchat] ${scopeType} memory compression skipped: ${errorMessage(error)}`)
250
+ return false
251
+ }
252
+ }
253
+
254
+ private async generateReflection(route: ModelRoute, system: string, input: unknown, fallbackSessionId: string, maxTokens = this.config.memoryMaxTokens): Promise<unknown> {
255
+ const request = createUserMessage({
256
+ source: { kind: 'plugin', plugin: 'dsh-qqchat' },
257
+ content: [{ type: 'text', text: JSON.stringify(input) }],
258
+ })
259
+ const assembler = new BlockAssembler()
260
+ for await (const chunk of this.ctx.llm.stream({
261
+ provider: route.provider,
262
+ model: route.model,
263
+ messages: [request],
264
+ system,
265
+ maxTokens,
266
+ sessionId: SessionId(route.sessionId || fallbackSessionId),
267
+ })) assembler.push(chunk)
268
+ const text = assembler.blocks()
269
+ .filter(block => block.type === 'text')
270
+ .map(block => block.text)
271
+ .join('\n')
272
+ return parseJsonObject(text)
273
+ }
274
+
275
+ private unreflectedDirectMessages(memberId: number, limit: number): MessageRow[] {
276
+ const cursor = this.db.getSetting<number>(memberCursorKey(memberId), 0)
277
+ const fetchLimit = Math.max(limit, this.config.reflectionMaxMessages)
278
+ return this.db.listDirectMessages(memberId, fetchLimit)
279
+ .filter(message => Number(message.id) > cursor)
280
+ .slice(-limit)
281
+ }
282
+
283
+ private applyReflection(groupId: number, members: GroupMemberRow[], reflected: ReflectionPayload): void {
284
+ const group = reflected.group || {}
285
+ if (group.profile !== undefined) this.db.setMemoryDoc('group', groupId, 'profile', stringifyDoc(group.profile))
286
+ if (group.summary !== undefined) this.db.setMemoryDoc('group', groupId, 'summary', stringifyDoc(group.summary))
287
+ if (group.memory !== undefined) this.db.setMemoryDoc('group', groupId, 'memory', stringifyMemory(group.memory))
288
+ if (group.daily !== undefined && stringifyDoc(group.daily).trim()) {
289
+ const stamp = new Date().toISOString().slice(0, 10)
290
+ this.db.appendDailyDoc('group', groupId, stamp, stringifyDoc(group.daily))
291
+ }
292
+ const byOpenid = new Map(members.map(member => [member.platform_user_id, Number(member.id)]))
293
+ const updates = Array.isArray(reflected.members) ? reflected.members : []
294
+ for (const update of updates) {
295
+ const memberId = byOpenid.get(String(update.senderId || ''))
296
+ 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))
299
+ if (update.summary !== undefined) this.db.setMemoryDoc('member', memberId, 'summary', stringifyDoc(update.summary))
300
+ if (update.memory !== undefined) this.db.setMemoryDoc('member', memberId, 'memory', stringifyMemory(update.memory))
301
+ if (Array.isArray(update.nicknames)) {
302
+ for (const nickname of update.nicknames) {
303
+ if (typeof nickname === 'string') this.db.addGroupMemberNickname(groupId, memberId, nickname)
304
+ }
305
+ }
306
+ }
307
+ }
308
+
309
+ contextForGroup(
310
+ group: GroupRow,
311
+ currentMember?: { id: number; platform_user_id: string; display_name: string | null },
312
+ ): string {
313
+ const groupDocs = this.db.memoryDocs('group', Number(group.id))
314
+ const memberDocs = currentMember ? this.db.memoryDocs('member', Number(currentMember.id)) : {}
315
+ return [
316
+ '[QQ 群聊上下文快照]',
317
+ '以下 sender ID / group ID 是可靠平台元数据。不要根据昵称猜身份,不要把其他群成员的兴趣、关系或记忆归到当前发言人。',
318
+ `群ID=${group.platform_group_id}`,
319
+ group.name ? `群名=${group.name}` : '',
320
+ section('群画像', groupDocs.profile),
321
+ section('群摘要', groupDocs.summary),
322
+ section('群长期记忆', groupDocs.memory),
323
+ section('群最近沉淀', groupDocs.daily),
324
+ 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),
329
+ ].filter(Boolean).join('\n')
330
+ }
331
+
332
+ contextForMember(member: { id: number; platform_user_id: string; display_name: string | null }): string {
333
+ const docs = this.db.memoryDocs('member', Number(member.id))
334
+ return [
335
+ '[QQ 私聊成员上下文]',
336
+ `成员ID=${member.platform_user_id}`,
337
+ `显示名=${member.display_name || ''}`,
338
+ '成员ID 是可靠平台身份;昵称仅供展示。',
339
+ section('成员画像', docs.profile),
340
+ section('行为模式', docs.pattern),
341
+ section('成员摘要', docs.summary),
342
+ section('成员近期沉淀', docs.daily),
343
+ section('成员长期记忆', docs.memory),
344
+ ].filter(Boolean).join('\n')
345
+ }
346
+
347
+ currentMessageText(message: QQNormalizedMessage): string {
348
+ const quoteText = message.quote
349
+ ? [
350
+ '[引用消息]',
351
+ message.quote.senderId ? `发送人ID=${message.quote.senderId}` : '',
352
+ message.quote.senderName ? `显示名=${message.quote.senderName}` : '',
353
+ message.quote.messageId ? `消息ID=${message.quote.messageId}` : '',
354
+ `正文=${message.quote.text || message.quotedText || '(空消息)'}`,
355
+ message.quote.attachments.length ? `附件=${message.quote.attachments.map(item => `${item.kind || 'file'}:${item.filename}${item.attachmentId ? ` (attachment_id=${item.attachmentId})` : ''}`).join('、')}` : '',
356
+ ].filter(Boolean).join('\n')
357
+ : (message.quotedText ? `[引用消息]\n正文=${message.quotedText}` : '')
358
+ return [
359
+ message.chatType === 'group' ? '[当前 QQ 群消息;以下身份字段是可靠元数据]' : '[当前 QQ 私聊消息;以下身份字段是可靠元数据]',
360
+ `发言人ID=${message.senderId}`,
361
+ `显示名=${message.senderName || ''}`,
362
+ message.chatType === 'group' ? `群ID=${message.groupOpenid || ''}` : '',
363
+ `是否@机器人=${message.mentioned ? '是' : '否'}`,
364
+ quoteText,
365
+ '正文:',
366
+ message.text || '(空消息)',
367
+ ].filter(Boolean).join('\n')
368
+ }
369
+
370
+ memoryView(groupId: number): MemoryView | undefined {
371
+ const group = this.db.groupById(Number(groupId))
372
+ if (!group) return undefined
373
+ const members = this.db.listGroupMembers(Number(groupId)).map(member => ({
374
+ ...member,
375
+ memory: this.db.memoryDocs('member', Number(member.id)),
376
+ }))
377
+ return { group, groupMemory: this.db.memoryDocs('group', Number(groupId)), members }
378
+ }
379
+ }
380
+
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 创建成员记忆。`
392
+ }
393
+
394
+ function memberMemorySystemPrompt(): string {
395
+ return `你负责整理一个 QQ 私聊成员的长期记忆。senderId 是唯一可靠身份,displayName 只用于展示。引用内容属于 quote.senderId;除非 quote.senderId 与当前成员 senderId 相同,否则不得把引用中的个人事实归给当前成员。规则:
396
+ 1. 只记录这个成员稳定且未来有用的信息,不根据昵称猜身份。
397
+ 2. profile 写较稳定的个人事实,必须输出带 type/text/ts 的条目;type 只能是 name、address、pronoun、background、preference、note。pattern 写多次出现的偏好、习惯或行为模式;summary 写当前值得保留的紧凑状态;memory 写长期项目、关系、约定和背景。
398
+ 3. 一次性寒暄、玩笑、短暂情绪和未经确认的推断不要写入长期记忆。
399
+ 4. existing 是已有记忆,应在其基础上克制更新。
400
+ 5. 输出严格 JSON,不要 Markdown 代码块、解释或额外文字。
401
+ 输出结构:{"profile":[{"type":"name|address|pronoun|background|preference|note","text":"...","ts":0}],"pattern":{},"summary":"","memory":"","daily":""}`
402
+ }
403
+
404
+ function parseReflectionQuote(value: string | null): { senderId?: string; senderName?: string; messageId?: string; text: string; attachments: Array<Record<string, unknown>> } | undefined {
405
+ if (!value) return undefined
406
+ try {
407
+ const raw = JSON.parse(value) as unknown
408
+ if (!raw || typeof raw !== 'object') return undefined
409
+ const quote = raw as Record<string, unknown>
410
+ return {
411
+ ...(typeof quote.senderId === 'string' && quote.senderId ? { senderId: quote.senderId } : {}),
412
+ ...(typeof quote.senderName === 'string' && quote.senderName ? { senderName: quote.senderName } : {}),
413
+ ...(typeof quote.messageId === 'string' && quote.messageId ? { messageId: quote.messageId } : {}),
414
+ text: typeof quote.text === 'string' ? quote.text : '',
415
+ attachments: Array.isArray(quote.attachments)
416
+ ? quote.attachments.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
417
+ : [],
418
+ }
419
+ } catch {
420
+ return undefined
421
+ }
422
+ }
423
+
424
+ function groupCompressionSystemPrompt(): string {
425
+ return `你负责维护 QQ 群长期记忆主档。把近期 daily 记录合并进已有 memory,合并重复、修正矛盾,但保留有价值的历史、日期、事件背景和变化过程;不要因为旧或细而删除历史,不评判、不推测。严格只输出 JSON:{"memory":"更新后的长期记忆全文;不能清空"}`
426
+ }
427
+
428
+ function memberCompressionSystemPrompt(): string {
429
+ return `你负责维护 QQ 成员长期记忆主档。把近期 daily 记录合并进已有 memory,结合 profile 和 pattern,保留有价值的历史、日期、项目背景和变化过程;不要重复抄写 profile/pattern,不评判、不推测,不能清空已有长期记忆。严格只输出 JSON:{"memory":"更新后的长期记忆全文;不能清空"}`
430
+ }
431
+
432
+ function renderDailyEntries(entries: DailyEntry[]): string {
433
+ const out: string[] = []
434
+ let current = ''
435
+ for (const entry of entries) {
436
+ if (!entry.date || !entry.note) continue
437
+ if (entry.date !== current) {
438
+ if (out.length) out.push('')
439
+ out.push(`## ${entry.date}`)
440
+ current = entry.date
441
+ }
442
+ out.push(`- ${entry.note}`)
443
+ }
444
+ return out.join('\n')
445
+ }
446
+
447
+ function preservesDailyDates(entries: DailyEntry[], memory: string): boolean {
448
+ return [...new Set(entries.map(entry => entry.date))].every(date => memory.includes(date))
449
+ }
450
+
451
+ function dateForTimestamp(value: number | null | undefined): string {
452
+ return new Date(Number(value || Date.now())).toISOString().slice(0, 10)
453
+ }
454
+
455
+ export function parseJsonObject(text: string): ReflectionPayload | MemberReflectionPayload {
456
+ const trimmed = String(text || '').trim()
457
+ const unfenced = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim()
458
+ for (const candidate of [trimmed, unfenced]) {
459
+ try {
460
+ const parsed = JSON.parse(candidate) as unknown
461
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as ReflectionPayload | MemberReflectionPayload
462
+ } catch {}
463
+ }
464
+
465
+ // Models sometimes add a short explanation before/after the JSON. Find a
466
+ // balanced object while respecting quoted strings and escaped characters.
467
+ for (let start = unfenced.indexOf('{'); start >= 0; start = unfenced.indexOf('{', start + 1)) {
468
+ let depth = 0
469
+ let inString = false
470
+ let escaped = false
471
+ for (let index = start; index < unfenced.length; index += 1) {
472
+ const char = unfenced[index]
473
+ if (inString) {
474
+ if (escaped) escaped = false
475
+ else if (char === '\\') escaped = true
476
+ else if (char === '"') inString = false
477
+ continue
478
+ }
479
+ if (char === '"') {
480
+ inString = true
481
+ continue
482
+ }
483
+ if (char === '{') depth += 1
484
+ else if (char === '}') {
485
+ depth -= 1
486
+ if (depth === 0) {
487
+ try {
488
+ const parsed = JSON.parse(unfenced.slice(start, index + 1)) as unknown
489
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as ReflectionPayload | MemberReflectionPayload
490
+ } catch {}
491
+ break
492
+ }
493
+ }
494
+ }
495
+ }
496
+ throw new Error('记忆反思模型没有返回有效 JSON')
497
+ }
498
+
499
+ function stringifyDoc(value: unknown): string {
500
+ if (typeof value === 'string') return value
501
+ return JSON.stringify(value, null, 2) ?? ''
502
+ }
503
+
504
+ function stringifyProfile(value: unknown): string {
505
+ const entries = normalizeProfileEntries(value)
506
+ return JSON.stringify(entries, null, 2)
507
+ }
508
+
509
+ function normalizeProfileEntries(value: unknown): ProfileEntry[] {
510
+ let input = value
511
+ if (typeof input === 'string') {
512
+ try { input = JSON.parse(input) } catch { input = { note: input } }
513
+ }
514
+ const now = Date.now()
515
+ if (Array.isArray(input)) {
516
+ return input.flatMap(item => {
517
+ if (!item || typeof item !== 'object') return []
518
+ const raw = item as Record<string, unknown>
519
+ const text = typeof raw.text === 'string' ? raw.text.trim() : ''
520
+ if (!text) return []
521
+ const type = profileType(raw.type)
522
+ return [{ type, text, ts: Number.isSafeInteger(raw.ts) ? Number(raw.ts) : now }]
523
+ })
524
+ }
525
+ if (input && typeof input === 'object') {
526
+ return Object.entries(input as Record<string, unknown>).flatMap(([key, raw]) => {
527
+ const text = typeof raw === 'string' ? raw.trim() : JSON.stringify(raw)
528
+ return text ? [{ type: profileType(key), text, ts: now }] : []
529
+ })
530
+ }
531
+ return typeof input === 'string' && input.trim() ? [{ type: 'note', text: input.trim(), ts: now }] : []
532
+ }
533
+
534
+ function profileType(value: unknown): ProfileEntryType {
535
+ const type = String(value || '').trim()
536
+ if (type === 'name' || type === 'name_observed' || type === 'display_name' || type === 'nickname') return 'name'
537
+ if (type === 'address' || type === 'pronoun' || type === 'background' || type === 'dev_env' || type === 'preference') return type === 'dev_env' ? 'background' : type
538
+ return 'note'
539
+ }
540
+
541
+ function parseStringList(value: string | null | undefined): string[] {
542
+ if (!value) return []
543
+ try {
544
+ const parsed = JSON.parse(value)
545
+ return Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string' && item.trim()).map(item => item.trim()) : []
546
+ } catch {
547
+ return []
548
+ }
549
+ }
550
+
551
+ function stringifyMemory(value: unknown): string {
552
+ if (Array.isArray(value)) {
553
+ return value.map(item => `- ${typeof item === 'string' ? item : JSON.stringify(item)}`).join('\n')
554
+ }
555
+ return stringifyDoc(value)
556
+ }
557
+
558
+ function pickDocs(docs: MemoryDocuments): MemoryDocuments {
559
+ return Object.fromEntries(DOCS.filter(key => docs[key]).map(key => [key, docs[key]])) as MemoryDocuments
560
+ }
561
+
562
+ function pickMemberDocs(docs: MemoryDocuments): MemoryDocuments {
563
+ return {
564
+ ...(docs.profile ? { profile: docs.profile } : {}),
565
+ ...(docs.pattern ? { pattern: docs.pattern } : {}),
566
+ ...(docs.summary ? { summary: docs.summary } : {}),
567
+ ...(docs.memory ? { memory: docs.memory } : {}),
568
+ }
569
+ }
570
+
571
+ function memberCursorKey(memberId: number): string {
572
+ return `memberReflection:${memberId}`
573
+ }
574
+
575
+ function section(title: string, content: string | undefined): string {
576
+ return content ? `[${title}]\n${content}` : ''
577
+ }
578
+
579
+ function errorMessage(error: unknown): string {
580
+ return error instanceof Error ? error.message : String(error)
581
+ }