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.
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/client-src/plugin.cts +2 -1
- package/client-src/settings.cts +18 -1
- package/client-src/shared.cts +43 -2
- package/docs/MEMORY.md +32 -4
- package/docs/MEMORY_CONTEXT.md +16 -0
- package/docs/README.md +2 -0
- 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
- package/docs/prds/README.md +62 -0
- package/lib/client.js +64 -2
- package/lib/config.js +3 -2
- package/lib/index.js +3 -1
- package/lib/session/memory-snapshot.d.ts +4 -0
- package/lib/session/memory-snapshot.js +6 -2
- package/lib/session/runtime.js +14 -5
- package/lib/storage/db.d.ts +6 -1
- package/lib/storage/db.js +57 -0
- package/lib/storage/memory.d.ts +28 -2
- package/lib/storage/memory.js +375 -101
- package/lib/transport/rpc.js +2 -0
- package/lib/types.d.ts +26 -0
- package/package.json +2 -1
- package/prompts/group-compression.md +5 -0
- package/prompts/group-reflection.md +39 -0
- package/prompts/member-batch-reflection.md +39 -0
- package/prompts/member-compression.md +5 -0
- package/prompts/private-reflection.md +32 -0
- package/src/config.ts +3 -2
- package/src/index.ts +3 -1
- package/src/session/agent-bridge.ts +3 -1
- package/src/session/memory-snapshot.ts +6 -3
- package/src/session/runtime.ts +13 -4
- package/src/storage/db.ts +70 -0
- package/src/storage/memory.ts +343 -84
- package/src/transport/rpc.ts +1 -0
- 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
|
+
"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,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,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:
|
|
13
|
+
reflectionIdleMs: 900_000,
|
|
14
14
|
reflectionBatchSize: 20,
|
|
15
15
|
reflectionMaxMessages: 80,
|
|
16
|
-
memoryMaxTokens:
|
|
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
|
|
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 {
|
|
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
|
-
|
|
36
|
-
|
|
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
|
}
|
package/src/session/runtime.ts
CHANGED
|
@@ -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')
|
|
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)
|
|
245
|
-
|
|
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)
|
|
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=?')
|