dsh-qqchat 0.2.4 → 0.2.6

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 (45) hide show
  1. package/README.en.md +5 -4
  2. package/README.md +3 -3
  3. package/client-src/plugin.cts +2 -2
  4. package/client-src/settings.cts +18 -1
  5. package/client-src/shared.cts +4 -4
  6. package/cordis.patch.yml +5 -0
  7. package/docs/ARCHITECTURE.en.md +1 -1
  8. package/docs/ARCHITECTURE.md +2 -1
  9. package/docs/DEVELOPMENT.md +3 -1
  10. package/docs/MEMORY.md +32 -4
  11. package/docs/MEMORY_CONTEXT.md +16 -0
  12. package/docs/README.md +2 -0
  13. package/docs/STREAMING.md +2 -2
  14. 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
  15. package/docs/prds/README.md +62 -0
  16. package/lib/client.js +21 -4
  17. package/lib/config.js +3 -2
  18. package/lib/index.d.ts +2 -2
  19. package/lib/index.js +2 -3
  20. package/lib/session/agent-bridge.d.ts +3 -4
  21. package/lib/session/agent-bridge.js +40 -46
  22. package/lib/session/memory-snapshot.d.ts +4 -0
  23. package/lib/session/memory-snapshot.js +6 -2
  24. package/lib/session/runtime.js +15 -5
  25. package/lib/storage/db.d.ts +6 -1
  26. package/lib/storage/db.js +57 -0
  27. package/lib/storage/memory.d.ts +28 -2
  28. package/lib/storage/memory.js +375 -101
  29. package/lib/transport/rpc.js +2 -0
  30. package/lib/types.d.ts +26 -0
  31. package/package.json +24 -23
  32. package/prompts/group-compression.md +5 -0
  33. package/prompts/group-reflection.md +39 -0
  34. package/prompts/member-batch-reflection.md +39 -0
  35. package/prompts/member-compression.md +5 -0
  36. package/prompts/private-reflection.md +32 -0
  37. package/src/config.ts +3 -2
  38. package/src/index.ts +2 -3
  39. package/src/session/agent-bridge.ts +40 -47
  40. package/src/session/memory-snapshot.ts +6 -3
  41. package/src/session/runtime.ts +14 -4
  42. package/src/storage/db.ts +70 -0
  43. package/src/storage/memory.ts +343 -84
  44. package/src/transport/rpc.ts +1 -0
  45. 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.4",
3
+ "version": "0.2.6",
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",
@@ -38,26 +39,25 @@
38
39
  "typecheck": "tsc -p tsconfig.json --noEmit",
39
40
  "test": "tsx --test tests/*.test.ts",
40
41
  "check": "npm run typecheck && npm test && npm run build",
41
- "prepublishOnly": "npm run check",
42
- "prepare": "npm run build"
42
+ "prepublishOnly": "npm run check"
43
43
  },
44
44
  "dependencies": {
45
45
  "qrcode": "^1.5.4"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@deepseek-ai/cordis": "^4.0.1",
49
- "@deepseek-ai/dsh-attachment": "^0.1.1-rc.2",
50
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
51
- "@deepseek-ai/dsh-agent-presets": "^0.1.1-rc.2",
52
- "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
53
- "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
54
- "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
55
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.2",
56
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2",
57
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
58
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
59
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
60
- "@deepseek-ai/dsh-tools": "^0.1.1-rc.2"
49
+ "@deepseek-ai/dsh-attachment": "^0.1.6-alpha.1",
50
+ "@deepseek-ai/dsh-agent": "^0.1.6-alpha.1",
51
+ "@deepseek-ai/dsh-agent-presets": "^0.1.6-alpha.1",
52
+ "@deepseek-ai/dsh-commands": "^0.1.6-alpha.1",
53
+ "@deepseek-ai/dsh-client-connection": "^0.1.6-alpha.1",
54
+ "@deepseek-ai/dsh-client-modules": "^0.1.6-alpha.1",
55
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.6-alpha.1",
56
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.6-alpha.1",
57
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.6-alpha.1",
58
+ "@deepseek-ai/dsh-llm": "^0.1.6-alpha.1",
59
+ "@deepseek-ai/dsh-session": "^0.1.6-alpha.1",
60
+ "@deepseek-ai/dsh-tools": "^0.1.6-alpha.1"
61
61
  },
62
62
  "peerDependenciesMeta": {
63
63
  "@deepseek-ai/dsh-client-connection": {
@@ -66,7 +66,7 @@
66
66
  "@deepseek-ai/dsh-client-ui-settings": {
67
67
  "optional": true
68
68
  },
69
- "@deepseek-ai/dsh-client-runtime": {
69
+ "@deepseek-ai/dsh-client-modules": {
70
70
  "optional": true
71
71
  },
72
72
  "@deepseek-ai/dsh-client-ui-conversation": {
@@ -74,11 +74,12 @@
74
74
  }
75
75
  },
76
76
  "devDependencies": {
77
- "@deepseek-ai/dsh-attachment": "^0.1.1-rc.2",
78
- "@deepseek-ai/dsh-agent-default-model": "^0.1.1-rc.2",
79
- "@deepseek-ai/dsh-agent-presets": "^0.1.1-rc.2",
80
- "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
81
- "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
77
+ "@deepseek-ai/dsh-attachment": "^0.1.6-alpha.1",
78
+ "@deepseek-ai/dsh-agent-default-model": "^0.1.6-alpha.1",
79
+ "@deepseek-ai/dsh-agent-presets": "^0.1.6-alpha.1",
80
+ "@deepseek-ai/dsh-commands": "^0.1.6-alpha.1",
81
+ "@deepseek-ai/dsh-client-connection": "^0.1.6-alpha.1",
82
+ "@deepseek-ai/dsh-client-modules": "^0.1.6-alpha.1",
82
83
  "@types/node": "^22.18.0",
83
84
  "@types/qrcode": "^1.5.5",
84
85
  "@types/react": "^19.1.0",
@@ -93,7 +94,7 @@
93
94
  "platform": "web",
94
95
  "inject": [
95
96
  "@deepseek-ai/dsh-client-connection",
96
- "@deepseek-ai/dsh-client-runtime",
97
+ "@deepseek-ai/dsh-client-modules",
97
98
  "@deepseek-ai/dsh-client-ui-conversation",
98
99
  "@deepseek-ai/dsh-client-ui-settings"
99
100
  ]
@@ -101,7 +102,7 @@
101
102
  },
102
103
  "repository": {
103
104
  "type": "git",
104
- "url": "git+ssh://git@github.com/Coffeiz/dsh-qqchat.git"
105
+ "url": "https://github.com/Coffeiz/dsh-qqchat"
105
106
  },
106
107
  "keywords": [
107
108
  "deepseek-harness",
@@ -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
@@ -20,7 +20,7 @@ import type { LoggerLike, QQChatConfigInput } from './types.js'
20
20
  const qqChatSessionEventRegistrations = await registerQQChatSessionEventType()
21
21
 
22
22
  export const name = 'dsh-qqchat'
23
- export const inject = ['connection', 'agents', 'agentDefaultModel', 'commands', 'llm', 'tools', 'workspaceRegistry'] as const
23
+ export const inject = ['connection', 'agents', 'agentDefaultModel', 'commands', 'llm', 'tools', 'webServer', 'workspaceRegistry'] as const
24
24
 
25
25
  export function apply(ctx: Context, inputConfig: QQChatConfigInput = {}): void {
26
26
  const config = resolveConfig(inputConfig)
@@ -38,10 +38,9 @@ export function apply(ctx: Context, inputConfig: QQChatConfigInput = {}): void {
38
38
  const bridge = new DshQQBridge(ctx, db, memory, config, logger)
39
39
  const runtime = new QQChatRuntime(ctx, db, api, auth, memory, bridge, config, logger, media)
40
40
 
41
- ctx.connection.rpc.handle('/qqchat', createQQChatRpc(runtime), { authority: 'loopback' })
41
+ ctx.connection.rpc.handle('/qqchat', createQQChatRpc(runtime))
42
42
  ctx.effect(() => {
43
43
  void (async () => {
44
- await bridge.ensureWorkspaceMembership()
45
44
  await runtime.start()
46
45
  })().catch(error => logger.error?.(error))
47
46
  return () => runtime.stop()
@@ -3,7 +3,7 @@ import { createRequire } from 'node:module'
3
3
  import type { Context } from '@deepseek-ai/cordis'
4
4
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
5
5
  import type { AgentHandle, AgentOptions, AgentSetup, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
6
- import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
6
+ import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
7
7
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
8
8
  import type { ContentBlock } from '@deepseek-ai/dsh-llm'
9
9
  import type { CommandInvocation, CommandRuntime, CommandResult } from '@deepseek-ai/dsh-commands'
@@ -39,16 +39,17 @@ 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 WorkspaceEntity { attachSession(sessionId: SessionId): Promise<void> }
43
42
  interface WorkspaceRegistryService {
44
43
  archivedSessionIds: readonly string[]
45
- create(path: string, title?: string): Promise<WorkspaceEntity>
44
+ resolveByPath(path: string): Promise<{ attachSession(sessionId: import('@deepseek-ai/dsh-session').SessionId): Promise<void> } | undefined>
46
45
  }
47
46
  interface Composition { presetId?: string; setup?: AgentSetup }
48
47
  interface ActiveActor { chatType: ChatType; senderId: string }
49
48
 
50
49
  export function resolveQQSessionPreset(header: Session['header'], events: readonly SessionEvent[]): string | undefined {
51
- return resolveSessionPreset({ header, events })
50
+ let current = agentPresetProjectionDefinition.init(header)
51
+ for (const event of events) current = agentPresetProjectionDefinition.apply(current, event)
52
+ return current || undefined
52
53
  }
53
54
 
54
55
  const MEDIA_TOOL_NAMES = new Set(['qqchat_describe_image', 'qqchat_read_file', 'qqchat_media_info'])
@@ -66,8 +67,8 @@ export class DshQQBridge {
66
67
  private readonly activeAttachments = new Map<string, Set<string>>()
67
68
  private readonly activeMediaReadable = new Map<string, boolean>()
68
69
  private readonly memorySnapshots = new Map<string, MemorySnapshotState>()
69
- private qqWorkspace?: Promise<WorkspaceEntity | undefined>
70
70
  private readonly disposeEvent: () => void
71
+ private readonly disposeAssistantStream: () => void
71
72
  private readonly disposeToolGate: () => void
72
73
  private readonly disposeCommands: () => void
73
74
  private readonly disposeImageTool: () => void
@@ -81,6 +82,10 @@ export class DshQQBridge {
81
82
  private readonly logger: LoggerLike = console,
82
83
  ) {
83
84
  this.disposeEvent = ctx.on('session/event', (session, event) => this.onSessionEvent(session, event))
85
+ this.disposeAssistantStream = ctx.on('agent/assistant-stream', ({ agent, frame }) => {
86
+ if (frame.type !== 'chunk' || frame.chunk.type !== 'text-delta' || !frame.chunk.text) return
87
+ this.pending.get(String(agent.id))?.onTextDelta?.(frame.chunk.text)
88
+ })
84
89
  this.disposeToolGate = ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
85
90
  const agent = exec.agent
86
91
  if (!agent) return next()
@@ -103,8 +108,19 @@ export class DshQQBridge {
103
108
  this.disposeCommands = this.registerCommands()
104
109
  }
105
110
 
111
+ async attachMappedSessionsToWorkspace(): Promise<void> {
112
+ const sessionIds = [
113
+ ...this.db.listGroups().map(row => row.dsh_session_id),
114
+ ...this.db.listDirectChats().map(row => row.dsh_session_id),
115
+ ]
116
+ for (const sessionId of sessionIds) {
117
+ if (sessionId) await this.attachToWorkspace(sessionId)
118
+ }
119
+ }
120
+
106
121
  async dispose(): Promise<void> {
107
122
  this.disposeEvent()
123
+ this.disposeAssistantStream()
108
124
  this.disposeToolGate()
109
125
  this.disposeImageTool()
110
126
  this.disposeMediaTools()
@@ -127,16 +143,6 @@ export class DshQQBridge {
127
143
  return sessionId
128
144
  }
129
145
 
130
- async ensureWorkspaceMembership(): Promise<void> {
131
- const sessionIds = [
132
- ...this.db.listGroups().map(row => row.dsh_session_id),
133
- ...this.db.listDirectChats().map(row => row.dsh_session_id),
134
- ]
135
- for (const sessionId of sessionIds) {
136
- if (sessionId) await this.attachToQQWorkspace(sessionId)
137
- }
138
- }
139
-
140
146
  async recordTranscript(event: QQChatDisplayEvent, row: GroupRow | MemberRow, createSession = false): Promise<string | undefined> {
141
147
  const existing = this.db.getChatSession(event.chatType, Number(row.id))
142
148
  if (!existing && !createSession) return undefined
@@ -248,12 +254,6 @@ export class DshQQBridge {
248
254
  if (text.trim()) pending.text = text
249
255
  return
250
256
  }
251
- if (event.type === 'assistant/chunk') {
252
- const pending = this.pending.get(id)
253
- const chunk = event.data.chunk
254
- if (pending?.onTextDelta && chunk.type === 'text-delta' && chunk.text) pending.onTextDelta(chunk.text)
255
- return
256
- }
257
257
  if (event.type === 'request/header') {
258
258
  const { config } = event.data.header
259
259
  if (config.provider && config.model) this.routes.set(id, { provider: config.provider, model: config.model })
@@ -284,7 +284,7 @@ export class DshQQBridge {
284
284
  }
285
285
 
286
286
  private restoreMemorySnapshot(session: Session): MemorySnapshotState | undefined {
287
- return restoreMemorySnapshotState(session.events as unknown as Parameters<typeof restoreMemorySnapshotState>[0], DSH_RUNTIME_CONTEXT_SOURCE)
287
+ return restoreMemorySnapshotState(session.snapshotEvents() as unknown as Parameters<typeof restoreMemorySnapshotState>[0], DSH_RUNTIME_CONTEXT_SOURCE)
288
288
  }
289
289
 
290
290
  private async ensureAgent(chatType: ChatType, row: GroupRow | MemberRow): Promise<{ agent: AgentHandle['agent']; sessionId: string }> {
@@ -305,16 +305,16 @@ export class DshQQBridge {
305
305
  if (live) {
306
306
  this.ensureSelection(live)
307
307
  this.ensureTitle(live.session, chatType, row)
308
+ await this.attachToWorkspace(sessionId)
308
309
  this.rememberRoute(chatType, row, live, sessionId)
309
- await this.attachToQQWorkspace(sessionId)
310
310
  return { agent: live, sessionId }
311
311
  }
312
312
  const resumed = await this.tryResume(sessionId)
313
313
  if (resumed) {
314
314
  this.ensureSelection(resumed.agent)
315
315
  this.ensureTitle(resumed.agent.session, chatType, row)
316
+ await this.attachToWorkspace(sessionId)
316
317
  this.rememberRoute(chatType, row, resumed.agent, sessionId)
317
- await this.attachToQQWorkspace(sessionId)
318
318
  return { agent: resumed.agent, sessionId }
319
319
  }
320
320
  }
@@ -332,36 +332,28 @@ export class DshQQBridge {
332
332
  this.handles.set(sessionId, handle)
333
333
  this.ensureSelection(handle.agent)
334
334
  this.db.setChatSession(chatType, Number(row.id), sessionId)
335
+ await this.attachToWorkspace(sessionId)
335
336
  this.ensureTitle(handle.agent.session, chatType, row)
336
337
  this.rememberRoute(chatType, row, handle.agent, sessionId)
337
- await this.attachToQQWorkspace(sessionId)
338
338
  return { agent: handle.agent, sessionId }
339
339
  }
340
340
 
341
- /** Keep all QQ sessions together in one DSH Workspace using the host API. */
342
- private async attachToQQWorkspace(sessionId: string): Promise<void> {
341
+ private isArchived(sessionId: string): boolean {
342
+ const registry = (this.ctx as unknown as { workspaceRegistry?: WorkspaceRegistryService }).workspaceRegistry
343
+ return registry?.archivedSessionIds.includes(sessionId) ?? false
344
+ }
345
+
346
+ private async attachToWorkspace(sessionId: string): Promise<void> {
343
347
  const registry = (this.ctx as unknown as { workspaceRegistry?: WorkspaceRegistryService }).workspaceRegistry
344
348
  if (!registry) return
345
- if (!this.qqWorkspace) {
346
- this.qqWorkspace = registry.create(process.cwd(), 'QQ Chat').catch(error => {
347
- this.logger.warn?.(`[dsh-qqchat] QQ Workspace 创建失败,将保留在未分组: ${error instanceof Error ? error.message : String(error)}`)
348
- return undefined
349
- })
350
- }
351
- const workspace = await this.qqWorkspace
352
- if (!workspace) return
353
349
  try {
354
- await workspace.attachSession(SessionId(sessionId))
350
+ const workspace = await registry.resolveByPath(process.cwd())
351
+ await workspace?.attachSession(SessionId(sessionId))
355
352
  } catch (error) {
356
- this.logger.warn?.(`[dsh-qqchat] QQ session ${sessionId} 加入 Workspace 失败,将保留在未分组: ${error instanceof Error ? error.message : String(error)}`)
353
+ this.logger.warn?.(`[dsh-qqchat] QQ session ${sessionId} 未能挂载到当前工作区: ${error instanceof Error ? error.message : String(error)}`)
357
354
  }
358
355
  }
359
356
 
360
- private isArchived(sessionId: string): boolean {
361
- const registry = (this.ctx as unknown as { workspaceRegistry?: WorkspaceRegistryService }).workspaceRegistry
362
- return registry?.archivedSessionIds.includes(sessionId) ?? false
363
- }
364
-
365
357
  private rememberRoute(chatType: ChatType, row: GroupRow | MemberRow, agent: AgentHandle['agent'], sessionId: string): void {
366
358
  const provider = agent.options.provider || this.config.provider
367
359
  const model = agent.options.model || this.config.model
@@ -371,7 +363,7 @@ export class DshQQBridge {
371
363
  }
372
364
 
373
365
  private appendDisplayIfMissing(session: Session, event: QQChatDisplayEvent): void {
374
- if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId)) return
366
+ if (session.snapshotEvents().some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId)) return
375
367
  // Keep this on the public Session.append signature. Official DSH releases
376
368
  // do not yet expose the optional ignorable envelope marker; if an older
377
369
  // DSH cannot restore this plugin-only event, ensureAgent falls back to a
@@ -380,7 +372,7 @@ export class DshQQBridge {
380
372
  }
381
373
 
382
374
  private appendOwnerMessageIfMissing(session: Session, event: QQChatDisplayEvent): void {
383
- if (session.events.some(item => {
375
+ if (session.snapshotEvents().some(item => {
384
376
  if (item.type !== 'user/message') return false
385
377
  const source = item.data.source as unknown as { messageId?: string }
386
378
  return source.messageId === event.messageId
@@ -556,7 +548,7 @@ export class DshQQBridge {
556
548
  const available = await presets.list()
557
549
  return { kind: 'success', text: available.length ? `可用 Agent Preset:\n${available.map(preset => `/qqpreset ${preset.id}`).join('\n')}` : '当前没有可用 Agent Preset。' }
558
550
  }
559
- if (agent.session.events.some(event => event.type === 'turn/start')) {
551
+ if (agent.session.snapshotEvents().some(event => event.type === 'turn/start')) {
560
552
  return { kind: 'error', text: '当前 Session 已经开始对话,DSH 不允许更换 Agent Preset。请先使用 /qqnew 开启新 Session。' }
561
553
  }
562
554
  try {
@@ -574,8 +566,9 @@ export class DshQQBridge {
574
566
 
575
567
  private statusCommand(agent: AgentHandle['agent']): CommandResult {
576
568
  const current = this.selection(agent)?.current
577
- const messages = agent.session.events.filter(event => event.type === 'user/message' || event.type === 'assistant/message').length
578
- const last = agent.session.events.at(-1)
569
+ const events = agent.session.snapshotEvents()
570
+ const messages = events.filter(event => event.type === 'user/message' || event.type === 'assistant/message').length
571
+ const last = events.at(-1)
579
572
  return {
580
573
  kind: 'success',
581
574
  text: ['📊 Session 状态', `Session:${String(agent.id)}`, `状态:${agent.status === 'running' ? '生成中' : '空闲'}`, `模型:${current ? `${current.provider}/${current.model}` : '未知'}`, `消息数:${messages}`, `最后事件:${last?.type || '无'}`].join('\n'),
@@ -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
  }
@@ -29,6 +29,7 @@ export class QQChatRuntime {
29
29
  ) {}
30
30
 
31
31
  async start(): Promise<void> {
32
+ await this.bridge.attachMappedSessionsToWorkspace()
32
33
  for (const account of this.db.enabledAccounts()) this.startGateway(account)
33
34
  this.outboxTimer = setInterval(() => void this.flushOutbox(), 3000)
34
35
  this.outboxTimer.unref?.()
@@ -58,6 +59,7 @@ export class QQChatRuntime {
58
59
 
59
60
  updateSettings(patch: QQChatRuntimeSettingsPatch): QQChatRuntimeSettings {
60
61
  if (patch.memoryEnabled !== undefined) this.db.setSetting('memoryEnabled', Boolean(patch.memoryEnabled))
62
+ if (patch.memoryMemberBatchEnabled !== undefined) this.db.setSetting('memoryMemberBatchEnabled', Boolean(patch.memoryMemberBatchEnabled))
61
63
  if (patch.groupReceiveMode !== undefined) {
62
64
  if (!isGroupReceiveMode(patch.groupReceiveMode)) throw new Error('无效的群聊接收模式')
63
65
  this.db.setSetting('groupReceiveMode', patch.groupReceiveMode)
@@ -120,7 +122,10 @@ export class QQChatRuntime {
120
122
  content: text, quotedText: '', mentioned: false, createdAt: Date.now(),
121
123
  }, row, true)
122
124
  if (this.settings().memoryEnabled) {
123
- if (chatType === 'group') this.memory.schedule(Number(row.id))
125
+ if (chatType === 'group') {
126
+ this.memory.schedule(Number(row.id))
127
+ if (settings.memoryMemberBatchEnabled) this.memory.scheduleGroupMembers(Number(row.id))
128
+ }
124
129
  else this.memory.scheduleMember(Number(row.id))
125
130
  }
126
131
  this.logger.info?.(`[dsh-qqchat] active ${chatType} message sent to ${shortId(targetId)}`)
@@ -241,8 +246,10 @@ export class QQChatRuntime {
241
246
  quotedText: message.quotedText, mentioned: message.mentioned, createdAt: Date.now(), attachments: publicAttachments(ownAttachments), quote: safeQuote,
242
247
  }
243
248
  if (settings.memoryEnabled) {
244
- if (group) this.memory.schedule(Number(group.id))
245
- else this.memory.scheduleMember(Number(member.id))
249
+ if (group) {
250
+ this.memory.schedule(Number(group.id))
251
+ if (settings.memoryMemberBatchEnabled) this.memory.scheduleGroupMembers(Number(group.id))
252
+ }
246
253
  }
247
254
 
248
255
  if (!shouldReply) {
@@ -279,7 +286,10 @@ export class QQChatRuntime {
279
286
  memberId: message.chatType === 'c2c' ? member.id : undefined, direction: 'outbound', content: reply, mentioned: false,
280
287
  })
281
288
  if (settings.memoryEnabled) {
282
- if (group) this.memory.schedule(Number(group.id))
289
+ if (group) {
290
+ this.memory.schedule(Number(group.id))
291
+ if (settings.memoryMemberBatchEnabled) this.memory.scheduleGroupMembers(Number(group.id))
292
+ }
283
293
  else this.memory.scheduleMember(Number(member.id))
284
294
  }
285
295
  } catch (error) {