dsh-qqchat 0.2.3 → 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 (37) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/client-src/plugin.cts +2 -1
  4. package/client-src/settings.cts +18 -1
  5. package/client-src/shared.cts +43 -2
  6. package/docs/MEMORY.md +32 -4
  7. package/docs/MEMORY_CONTEXT.md +16 -0
  8. package/docs/README.md +2 -0
  9. 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
  10. package/docs/prds/README.md +62 -0
  11. package/lib/client.js +64 -2
  12. package/lib/config.js +3 -2
  13. package/lib/index.js +3 -1
  14. package/lib/session/memory-snapshot.d.ts +4 -0
  15. package/lib/session/memory-snapshot.js +6 -2
  16. package/lib/session/runtime.js +14 -5
  17. package/lib/storage/db.d.ts +6 -1
  18. package/lib/storage/db.js +57 -0
  19. package/lib/storage/memory.d.ts +28 -2
  20. package/lib/storage/memory.js +375 -101
  21. package/lib/transport/rpc.js +2 -0
  22. package/lib/types.d.ts +26 -0
  23. package/package.json +2 -1
  24. package/prompts/group-compression.md +5 -0
  25. package/prompts/group-reflection.md +39 -0
  26. package/prompts/member-batch-reflection.md +39 -0
  27. package/prompts/member-compression.md +5 -0
  28. package/prompts/private-reflection.md +32 -0
  29. package/src/config.ts +3 -2
  30. package/src/index.ts +3 -1
  31. package/src/session/agent-bridge.ts +3 -1
  32. package/src/session/memory-snapshot.ts +6 -3
  33. package/src/session/runtime.ts +13 -4
  34. package/src/storage/db.ts +70 -0
  35. package/src/storage/memory.ts +343 -84
  36. package/src/transport/rpc.ts +1 -0
  37. package/src/types.ts +30 -0
package/lib/types.d.ts CHANGED
@@ -54,6 +54,7 @@ export interface StoredAttachmentSummary {
54
54
  }
55
55
  export interface QQChatRuntimeSettings {
56
56
  memoryEnabled: boolean;
57
+ memoryMemberBatchEnabled: boolean;
57
58
  groupReceiveMode: GroupReceiveMode;
58
59
  groupReplyFormat: ReplyFormat;
59
60
  directReplyFormat: ReplyFormat;
@@ -65,6 +66,7 @@ export interface QQChatRuntimeSettings {
65
66
  }
66
67
  export interface QQChatRuntimeSettingsPatch {
67
68
  memoryEnabled?: boolean;
69
+ memoryMemberBatchEnabled?: boolean;
68
70
  groupReceiveMode?: GroupReceiveMode;
69
71
  groupReplyFormat?: ReplyFormat;
70
72
  directReplyFormat?: ReplyFormat;
@@ -384,6 +386,16 @@ export interface ModelRoute {
384
386
  model: string;
385
387
  sessionId?: string;
386
388
  }
389
+ export type ReflectionScopeType = 'group' | 'member' | 'private';
390
+ export type ReflectionTaskType = 'group' | 'member-batch' | 'private-owner';
391
+ export interface ReflectionTaskInput {
392
+ scopeType: ReflectionScopeType;
393
+ scopeKey: number;
394
+ taskType: ReflectionTaskType;
395
+ startMessageId: number;
396
+ endMessageId: number;
397
+ idempotencyKey: string;
398
+ }
387
399
  export interface PendingReply {
388
400
  text: string;
389
401
  onTextDelta?: (delta: string) => void;
@@ -402,6 +414,20 @@ export interface ReflectionMemberUpdate {
402
414
  memory?: unknown;
403
415
  nicknames?: unknown;
404
416
  }
417
+ export interface ReflectionMemberBatchUpdate {
418
+ senderId?: unknown;
419
+ profile_add?: unknown;
420
+ profile_remove?: unknown;
421
+ pattern_add?: unknown;
422
+ pattern_remove?: unknown;
423
+ summary?: unknown;
424
+ daily?: unknown;
425
+ memory?: unknown;
426
+ nicknames?: unknown;
427
+ }
428
+ export interface MemberBatchReflectionPayload {
429
+ members?: ReflectionMemberBatchUpdate[];
430
+ }
405
431
  export interface ReflectionPayload {
406
432
  group?: ReflectionGroupUpdate;
407
433
  members?: ReflectionMemberUpdate[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-qqchat",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "DeepSeek Harness 的 QQ 私聊/群聊插件,支持扫码授权、原生 Session、群记忆与工具权限。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,6 +20,7 @@
20
20
  "files": [
21
21
  "lib",
22
22
  "src",
23
+ "prompts",
23
24
  "client-src",
24
25
  "cordis.patch.yml",
25
26
  "README.md",
@@ -0,0 +1,5 @@
1
+ 你负责维护 QQ 群长期记忆主档。把近期 daily 记录合并进已有 memory,合并重复、修正明确矛盾,但保留有价值的历史、日期、事件背景和变化过程;不要因为旧或细而删除历史,不评判、不推测,不能清空已有长期记忆。
2
+
3
+ 严格只输出 JSON,不要 Markdown、解释或额外文字:
4
+
5
+ {"memory":"更新后的长期记忆全文;不能清空"}
@@ -0,0 +1,39 @@
1
+ 你负责维护当前 QQ 群的公开长期记忆。你会收到当前群信息、已有群记忆和一批完整消息。这是群组反思,不是当前发言人的个人总结,也不是成员画像任务。
2
+
3
+ ## 一、群组记忆边界
4
+
5
+ 只记录当前群公开且明确的内容:
6
+
7
+ - 群名称、用途和性质;
8
+ - 群内明确确认的规则、协作约定和长期沟通偏好;
9
+ - 明确公开的长期项目、共同决定、分工、关系和持续讨论;
10
+ - 当前群正在进行的事项和近期状态。
11
+
12
+ 不要记录:
13
+
14
+ - 单个成员的个人资料、兴趣、性格、经历或私聊内容;
15
+ - 根据昵称、语气、@、引用或他人评价推断身份;
16
+ - 一次性闲聊、玩笑、临时情绪和短期安排;
17
+ - 没有明确依据的猜测;
18
+ - 原始 senderId、groupId、openid 等内部 ID;
19
+ - 群外信息。
20
+
21
+ ## 二、主体和证据判断
22
+
23
+ 先判断一条消息描述的是群体、某个成员、引用中的人,还是不明确的第三方,再判断是否属于群记忆。消息发送者不等于语义主体。引用和转述中的个人事实不能因为出现在群里就变成群事实;只有明确的群体决定、公开协作或群内共同事件才可以记录。
24
+
25
+ “可能、应该、感觉、听说、好像、我觉得”不能作为确定事实。涉及时间时使用消息时间换算出的绝对日期,不使用“今天、明天、最近”等相对时间。
26
+
27
+ ## 三、字段规则
28
+
29
+ - profile:稳定、公开、未来仍有参考价值的群事实;本批没有变化时保持原内容,不要清空或重写。
30
+ - summary:不超过 150 字的当前群状态,回答“现在群里在做什么”;没有变化时沿用已有内容。
31
+ - daily:本批值得留档的近期群事件、决定和讨论进展,一条一个事实;一次性内容不要写入 profile。
32
+ - memory:真正长期有用的群项目、关系、约定和背景;不要复制完整聊天记录,也不要写个人属性。
33
+ - 与 existing 冲突时,只有消息明确推翻旧内容才修正;不能因为新消息暂时没提到旧事实就删除。
34
+
35
+ 输出必须是严格 JSON,不要 Markdown、解释或额外文字。只修改 group,不要输出 members,也不要为 BOT 创建成员记忆。
36
+
37
+ 输出结构:
38
+
39
+ {"group":{"profile":{},"summary":"","daily":"","memory":["..."]}}
@@ -0,0 +1,39 @@
1
+ 你负责维护本批 QQ 群消息中多个成员的个人长期记忆。你会收到已有成员记忆、完整群消息和成员列表。这是成员批量反思,不是当前发言人的单人总结,也不是群组总结。
2
+
3
+ ## 一、先判断“是谁的”,再判断“是什么”
4
+
5
+ 对每条候选信息按以下顺序判断:
6
+
7
+ 1. 语义主体是发送者本人、其他群成员、群外的人、引用中的人,还是无法明确?
8
+ 2. 主体明确后,它是稳定个人资料、可复用行为模式、近期状态、高价值个人事件,还是只属于群组事件?
9
+
10
+ 只有主体能明确落到本批真实出现的 senderId,且内容描述该成员本人时,才允许写入该成员对象。消息发送者不等于语义主体,宁可漏记,也不要错记。
11
+
12
+ ## 二、允许输出的成员
13
+
14
+ members 中只能包含 messages 里真实作为发送者出现、元数据提供 senderId、且确实有可记录个人信息的成员。不得因为某人被昵称提到、被 @、出现在引用中、被代词指代或被他人描述,就猜造其 senderId。没有可记内容的成员可以省略。senderId 是唯一身份依据,displayName 只用于理解和展示。
15
+
16
+ ## 三、主体归属规则
17
+
18
+ - “我喜欢爵士”可以记为发送者本人的偏好。
19
+ - “我觉得小明喜欢爵士”描述小明,不能记为发送者喜欢爵士。
20
+ - “我和小明一起排查问题”可以记录发送者自己的协作模式,不能据此记录小明的能力或偏好。
21
+ - 引用、转述、第三方评价和“他说/听说/好像/我觉得他”等模糊表达不能直接升级为个人记忆。
22
+ - 只有被描述成员随后明确确认,才可以记录该成员自己的事实。
23
+ - 群规、群角色、公共决定、群项目和群体关系属于 group,不写入成员记忆。
24
+ - 不要为 BOT 创建成员记忆,也不要把 BOT 的消息当作任何成员的事实证据。
25
+
26
+ ## 四、字段和证据门槛
27
+
28
+ - profile_add/profile_remove:稳定且未来有用的个人资料。type 只能是 name、address、pronoun、background、preference、note;涉及阶段性内容(最近、刚刚、目前、本周、暂时)不要放入 profile。
29
+ - pattern_add/pattern_remove:重复且可复用、可观察的行为或决策模式。不能因为一次消息或一件事就推断稳定人格。
30
+ - summary:不超过 120 字的近期状态,可以准确描述该成员参与的互动;没有变化时沿用 existing,不要写成流水账。
31
+ - memory:高价值、可长期回看的个人事件,包含必要的时间、背景、行动和结果;其他人的属性和群组信息不能写入。
32
+ - daily:本批重要但阶段性的个人事实,使用绝对日期;没有就返回空字符串。
33
+ - 只返回本批新增或明确修正,不要回显已有内容。旧内容只有被明确推翻时才放入 remove 数组。
34
+
35
+ 严格只输出 JSON,不要 Markdown、解释或额外文字。
36
+
37
+ 输出结构:
38
+
39
+ {"members":[{"senderId":"本批消息中真实出现的可靠 ID","profile_add":[{"type":"name|address|pronoun|background|preference|note","text":"只描述该成员本人","ts":0}],"profile_remove":[{"text":"被明确推翻的旧资料"}],"pattern_add":[],"pattern_remove":[],"summary":"不超过120字","daily":"绝对日期的近期事实","memory":"高价值个人事件"}]}
@@ -0,0 +1,5 @@
1
+ 你负责维护 QQ 成员长期记忆主档。把近期 daily 记录合并进已有 memory,结合 profile 和 pattern,保留有价值的历史、日期、项目背景和变化过程;不要重复抄写 profile/pattern,不评判、不推测,不能清空已有长期记忆,也不要把群组信息或其他人的属性写入当前成员记忆。
2
+
3
+ 严格只输出 JSON,不要 Markdown、解释或额外文字:
4
+
5
+ {"memory":"更新后的长期记忆全文;不能清空"}
@@ -0,0 +1,32 @@
1
+ 你负责维护当前 QQ 私聊对象的长期记忆。你会收到已有记忆和一批按时间排列的私聊消息,只针对这个 senderId,不针对 BOT,也不针对其他被提到的人。
2
+
3
+ ## 一、事实边界和主体判断
4
+
5
+ 输入中的 direction 是硬性事实边界:
6
+
7
+ - direction=inbound 的消息是用户自己的表达,才可能产生用户事实;
8
+ - direction=outbound 的 BOT 消息只是对话上下文,不能证明用户拥有某个偏好、身份、经历或状态;
9
+ - 引用、转述、第三方描述、代词、玩笑、反问和“听说/好像/我觉得他”等内容,主体不明确时宁可不记;
10
+ - senderId 是唯一身份依据,不能根据昵称、语气、最近发言或模型推断补全身份。
11
+
12
+ ## 二、记忆字段
13
+
14
+ - profile:当前用户稳定的身份、背景、地址、称呼和长期偏好。阶段性内容(最近、刚刚、目前、本周、暂时)不要写入。
15
+ - pattern:当前用户重复出现且可复用的行为或决策模式;一次行为不能直接推断稳定人格。
16
+ - summary:不超过 120 字的近期状态或关注点;没有变化时原样沿用 existing,不要清空或改成流水账。
17
+ - daily:本轮重要但阶段性的事实,一句话即可,涉及时间必须使用绝对日期。
18
+ - memory:高价值、可长期回看的用户个人事件,保留必要时间、背景、行动和结果,不复制聊天原文。
19
+
20
+ ## 三、增量和安全规则
21
+
22
+ - profile_add/profile_remove、pattern_add/pattern_remove 只包含本轮新增或明确修正,不要回显已有内容。
23
+ - 只有被当前用户明确推翻的旧资料或模式才进入 remove 数组。
24
+ - 不要把 BOT 的建议、观点或回复写成用户事实。
25
+ - 不要把其他人的个人信息、群组规则或群体事件写入当前用户记忆。
26
+ - 不确定时宁可漏记;existing 是已有事实,应克制更新而不是无条件推翻。
27
+
28
+ 严格只输出 JSON,不要 Markdown、解释或额外文字。
29
+
30
+ 输出结构:
31
+
32
+ {"profile_add":[{"type":"name|address|pronoun|background|preference|note","text":"只描述当前私聊对象","ts":0}],"profile_remove":[{"text":"被明确推翻的旧资料"}],"pattern_add":[],"pattern_remove":[],"summary":"不超过120字的当前状态","daily":"绝对日期的阶段性事实","memory":"高价值个人事件"}
package/src/config.ts CHANGED
@@ -10,10 +10,10 @@ const DEFAULT_CONFIG = Object.freeze({
10
10
  groupReadEnabled: true,
11
11
  replyFormat: 'smart' as ReplyFormat,
12
12
  recentGroupMessages: 40,
13
- reflectionIdleMs: 120_000,
13
+ reflectionIdleMs: 900_000,
14
14
  reflectionBatchSize: 20,
15
15
  reflectionMaxMessages: 80,
16
- memoryMaxTokens: 1400,
16
+ memoryMaxTokens: 4096,
17
17
  memoryCompressionMaxTokens: 15000,
18
18
  })
19
19
 
@@ -50,6 +50,7 @@ export function defaultRuntimeSettings(config: QQChatConfig): QQChatRuntimeSetti
50
50
  : config.groupRequiresAt ? 'mention' : 'auto'
51
51
  return {
52
52
  memoryEnabled: true,
53
+ memoryMemberBatchEnabled: true,
53
54
  groupReceiveMode,
54
55
  groupReplyFormat: 'compat',
55
56
  directReplyFormat: config.replyFormat,
package/src/index.ts CHANGED
@@ -40,7 +40,9 @@ export function apply(ctx: Context, inputConfig: QQChatConfigInput = {}): void {
40
40
 
41
41
  ctx.connection.rpc.handle('/qqchat', createQQChatRpc(runtime), { authority: 'loopback' })
42
42
  ctx.effect(() => {
43
- void runtime.start().catch(error => logger.error?.(error))
43
+ void (async () => {
44
+ await runtime.start()
45
+ })().catch(error => logger.error?.(error))
44
46
  return () => runtime.stop()
45
47
  }, 'dsh-qqchat runtime')
46
48
  }
@@ -39,7 +39,9 @@ interface AgentPreset { id: string }
39
39
  interface AgentPresetService { resolve(id?: string): Promise<AgentPreset>; list(): Promise<AgentPreset[]>; mount(ctx: Context, id: string): Promise<unknown>; recompose(ctx: Context, id: string): Promise<AgentPreset> }
40
40
  interface SessionPersistenceService { inspect(id: SessionId): Promise<{ meta: Session['header']; events: readonly SessionEvent[] }> }
41
41
  interface SessionTitleService { get(session: Session): unknown; rename(session: Session, title: string): unknown }
42
- interface WorkspaceRegistryService { archivedSessionIds: readonly string[] }
42
+ interface WorkspaceRegistryService {
43
+ archivedSessionIds: readonly string[]
44
+ }
43
45
  interface Composition { presetId?: string; setup?: AgentSetup }
44
46
  interface ActiveActor { chatType: ChatType; senderId: string }
45
47
 
@@ -12,7 +12,7 @@ export interface MemorySnapshotEvent {
12
12
  type?: string
13
13
  time?: number
14
14
  data?: {
15
- source?: { kind?: string; plugin?: string }
15
+ source?: { kind?: string; plugin?: string; sections?: readonly { name?: string; text?: string }[] }
16
16
  content?: readonly { type?: string; text?: string }[]
17
17
  }
18
18
  }
@@ -32,8 +32,11 @@ export function restoreMemorySnapshotState(events: readonly MemorySnapshotEvent[
32
32
  for (let index = events.length - 1; index >= 0; index -= 1) {
33
33
  const event = events[index]
34
34
  if (!event) continue
35
- if (event.type !== 'user/message' || event.data?.source?.kind !== 'plugin' || event.data.source.plugin !== sourcePlugin) continue
36
- const block = event.data.content?.length === 1 ? event.data.content[0] : undefined
35
+ const source = event.data?.source
36
+ if (event.type !== 'user/message' || source?.kind !== 'plugin' || source.plugin !== sourcePlugin) continue
37
+ if (!source.sections?.some(section => section.name === 'qq-chat-context')) continue
38
+ const content = event.data?.content
39
+ const block = content?.length === 1 ? content[0] : undefined
37
40
  if (block?.type !== 'text' || typeof block.text !== 'string') continue
38
41
  return { hash: memorySnapshotHash(block.text), lastInjectedAt: Number(event.time) || 0, stale: lastCompactionAt > (Number(event.time) || 0) }
39
42
  }
@@ -58,6 +58,7 @@ export class QQChatRuntime {
58
58
 
59
59
  updateSettings(patch: QQChatRuntimeSettingsPatch): QQChatRuntimeSettings {
60
60
  if (patch.memoryEnabled !== undefined) this.db.setSetting('memoryEnabled', Boolean(patch.memoryEnabled))
61
+ if (patch.memoryMemberBatchEnabled !== undefined) this.db.setSetting('memoryMemberBatchEnabled', Boolean(patch.memoryMemberBatchEnabled))
61
62
  if (patch.groupReceiveMode !== undefined) {
62
63
  if (!isGroupReceiveMode(patch.groupReceiveMode)) throw new Error('无效的群聊接收模式')
63
64
  this.db.setSetting('groupReceiveMode', patch.groupReceiveMode)
@@ -120,7 +121,10 @@ export class QQChatRuntime {
120
121
  content: text, quotedText: '', mentioned: false, createdAt: Date.now(),
121
122
  }, row, true)
122
123
  if (this.settings().memoryEnabled) {
123
- if (chatType === 'group') this.memory.schedule(Number(row.id))
124
+ if (chatType === 'group') {
125
+ this.memory.schedule(Number(row.id))
126
+ if (settings.memoryMemberBatchEnabled) this.memory.scheduleGroupMembers(Number(row.id))
127
+ }
124
128
  else this.memory.scheduleMember(Number(row.id))
125
129
  }
126
130
  this.logger.info?.(`[dsh-qqchat] active ${chatType} message sent to ${shortId(targetId)}`)
@@ -241,8 +245,10 @@ export class QQChatRuntime {
241
245
  quotedText: message.quotedText, mentioned: message.mentioned, createdAt: Date.now(), attachments: publicAttachments(ownAttachments), quote: safeQuote,
242
246
  }
243
247
  if (settings.memoryEnabled) {
244
- if (group) this.memory.schedule(Number(group.id))
245
- else this.memory.scheduleMember(Number(member.id))
248
+ if (group) {
249
+ this.memory.schedule(Number(group.id))
250
+ if (settings.memoryMemberBatchEnabled) this.memory.scheduleGroupMembers(Number(group.id))
251
+ }
246
252
  }
247
253
 
248
254
  if (!shouldReply) {
@@ -279,7 +285,10 @@ export class QQChatRuntime {
279
285
  memberId: message.chatType === 'c2c' ? member.id : undefined, direction: 'outbound', content: reply, mentioned: false,
280
286
  })
281
287
  if (settings.memoryEnabled) {
282
- if (group) this.memory.schedule(Number(group.id))
288
+ if (group) {
289
+ this.memory.schedule(Number(group.id))
290
+ if (settings.memoryMemberBatchEnabled) this.memory.scheduleGroupMembers(Number(group.id))
291
+ }
283
292
  else this.memory.scheduleMember(Number(member.id))
284
293
  }
285
294
  } catch (error) {
package/src/storage/db.ts CHANGED
@@ -27,9 +27,11 @@ import type {
27
27
  QQQuoteIndexRow,
28
28
  QQQuoteInput,
29
29
  StoredAttachmentSummary,
30
+ ReflectionTaskInput,
30
31
  } from '../types.js'
31
32
 
32
33
  const now = (): number => Date.now()
34
+ const REFLECTION_TASK_STALE_MS = 15 * 60 * 1000
33
35
 
34
36
  function parseStringList(value: string | null | undefined): string[] {
35
37
  if (!value) return []
@@ -233,8 +235,23 @@ export class QQChatDatabase {
233
235
  CREATE TABLE IF NOT EXISTS reflection_state (
234
236
  group_id INTEGER PRIMARY KEY REFERENCES groups(id) ON DELETE CASCADE,
235
237
  last_message_id INTEGER NOT NULL DEFAULT 0,
238
+ last_member_reflected_message_id INTEGER NOT NULL DEFAULT 0,
236
239
  last_reflected_at INTEGER
237
240
  );
241
+ CREATE TABLE IF NOT EXISTS reflection_tasks (
242
+ id INTEGER PRIMARY KEY,
243
+ scope_type TEXT NOT NULL CHECK(scope_type IN ('group','member','private')),
244
+ scope_key INTEGER NOT NULL,
245
+ task_type TEXT NOT NULL CHECK(task_type IN ('group','member-batch','private-owner')),
246
+ start_message_id INTEGER NOT NULL,
247
+ end_message_id INTEGER NOT NULL,
248
+ status TEXT NOT NULL CHECK(status IN ('running','completed','failed')),
249
+ attempts INTEGER NOT NULL DEFAULT 0,
250
+ idempotency_key TEXT NOT NULL UNIQUE,
251
+ created_at INTEGER NOT NULL,
252
+ updated_at INTEGER NOT NULL
253
+ );
254
+ CREATE INDEX IF NOT EXISTS reflection_tasks_scope ON reflection_tasks(scope_type, scope_key, task_type, status);
238
255
  CREATE TABLE IF NOT EXISTS outbox (
239
256
  id INTEGER PRIMARY KEY,
240
257
  account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
@@ -247,6 +264,8 @@ export class QQChatDatabase {
247
264
  error TEXT
248
265
  );
249
266
  `)
267
+ this.db.prepare("UPDATE reflection_tasks SET status='failed',updated_at=? WHERE status='running' AND updated_at<?")
268
+ .run(now(), now() - REFLECTION_TASK_STALE_MS)
250
269
  const columns = new Set(many<{ name: string }>(this.db.prepare('PRAGMA table_info(group_members)').all()).map(column => column.name))
251
270
  const groupColumns = new Set(many<{ name: string }>(this.db.prepare('PRAGMA table_info(groups)').all()).map(column => column.name))
252
271
  if (groupColumns.has('requires_at')) this.db.exec('ALTER TABLE groups DROP COLUMN requires_at')
@@ -254,6 +273,10 @@ export class QQChatDatabase {
254
273
  if (!columns.has('nicknames_json')) this.db.exec("ALTER TABLE group_members ADD COLUMN nicknames_json TEXT NOT NULL DEFAULT '[]'")
255
274
  if (!columns.has('message_count')) this.db.exec('ALTER TABLE group_members ADD COLUMN message_count INTEGER NOT NULL DEFAULT 0')
256
275
  const messageColumns = new Set(many<{ name: string }>(this.db.prepare('PRAGMA table_info(messages)').all()).map(column => column.name))
276
+ const reflectionColumns = new Set(many<{ name: string }>(this.db.prepare('PRAGMA table_info(reflection_state)').all()).map(column => column.name))
277
+ if (!reflectionColumns.has('last_member_reflected_message_id')) {
278
+ this.db.exec('ALTER TABLE reflection_state ADD COLUMN last_member_reflected_message_id INTEGER NOT NULL DEFAULT 0')
279
+ }
257
280
  if (!messageColumns.has('attachments_json')) this.db.exec('ALTER TABLE messages ADD COLUMN attachments_json TEXT')
258
281
  if (!messageColumns.has('quote_json')) this.db.exec('ALTER TABLE messages ADD COLUMN quote_json TEXT')
259
282
  this.db.exec(`UPDATE group_members SET message_count=(
@@ -375,6 +398,7 @@ export class QQChatDatabase {
375
398
  runtimeSettings(defaults: QQChatRuntimeSettings): QQChatRuntimeSettings {
376
399
  return {
377
400
  memoryEnabled: this.getSetting('memoryEnabled', defaults.memoryEnabled),
401
+ memoryMemberBatchEnabled: this.getSetting('memoryMemberBatchEnabled', defaults.memoryMemberBatchEnabled),
378
402
  groupReceiveMode: this.getSetting('groupReceiveMode', defaults.groupReceiveMode),
379
403
  groupReplyFormat: this.getSetting('groupReplyFormat', defaults.groupReplyFormat),
380
404
  directReplyFormat: this.getSetting('directReplyFormat', this.getSetting('replyFormat', defaults.directReplyFormat)),
@@ -690,12 +714,58 @@ export class QQChatDatabase {
690
714
  return Number(row?.n || 0)
691
715
  }
692
716
 
717
+ unreflectedMemberMessages(groupId: number, limit = 80): MessageRow[] {
718
+ const state = one<{ last_member_reflected_message_id: number }>(this.db.prepare(
719
+ 'SELECT last_member_reflected_message_id FROM reflection_state WHERE group_id=?',
720
+ ).get(groupId))
721
+ const after = Number(state?.last_member_reflected_message_id || 0)
722
+ return many<MessageRow>(this.db.prepare(`SELECT m.*,mem.platform_user_id,mem.display_name
723
+ FROM messages m LEFT JOIN members mem ON mem.id=m.member_id
724
+ WHERE m.group_id=? AND m.id>? ORDER BY m.id ASC LIMIT ?`).all(groupId, after, limit))
725
+ }
726
+
727
+ unreflectedMemberCount(groupId: number): number {
728
+ const state = one<{ last_member_reflected_message_id: number }>(this.db.prepare(
729
+ 'SELECT last_member_reflected_message_id FROM reflection_state WHERE group_id=?',
730
+ ).get(groupId))
731
+ const row = one<{ n: number }>(this.db.prepare(
732
+ 'SELECT COUNT(*) AS n FROM messages WHERE group_id=? AND id>?',
733
+ ).get(groupId, Number(state?.last_member_reflected_message_id || 0)))
734
+ return Number(row?.n || 0)
735
+ }
736
+
693
737
  markReflected(groupId: number, messageId: number): void {
694
738
  this.db.prepare(`INSERT INTO reflection_state(group_id,last_message_id,last_reflected_at) VALUES(?,?,?)
695
739
  ON CONFLICT(group_id) DO UPDATE SET last_message_id=excluded.last_message_id,last_reflected_at=excluded.last_reflected_at`)
696
740
  .run(groupId, messageId, now())
697
741
  }
698
742
 
743
+ markMembersReflected(groupId: number, messageId: number): void {
744
+ this.db.prepare(`INSERT INTO reflection_state(group_id,last_message_id,last_member_reflected_message_id,last_reflected_at) VALUES(?,?,?,?)
745
+ ON CONFLICT(group_id) DO UPDATE SET last_member_reflected_message_id=excluded.last_member_reflected_message_id,last_reflected_at=excluded.last_reflected_at`)
746
+ .run(groupId, 0, messageId, now())
747
+ }
748
+
749
+ startReflectionTask(input: ReflectionTaskInput): boolean {
750
+ const existing = one<{ id: number; status: string }>(this.db.prepare(
751
+ 'SELECT id,status FROM reflection_tasks WHERE idempotency_key=?',
752
+ ).get(input.idempotencyKey))
753
+ if (existing?.status === 'completed' || existing?.status === 'running') return false
754
+ if (existing) {
755
+ this.db.prepare(`UPDATE reflection_tasks SET status='running',attempts=attempts+1,updated_at=? WHERE id=?`)
756
+ .run(now(), existing.id)
757
+ return true
758
+ }
759
+ this.db.prepare(`INSERT INTO reflection_tasks(scope_type,scope_key,task_type,start_message_id,end_message_id,status,attempts,idempotency_key,created_at,updated_at)
760
+ VALUES(?,?,?,?,?,'running',1,?,?,?)`)
761
+ .run(input.scopeType, input.scopeKey, input.taskType, input.startMessageId, input.endMessageId, input.idempotencyKey, now(), now())
762
+ return true
763
+ }
764
+
765
+ finishReflectionTask(idempotencyKey: string, status: 'completed' | 'failed'): void {
766
+ this.db.prepare('UPDATE reflection_tasks SET status=?,updated_at=? WHERE idempotency_key=?').run(status, now(), idempotencyKey)
767
+ }
768
+
699
769
  memoryDocs(scopeType: MemoryScopeType, scopeKey: number): MemoryDocuments {
700
770
  const rows = many<{ doc_type: MemoryDocType; content: string; updated_at: number }>(
701
771
  this.db.prepare('SELECT doc_type,content,updated_at FROM memory_documents WHERE scope_type=? AND scope_key=?')