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.
- package/LICENSE +21 -0
- package/README.en.md +276 -0
- package/README.md +211 -0
- package/client-src/plugin.cts +15 -0
- package/client-src/settings.cts +216 -0
- package/client-src/shared.cts +104 -0
- package/client-src/workspace.cts +164 -0
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.en.md +346 -0
- package/docs/ARCHITECTURE.md +411 -0
- package/docs/DEVELOPMENT.md +69 -0
- package/docs/MEDIA_AND_QUOTES.md +644 -0
- package/docs/MEMORY.md +99 -0
- package/docs/MEMORY_CONTEXT.md +254 -0
- package/docs/QUOTES.md +355 -0
- package/docs/README.md +22 -0
- package/docs/SECURITY.md +62 -0
- package/docs/STREAMING.md +347 -0
- package/docs/assets//346/211/253/347/240/201/351/223/276/346/216/245.jpg +0 -0
- package/docs/assets//347/276/244/350/201/212/347/225/214/351/235/242.jpg +0 -0
- package/docs/assets//350/256/260/345/277/206/347/263/273/347/273/237.jpg +0 -0
- package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
- package/docs/devlog.md +24 -0
- package/lib/client.js +433 -0
- package/lib/commands/dispatch.d.ts +16 -0
- package/lib/commands/dispatch.js +48 -0
- package/lib/config.d.ts +3 -0
- package/lib/config.js +63 -0
- package/lib/gateway/api.d.ts +30 -0
- package/lib/gateway/api.js +202 -0
- package/lib/gateway/auth.d.ts +35 -0
- package/lib/gateway/auth.js +80 -0
- package/lib/gateway/crypto.d.ts +2 -0
- package/lib/gateway/crypto.js +17 -0
- package/lib/gateway/gateway.d.ts +32 -0
- package/lib/gateway/gateway.js +219 -0
- package/lib/gateway/normalize.d.ts +6 -0
- package/lib/gateway/normalize.js +248 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.js +35 -0
- package/lib/media/image-tool.d.ts +4 -0
- package/lib/media/image-tool.js +62 -0
- package/lib/media/media-tools.d.ts +3 -0
- package/lib/media/media-tools.js +68 -0
- package/lib/media/store.d.ts +14 -0
- package/lib/media/store.js +176 -0
- package/lib/session/agent-bridge.d.ts +56 -0
- package/lib/session/agent-bridge.js +613 -0
- package/lib/session/memory-snapshot.d.ts +23 -0
- package/lib/session/memory-snapshot.js +27 -0
- package/lib/session/runtime.d.ts +41 -0
- package/lib/session/runtime.js +395 -0
- package/lib/shared/augmentations.d.ts +40 -0
- package/lib/shared/augmentations.js +1 -0
- package/lib/shared/logging.d.ts +14 -0
- package/lib/shared/logging.js +41 -0
- package/lib/storage/db.d.ts +82 -0
- package/lib/storage/db.js +669 -0
- package/lib/storage/memory.d.ts +50 -0
- package/lib/storage/memory.js +566 -0
- package/lib/transport/rpc.d.ts +18 -0
- package/lib/transport/rpc.js +318 -0
- package/lib/types.d.ts +416 -0
- package/lib/types.js +1 -0
- package/package.json +115 -0
- package/scripts/wrap-client.mjs +20 -0
- package/src/commands/dispatch.ts +75 -0
- package/src/config.ts +70 -0
- package/src/gateway/api.ts +260 -0
- package/src/gateway/auth.ts +99 -0
- package/src/gateway/crypto.ts +16 -0
- package/src/gateway/gateway.ts +207 -0
- package/src/gateway/normalize.ts +236 -0
- package/src/index.ts +46 -0
- package/src/media/image-tool.ts +62 -0
- package/src/media/media-tools.ts +66 -0
- package/src/media/store.ts +157 -0
- package/src/session/agent-bridge.ts +624 -0
- package/src/session/memory-snapshot.ts +41 -0
- package/src/session/runtime.ts +368 -0
- package/src/shared/augmentations.ts +45 -0
- package/src/shared/logging.ts +36 -0
- package/src/storage/db.ts +760 -0
- package/src/storage/memory.ts +581 -0
- package/src/transport/rpc.ts +316 -0
- package/src/types.ts +456 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.client.json +14 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
4
|
+
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
5
|
+
import type { AgentHandle, AgentOptions, AgentSetup, ModelSelectionRef, PreStepDecision as AgentPreStepDecision } from '@deepseek-ai/dsh-agent'
|
|
6
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
7
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
8
|
+
import type { CommandInvocation, CommandRuntime, CommandResult } from '@deepseek-ai/dsh-commands'
|
|
9
|
+
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
10
|
+
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
11
|
+
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
12
|
+
import type { PreToolDecision } from '@deepseek-ai/dsh-tools'
|
|
13
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
14
|
+
import '../shared/augmentations.js'
|
|
15
|
+
import { defaultRuntimeSettings } from '../config.js'
|
|
16
|
+
import { registerQQImageTool } from '../media/image-tool.js'
|
|
17
|
+
import { registerQQMediaTools } from '../media/media-tools.js'
|
|
18
|
+
import { dispatchQQCommand, qqCommandText } from '../commands/dispatch.js'
|
|
19
|
+
import type { QQChatDatabase } from '../storage/db.js'
|
|
20
|
+
import type { MemoryEngine } from '../storage/memory.js'
|
|
21
|
+
import type {
|
|
22
|
+
ChatType,
|
|
23
|
+
GroupRow,
|
|
24
|
+
LoggerLike,
|
|
25
|
+
MemberRow,
|
|
26
|
+
PendingReply,
|
|
27
|
+
StoredAttachmentSummary,
|
|
28
|
+
QQChatConfig,
|
|
29
|
+
QQChatDisplayEvent,
|
|
30
|
+
QQNormalizedMessage,
|
|
31
|
+
} from '../types.js'
|
|
32
|
+
import { MEMORY_SNAPSHOT_TTL_MS, memorySnapshotHash, restoreMemorySnapshotState, shouldRefreshMemorySnapshot } from './memory-snapshot.js'
|
|
33
|
+
import type { MemorySnapshotState } from './memory-snapshot.js'
|
|
34
|
+
|
|
35
|
+
const DSH_RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt'
|
|
36
|
+
|
|
37
|
+
interface AgentPreset { id: string }
|
|
38
|
+
interface AgentPresetService { resolve(id?: string): Promise<AgentPreset>; mount(ctx: Context, id: string): Promise<unknown> }
|
|
39
|
+
interface SessionTitleService { get(session: Session): unknown; rename(session: Session, title: string): unknown }
|
|
40
|
+
interface WorkspaceRegistryService { archivedSessionIds: readonly string[] }
|
|
41
|
+
interface Composition { presetId?: string; setup?: AgentSetup }
|
|
42
|
+
interface ActiveActor { chatType: ChatType; senderId: string }
|
|
43
|
+
|
|
44
|
+
const MEDIA_TOOL_NAMES = new Set(['qqchat_describe_image', 'qqchat_read_file', 'qqchat_media_info'])
|
|
45
|
+
const packageMetadata = createRequire(import.meta.url)('../../package.json') as { version?: string }
|
|
46
|
+
const PLUGIN_VERSION = packageMetadata.version || 'unknown'
|
|
47
|
+
|
|
48
|
+
export class DshQQBridge {
|
|
49
|
+
private readonly handles = new Map<string, AgentHandle>()
|
|
50
|
+
private readonly locks = new Map<string, Promise<unknown>>()
|
|
51
|
+
private readonly pending = new Map<string, PendingReply>()
|
|
52
|
+
private readonly routes = new Map<string, { provider: string; model: string }>()
|
|
53
|
+
private readonly selections = new Map<string, { ref: ModelSelectionRef; dispose: () => void }>()
|
|
54
|
+
private readonly pendingResets = new Set<string>()
|
|
55
|
+
private readonly activeActors = new Map<string, ActiveActor>()
|
|
56
|
+
private readonly activeAttachments = new Map<string, Set<string>>()
|
|
57
|
+
private readonly activeMediaReadable = new Map<string, boolean>()
|
|
58
|
+
private readonly memorySnapshots = new Map<string, MemorySnapshotState>()
|
|
59
|
+
private readonly disposeEvent: () => void
|
|
60
|
+
private readonly disposeToolGate: () => void
|
|
61
|
+
private readonly disposeBootstrapGate: () => void
|
|
62
|
+
private readonly disposeCommands: () => void
|
|
63
|
+
private readonly disposeImageTool: () => void
|
|
64
|
+
private readonly disposeMediaTools: () => void
|
|
65
|
+
|
|
66
|
+
constructor(
|
|
67
|
+
private readonly ctx: Context,
|
|
68
|
+
private readonly db: QQChatDatabase,
|
|
69
|
+
private readonly memory: MemoryEngine,
|
|
70
|
+
private readonly config: QQChatConfig,
|
|
71
|
+
private readonly logger: LoggerLike = console,
|
|
72
|
+
) {
|
|
73
|
+
this.disposeEvent = ctx.on('session/event', (session, event) => this.onSessionEvent(session, event))
|
|
74
|
+
this.disposeBootstrapGate = ctx.on('agent/pre-step', async ({ messages }, next): Promise<AgentPreStepDecision> => {
|
|
75
|
+
if (messages.some(message => message.source.kind === 'qq-chat-bootstrap')) return { kind: 'reject' }
|
|
76
|
+
return next()
|
|
77
|
+
})
|
|
78
|
+
this.disposeToolGate = ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
|
79
|
+
const agent = exec.agent
|
|
80
|
+
if (!agent) return next()
|
|
81
|
+
const actor = this.activeActors.get(String(agent.id))
|
|
82
|
+
if (!actor || actor.chatType !== 'group') return next()
|
|
83
|
+
const settings = this.db.runtimeSettings(defaultRuntimeSettings(this.config))
|
|
84
|
+
if (settings.groupMembersCanUseTools) return next()
|
|
85
|
+
if (settings.ownerUserId && settings.ownerUserId === actor.senderId) return next()
|
|
86
|
+
if (MEDIA_TOOL_NAMES.has(exec.name) && settings.groupMembersCanReadMedia) return next()
|
|
87
|
+
return {
|
|
88
|
+
kind: 'deny',
|
|
89
|
+
reason: settings.ownerUserId
|
|
90
|
+
? (MEDIA_TOOL_NAMES.has(exec.name) ? '当前 QQ 群已关闭群成员媒体读取权限。' : '当前 QQ 群只允许 Owner 使用工具。')
|
|
91
|
+
: (MEDIA_TOOL_NAMES.has(exec.name) ? '当前 QQ 群已关闭群成员媒体读取权限。' : '当前 QQ 群已关闭群成员工具权限,但尚未设置 Owner stable ID。'),
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
const canReadMedia = (agentId: string, attachmentId: string) => this.activeMediaReadable.get(agentId) === true && this.activeAttachments.get(agentId)?.has(attachmentId) === true
|
|
95
|
+
this.disposeImageTool = registerQQImageTool(ctx, db, canReadMedia)
|
|
96
|
+
this.disposeMediaTools = registerQQMediaTools(ctx, db, canReadMedia)
|
|
97
|
+
this.disposeCommands = this.registerCommands()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async dispose(): Promise<void> {
|
|
101
|
+
this.disposeEvent()
|
|
102
|
+
this.disposeBootstrapGate()
|
|
103
|
+
this.disposeToolGate()
|
|
104
|
+
this.disposeImageTool()
|
|
105
|
+
this.disposeMediaTools()
|
|
106
|
+
this.disposeCommands()
|
|
107
|
+
for (const handle of this.handles.values()) {
|
|
108
|
+
try { await handle.dispose() } catch {}
|
|
109
|
+
}
|
|
110
|
+
this.handles.clear()
|
|
111
|
+
for (const selection of this.selections.values()) selection.dispose()
|
|
112
|
+
this.selections.clear()
|
|
113
|
+
this.pendingResets.clear()
|
|
114
|
+
this.activeActors.clear()
|
|
115
|
+
this.activeAttachments.clear()
|
|
116
|
+
this.activeMediaReadable.clear()
|
|
117
|
+
this.memorySnapshots.clear()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async ensureChatSession(chatType: ChatType, row: GroupRow | MemberRow): Promise<string> {
|
|
121
|
+
const { sessionId } = await this.ensureAgent(chatType, row)
|
|
122
|
+
return sessionId
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async recordTranscript(event: QQChatDisplayEvent, row: GroupRow | MemberRow, createSession = false): Promise<string | undefined> {
|
|
126
|
+
const existing = this.db.getChatSession(event.chatType, Number(row.id))
|
|
127
|
+
if (!existing && !createSession) return undefined
|
|
128
|
+
const { agent, sessionId } = await this.ensureAgent(event.chatType, row)
|
|
129
|
+
event.sessionId = sessionId
|
|
130
|
+
if (event.isOwner) {
|
|
131
|
+
const hasImages = event.attachments?.some(attachment => attachment.kind === 'image' && this.db.attachmentById(attachment.id)?.imageRef) === true
|
|
132
|
+
if (!hasImages || await this.imageRouteAvailable(agent)) this.appendOwnerMessageIfMissing(agent.session, event)
|
|
133
|
+
else this.appendDisplayIfMissing(agent.session, event)
|
|
134
|
+
return sessionId
|
|
135
|
+
}
|
|
136
|
+
this.appendDisplayIfMissing(agent.session, event)
|
|
137
|
+
return sessionId
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async reply(message: QQNormalizedMessage, group: GroupRow | undefined, member: MemberRow, attachments: StoredAttachmentSummary[] = [], onTextDelta?: (delta: string) => void, isOwner = message.chatType === 'c2c'): Promise<string> {
|
|
141
|
+
if (message.chatType === 'group' && !group) throw new Error('群消息缺少群上下文')
|
|
142
|
+
const key = message.chatType === 'group' ? `g:${group!.id}` : `u:${member.id}`
|
|
143
|
+
return this.serial(key, async () => {
|
|
144
|
+
const row = message.chatType === 'group' ? group! : member
|
|
145
|
+
const { agent, sessionId } = await this.ensureAgent(message.chatType, row)
|
|
146
|
+
this.activeActors.set(String(sessionId), { chatType: message.chatType, senderId: message.senderId })
|
|
147
|
+
try {
|
|
148
|
+
const commands = (this.ctx as unknown as { commands?: CommandRuntime }).commands
|
|
149
|
+
const commandText = qqCommandText(message.text, message.mentioned)
|
|
150
|
+
if (commandText !== undefined) {
|
|
151
|
+
if (!commands) return '当前 DSH profile 未加载命令系统,请重启并确认使用了最新插件。'
|
|
152
|
+
const command = await dispatchQQCommand(commands, agent, commandText, { chatType: message.chatType, isOwner })
|
|
153
|
+
if (command.handled) {
|
|
154
|
+
if (this.pendingResets.delete(String(sessionId))) await this.resetSession(message.chatType, row)
|
|
155
|
+
return command.text || ''
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
this.activeActors.delete(String(sessionId))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await agent.whenIdle()
|
|
163
|
+
|
|
164
|
+
if (this.db.runtimeSettings(defaultRuntimeSettings(this.config)).memoryEnabled) {
|
|
165
|
+
const contextText = message.chatType === 'group'
|
|
166
|
+
? this.memory.contextForGroup(group!, member)
|
|
167
|
+
: this.memory.contextForMember(member)
|
|
168
|
+
this.injectMemorySnapshot(agent, sessionId, contextText)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const currentText = this.memory.currentMessageText(message) + mediaPrompt(attachments)
|
|
172
|
+
const currentContent: ContentBlock[] = [{ type: 'text', text: currentText }]
|
|
173
|
+
if (attachments.length && await this.imageRouteAvailable(agent)) {
|
|
174
|
+
for (const attachment of attachments) {
|
|
175
|
+
if (attachment.kind === 'image' && attachment.imageRef) {
|
|
176
|
+
currentContent.push({ type: 'image', attachment: attachment.imageRef })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const current = createUserMessage({
|
|
181
|
+
source: {
|
|
182
|
+
kind: 'qq-chat', botId: String(message.accountId), chatType: message.chatType,
|
|
183
|
+
chatId: message.chatId, senderId: message.senderId,
|
|
184
|
+
...(message.senderName ? { senderName: message.senderName } : {}),
|
|
185
|
+
messageId: message.messageId || '', mentioned: Boolean(message.mentioned),
|
|
186
|
+
form: 'notice',
|
|
187
|
+
summary: `QQ ${message.chatType === 'group' ? '群聊' : '私聊'}消息 · ${message.senderName || shortId(message.senderId)}`,
|
|
188
|
+
},
|
|
189
|
+
content: currentContent,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
this.pending.set(String(sessionId), { text: '', onTextDelta })
|
|
193
|
+
this.activeAttachments.set(String(sessionId), new Set(attachments.map(item => item.id)))
|
|
194
|
+
const settings = this.db.runtimeSettings(defaultRuntimeSettings(this.config))
|
|
195
|
+
this.activeMediaReadable.set(String(sessionId), message.chatType !== 'group'
|
|
196
|
+
|| (settings.ownerUserId !== '' && settings.ownerUserId === message.senderId)
|
|
197
|
+
|| settings.groupMembersCanReadMedia)
|
|
198
|
+
this.activeActors.set(String(sessionId), { chatType: message.chatType, senderId: message.senderId })
|
|
199
|
+
try {
|
|
200
|
+
agent.followup(current)
|
|
201
|
+
await agent.whenIdle()
|
|
202
|
+
} finally {
|
|
203
|
+
this.activeActors.delete(String(sessionId))
|
|
204
|
+
this.activeAttachments.delete(String(sessionId))
|
|
205
|
+
this.activeMediaReadable.delete(String(sessionId))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const pending = this.pending.get(String(sessionId))
|
|
209
|
+
this.pending.delete(String(sessionId))
|
|
210
|
+
const route = this.routes.get(String(sessionId)) || { provider: agent.options.provider, model: agent.options.model }
|
|
211
|
+
if (message.chatType === 'group' && route.provider && route.model) {
|
|
212
|
+
this.memory.setRoute(Number(group!.id), route.provider, route.model, String(sessionId))
|
|
213
|
+
} else if (message.chatType === 'c2c' && route.provider && route.model) {
|
|
214
|
+
this.memory.setMemberRoute(Number(member.id), route.provider, route.model, String(sessionId))
|
|
215
|
+
}
|
|
216
|
+
const text = pending?.text.trim()
|
|
217
|
+
if (!text) throw new Error('DSH Agent 本轮没有产生可发送的文本回复')
|
|
218
|
+
return text
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private onSessionEvent(session: Session, event: SessionEvent): void {
|
|
223
|
+
const id = String(session.id)
|
|
224
|
+
if (event.type === 'assistant/message') {
|
|
225
|
+
const pending = this.pending.get(id)
|
|
226
|
+
if (!pending) return
|
|
227
|
+
const text = extractText(event.data.message.content)
|
|
228
|
+
if (text.trim()) pending.text = text
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (event.type === 'assistant/chunk') {
|
|
232
|
+
const pending = this.pending.get(id)
|
|
233
|
+
const chunk = event.data.chunk
|
|
234
|
+
if (pending?.onTextDelta && chunk.type === 'text-delta' && chunk.text) pending.onTextDelta(chunk.text)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
if (event.type === 'request/header') {
|
|
238
|
+
const { config } = event.data.header
|
|
239
|
+
if (config.provider && config.model) this.routes.set(id, { provider: config.provider, model: config.model })
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
if ((event.type as string) === 'compaction/end') {
|
|
243
|
+
const state = this.memorySnapshots.get(id)
|
|
244
|
+
if (state) state.stale = true
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private injectMemorySnapshot(agent: AgentHandle['agent'], sessionId: string, contextText: string): void {
|
|
249
|
+
const now = Date.now()
|
|
250
|
+
const hash = memorySnapshotHash(contextText)
|
|
251
|
+
const previous = this.memorySnapshots.get(sessionId) || this.restoreMemorySnapshot(agent.session)
|
|
252
|
+
if (shouldRefreshMemorySnapshot(previous, contextText, now, MEMORY_SNAPSHOT_TTL_MS)) {
|
|
253
|
+
agent.inject(createUserMessage({
|
|
254
|
+
source: { kind: 'plugin', plugin: DSH_RUNTIME_CONTEXT_SOURCE, form: 'snapshot', sections: [{ name: 'qq-chat-context', text: contextText }] },
|
|
255
|
+
content: [{ type: 'text', text: contextText }],
|
|
256
|
+
}))
|
|
257
|
+
this.memorySnapshots.set(sessionId, { hash, lastInjectedAt: now, stale: false })
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
if (!previous) return
|
|
261
|
+
// Keep the cache fresh without writing another runtime-context event.
|
|
262
|
+
previous.lastInjectedAt = now
|
|
263
|
+
this.memorySnapshots.set(sessionId, previous)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private restoreMemorySnapshot(session: Session): MemorySnapshotState | undefined {
|
|
267
|
+
return restoreMemorySnapshotState(session.events as unknown as Parameters<typeof restoreMemorySnapshotState>[0], DSH_RUNTIME_CONTEXT_SOURCE)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private async ensureAgent(chatType: ChatType, row: GroupRow | MemberRow): Promise<{ agent: AgentHandle['agent']; sessionId: string }> {
|
|
271
|
+
let sessionId = this.db.getChatSession(chatType, Number(row.id))
|
|
272
|
+
if (sessionId) {
|
|
273
|
+
if (this.isArchived(sessionId)) {
|
|
274
|
+
const old = this.handles.get(sessionId)
|
|
275
|
+
if (old) {
|
|
276
|
+
try { await old.dispose() } catch {}
|
|
277
|
+
this.handles.delete(sessionId)
|
|
278
|
+
}
|
|
279
|
+
this.logger.info?.(`[dsh-qqchat] QQ session ${sessionId} 已归档,将创建新 session`)
|
|
280
|
+
sessionId = null
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (sessionId) {
|
|
284
|
+
const live = this.ctx.agents.get(SessionId(sessionId))
|
|
285
|
+
if (live) {
|
|
286
|
+
this.ensureSelection(live)
|
|
287
|
+
this.ensureTitle(live.session, chatType, row)
|
|
288
|
+
this.rememberRoute(chatType, row, live, sessionId)
|
|
289
|
+
await this.ensureVisible(live)
|
|
290
|
+
return { agent: live, sessionId }
|
|
291
|
+
}
|
|
292
|
+
const resumed = await this.tryResume(sessionId)
|
|
293
|
+
if (resumed) {
|
|
294
|
+
this.ensureSelection(resumed.agent)
|
|
295
|
+
this.ensureTitle(resumed.agent.session, chatType, row)
|
|
296
|
+
this.rememberRoute(chatType, row, resumed.agent, sessionId)
|
|
297
|
+
await this.ensureVisible(resumed.agent)
|
|
298
|
+
return { agent: resumed.agent, sessionId }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
sessionId = `qqchat-${randomUUID()}`
|
|
303
|
+
const composition = await this.composition()
|
|
304
|
+
const handle = await this.ctx.agents.create({
|
|
305
|
+
sessionId: SessionId(sessionId),
|
|
306
|
+
meta: {
|
|
307
|
+
cwd: process.cwd(),
|
|
308
|
+
...(composition.presetId ? { agentPreset: composition.presetId } : {}),
|
|
309
|
+
},
|
|
310
|
+
agentOptions: this.agentOptions(), setup: composition.setup,
|
|
311
|
+
})
|
|
312
|
+
this.handles.set(sessionId, handle)
|
|
313
|
+
this.ensureSelection(handle.agent)
|
|
314
|
+
this.db.setChatSession(chatType, Number(row.id), sessionId)
|
|
315
|
+
this.ensureTitle(handle.agent.session, chatType, row)
|
|
316
|
+
this.rememberRoute(chatType, row, handle.agent, sessionId)
|
|
317
|
+
await this.ensureVisible(handle.agent)
|
|
318
|
+
return { agent: handle.agent, sessionId }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private isArchived(sessionId: string): boolean {
|
|
322
|
+
const registry = (this.ctx as unknown as { workspaceRegistry?: WorkspaceRegistryService }).workspaceRegistry
|
|
323
|
+
return registry?.archivedSessionIds.includes(sessionId) ?? false
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private rememberRoute(chatType: ChatType, row: GroupRow | MemberRow, agent: AgentHandle['agent'], sessionId: string): void {
|
|
327
|
+
const provider = agent.options.provider || this.config.provider
|
|
328
|
+
const model = agent.options.model || this.config.model
|
|
329
|
+
if (!provider || !model) return
|
|
330
|
+
if (chatType === 'group') this.memory.setRoute(Number((row as GroupRow).id), provider, model, sessionId)
|
|
331
|
+
else this.memory.setMemberRoute(Number((row as MemberRow).id), provider, model, sessionId)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async ensureVisible(agent: AgentHandle['agent']): Promise<void> {
|
|
335
|
+
if (agent.session.events.some(event => event.type === 'turn/start')) return
|
|
336
|
+
agent.followup(createUserMessage({ source: { kind: 'qq-chat-bootstrap', plugin: 'dsh-qqchat' }, content: [{ type: 'text', text: 'Activate QQ Chat workspace session.' }] }))
|
|
337
|
+
await agent.whenIdle()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private appendDisplayIfMissing(session: Session, event: QQChatDisplayEvent): void {
|
|
341
|
+
if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId)) return
|
|
342
|
+
session.append('qqchat/message', displayEventPayload(event))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private appendOwnerMessageIfMissing(session: Session, event: QQChatDisplayEvent): void {
|
|
346
|
+
if (session.events.some(item => {
|
|
347
|
+
if (item.type !== 'user/message') return false
|
|
348
|
+
const source = item.data.source as unknown as { messageId?: string }
|
|
349
|
+
return source.messageId === event.messageId
|
|
350
|
+
})) return
|
|
351
|
+
session.append('user/message', createUserMessage({
|
|
352
|
+
source: {
|
|
353
|
+
kind: 'user', channel: 'qq', botId: 'qqchat', chatType: event.chatType,
|
|
354
|
+
chatId: event.chatId, senderId: event.senderId,
|
|
355
|
+
...(event.senderName ? { senderName: event.senderName } : {}),
|
|
356
|
+
messageId: event.messageId, mentioned: event.mentioned,
|
|
357
|
+
},
|
|
358
|
+
content: transcriptContent(event, this.db),
|
|
359
|
+
}), { surfaceOp: 'append' })
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private ensureTitle(session: Session, chatType: ChatType, row: GroupRow | MemberRow): void {
|
|
363
|
+
try {
|
|
364
|
+
const getService = (this.ctx as unknown as { get?: (name: string) => unknown }).get
|
|
365
|
+
const titles = getService?.call(this.ctx, 'sessionTitle') as SessionTitleService | undefined
|
|
366
|
+
if (!titles || titles.get(session)) return
|
|
367
|
+
const fallback = chatType === 'group'
|
|
368
|
+
? `QQ群 · ${(row as GroupRow).name || shortId((row as GroupRow).platform_group_id)}`
|
|
369
|
+
: `QQ私聊 · ${(row as MemberRow).display_name || shortId((row as MemberRow).platform_user_id)}`
|
|
370
|
+
titles.rename(session, fallback)
|
|
371
|
+
} catch (error) {
|
|
372
|
+
this.logger.debug?.(`[dsh-qqchat] session title skipped: ${error instanceof Error ? error.message : String(error)}`)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private async tryResume(sessionId: string): Promise<AgentHandle | undefined> {
|
|
377
|
+
try {
|
|
378
|
+
const composition = await this.composition()
|
|
379
|
+
const handle = await this.ctx.agents.resume({ resumeSessionId: SessionId(sessionId), agentOptions: this.agentOptions(), setup: composition.setup })
|
|
380
|
+
if (!handle.agent.session.header.cwd) {
|
|
381
|
+
await handle.dispose()
|
|
382
|
+
this.logger.warn?.(`[dsh-qqchat] QQ session ${sessionId} 缺少 cwd 元数据,将创建新 session`)
|
|
383
|
+
return undefined
|
|
384
|
+
}
|
|
385
|
+
this.handles.set(sessionId, handle)
|
|
386
|
+
return handle
|
|
387
|
+
} catch (error) {
|
|
388
|
+
this.logger.warn?.(`[dsh-qqchat] 无法恢复 QQ session ${sessionId},将创建新 session: ${error instanceof Error ? error.message : String(error)}`)
|
|
389
|
+
return undefined
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private agentOptions(): AgentOptions {
|
|
394
|
+
const defaults = this.ctx.agentDefaultModel.currentSelection()
|
|
395
|
+
const options: AgentOptions = {
|
|
396
|
+
provider: this.config.provider || defaults.provider,
|
|
397
|
+
model: this.config.model || defaults.model,
|
|
398
|
+
}
|
|
399
|
+
if (this.config.maxTokens) options.maxTokens = this.config.maxTokens
|
|
400
|
+
return options
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private async imageRouteAvailable(agent: AgentHandle['agent']): Promise<boolean> {
|
|
404
|
+
const route = this.selection(agent)?.current || { provider: agent.options.provider, model: agent.options.model }
|
|
405
|
+
if (!route.provider || !route.model) return false
|
|
406
|
+
try {
|
|
407
|
+
const info = await this.ctx.llm.resolveModelInfo(route.provider, route.model)
|
|
408
|
+
return info.inputModalities?.includes('image') === true
|
|
409
|
+
} catch { return false }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private ensureSelection(agent: AgentHandle['agent']): ModelSelectionRef | undefined {
|
|
413
|
+
const id = String(agent.id)
|
|
414
|
+
const existing = this.selections.get(id)
|
|
415
|
+
if (existing) return existing.ref
|
|
416
|
+
const logged = agent.session.requestHeader()?.config
|
|
417
|
+
const route = this.routes.get(id)
|
|
418
|
+
|| (logged?.provider && logged.model ? { provider: logged.provider, model: logged.model } : undefined)
|
|
419
|
+
|| (agent.options.provider && agent.options.model ? { provider: agent.options.provider, model: agent.options.model } : undefined)
|
|
420
|
+
if (!route) return undefined
|
|
421
|
+
const ref: ModelSelectionRef = { current: route, assembled: undefined }
|
|
422
|
+
this.selections.set(id, { ref, dispose: installModelSelection(agent.ctx, ref) })
|
|
423
|
+
return ref
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private selection(agent: AgentHandle['agent']): ModelSelectionRef | undefined {
|
|
427
|
+
return this.ensureSelection(agent)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private requestReset(sessionId: string): void {
|
|
431
|
+
this.pendingResets.add(sessionId)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private async resetSession(chatType: ChatType, row: GroupRow | MemberRow): Promise<void> {
|
|
435
|
+
const sessionId = this.db.getChatSession(chatType, Number(row.id))
|
|
436
|
+
if (!sessionId) return
|
|
437
|
+
const handle = this.handles.get(sessionId)
|
|
438
|
+
if (handle) {
|
|
439
|
+
try { await handle.dispose() } catch {}
|
|
440
|
+
this.handles.delete(sessionId)
|
|
441
|
+
}
|
|
442
|
+
const selection = this.selections.get(sessionId)
|
|
443
|
+
if (selection) {
|
|
444
|
+
selection.dispose()
|
|
445
|
+
this.selections.delete(sessionId)
|
|
446
|
+
}
|
|
447
|
+
this.routes.delete(sessionId)
|
|
448
|
+
this.db.setChatSession(chatType, Number(row.id), '')
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private commandHelp(agent: AgentHandle['agent']): string {
|
|
452
|
+
const commands = this.ctx.commands.list(agent)
|
|
453
|
+
return [
|
|
454
|
+
'🤖 QQChat / DSH 命令',
|
|
455
|
+
'',
|
|
456
|
+
'通用能力',
|
|
457
|
+
...commands.filter(command => ['compact', 'goal', 'plan', 'permission', 'feedback'].includes(command.name))
|
|
458
|
+
.map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
|
|
459
|
+
'',
|
|
460
|
+
'QQChat 会话',
|
|
461
|
+
...commands.filter(command => ['qqnew', 'qqreset', 'qqclear', 'qqmodel', 'qqstop', 'qqstatus'].includes(command.name))
|
|
462
|
+
.map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
|
|
463
|
+
'',
|
|
464
|
+
'QQChat 工具',
|
|
465
|
+
...commands.filter(command => ['qqping', 'qqversion', 'qqhelp', 'qqcommands'].includes(command.name))
|
|
466
|
+
.map(command => `/${command.name} — ${command.description}`),
|
|
467
|
+
].join('\n')
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private registerCommands(): () => void {
|
|
471
|
+
const register = (name: string, description: string, handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>, input?: string): (() => void) =>
|
|
472
|
+
this.ctx.commands.register({ name, description, ...(input ? { input: { hint: input } } : {}), handler })
|
|
473
|
+
const disposers = [
|
|
474
|
+
register('qqhelp', '查看 QQChat 和 DSH 命令', invocation => ({ kind: 'success', text: this.commandHelp(invocation.agent as AgentHandle['agent']) })),
|
|
475
|
+
register('qqcommands', '查看 QQChat 和 DSH 命令', invocation => ({ kind: 'success', text: this.commandHelp(invocation.agent as AgentHandle['agent']) })),
|
|
476
|
+
register('qqnew', '开始新会话(保留旧 Session)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' } }),
|
|
477
|
+
register('qqreset', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' } }),
|
|
478
|
+
register('qqclear', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' } }),
|
|
479
|
+
register('qqmodel', '查看或切换当前会话模型', invocation => this.modelCommand(invocation), '[provider/]model'),
|
|
480
|
+
register('qqstop', '中止当前生成', invocation => {
|
|
481
|
+
if (invocation.agent.status !== 'running') return { kind: 'success', text: '当前没有进行中的生成。' }
|
|
482
|
+
invocation.agent.cancel({ kind: 'user' })
|
|
483
|
+
return { kind: 'success', text: '已中止当前生成。' }
|
|
484
|
+
}),
|
|
485
|
+
register('qqping', '测试 QQChat 连通性', () => ({ kind: 'success', text: 'pong 🏓' })),
|
|
486
|
+
register('qqversion', '查看 QQChat 版本', () => ({ kind: 'success', text: `dsh-qqchat v${PLUGIN_VERSION}` })),
|
|
487
|
+
register('qqstatus', '查看当前会话状态', invocation => this.statusCommand(invocation.agent as AgentHandle['agent'])),
|
|
488
|
+
]
|
|
489
|
+
return () => { for (const dispose of disposers.reverse()) dispose() }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private async modelCommand(invocation: CommandInvocation): Promise<CommandResult> {
|
|
493
|
+
const agent = invocation.agent as AgentHandle['agent']
|
|
494
|
+
const ref = this.selection(agent)
|
|
495
|
+
if (!ref) return { kind: 'error', text: '当前 Session 尚未确定模型路由。' }
|
|
496
|
+
const args = invocation.rawInput.trim()
|
|
497
|
+
if (!args) {
|
|
498
|
+
const providers = this.ctx.llm.listProviders()
|
|
499
|
+
const models = await this.ctx.llm.listModels(ref.current?.provider || '')
|
|
500
|
+
return {
|
|
501
|
+
kind: 'success',
|
|
502
|
+
text: [
|
|
503
|
+
`当前模型:${ref.current?.provider}/${ref.current?.model}`,
|
|
504
|
+
providers.length ? `可用提供方:${providers.map(provider => provider.id).join(', ')}` : '',
|
|
505
|
+
models.length ? `当前提供方模型:${models.slice(0, 20).map(model => `${model.id}${model.name !== model.id ? `(${model.name})` : ''}`).join(', ')}` : '',
|
|
506
|
+
'切换用法:/qqmodel provider/model 或 /qqmodel model',
|
|
507
|
+
].filter(Boolean).join('\n'),
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const separator = args.indexOf('/')
|
|
511
|
+
const provider = separator > 0 ? args.slice(0, separator).trim() : ref.current?.provider || ''
|
|
512
|
+
const model = separator > 0 ? args.slice(separator + 1).trim() : args
|
|
513
|
+
if (!provider || !model) return { kind: 'error', text: '用法:/qqmodel provider/model' }
|
|
514
|
+
if (!this.ctx.llm.listProviders().some(item => item.id === provider)) {
|
|
515
|
+
return { kind: 'error', text: `未找到提供方:${provider}` }
|
|
516
|
+
}
|
|
517
|
+
ref.current = { provider, model }
|
|
518
|
+
this.routes.set(String(agent.id), { provider, model })
|
|
519
|
+
return { kind: 'success', text: `模型已切换:${provider}/${model}\n对话上下文保留,下一轮生效。` }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private statusCommand(agent: AgentHandle['agent']): CommandResult {
|
|
523
|
+
const current = this.selection(agent)?.current
|
|
524
|
+
const messages = agent.session.events.filter(event => event.type === 'user/message' || event.type === 'assistant/message').length
|
|
525
|
+
const last = agent.session.events.at(-1)
|
|
526
|
+
return {
|
|
527
|
+
kind: 'success',
|
|
528
|
+
text: ['📊 Session 状态', `Session:${String(agent.id)}`, `状态:${agent.status === 'running' ? '生成中' : '空闲'}`, `模型:${current ? `${current.provider}/${current.model}` : '未知'}`, `消息数:${messages}`, `最后事件:${last?.type || '无'}`].join('\n'),
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private async composition(): Promise<Composition> {
|
|
533
|
+
const getService = (this.ctx as unknown as { get?: (name: string) => unknown }).get
|
|
534
|
+
const presets = getService?.call(this.ctx, 'agentPresets') as AgentPresetService | undefined
|
|
535
|
+
if (!presets) return {}
|
|
536
|
+
const preset = await presets.resolve(this.config.agentPreset)
|
|
537
|
+
return { presetId: preset.id, setup: async agentCtx => { await presets.mount(agentCtx, preset.id) } }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private serial<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
541
|
+
const previous = this.locks.get(key) || Promise.resolve()
|
|
542
|
+
const current = previous.catch(() => undefined).then(task)
|
|
543
|
+
this.locks.set(key, current)
|
|
544
|
+
return current.finally(() => { if (this.locks.get(key) === current) this.locks.delete(key) })
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function mediaPrompt(attachments: StoredAttachmentSummary[]): string {
|
|
549
|
+
if (!attachments.length) return ''
|
|
550
|
+
const lines = attachments.map(item => `- ${item.kind}: ${item.filename} (attachment_id=${item.id}${item.quoted ? ', 引用消息附件' : ''})`)
|
|
551
|
+
return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n图片可使用 qqchat_describe_image 查看。`
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function transcriptContent(event: QQChatDisplayEvent, db: QQChatDatabase): ContentBlock[] {
|
|
555
|
+
const attachments = event.attachments || []
|
|
556
|
+
const renderableImages = attachments.filter(attachment => attachment.kind === 'image' && db.attachmentById(attachment.id)?.imageRef)
|
|
557
|
+
const imagePlaceholderLines = new Set(renderableImages.map(attachment => `[图片] ${attachment.filename}`))
|
|
558
|
+
const quote = event.quote ? formatDisplayQuote(event) : ''
|
|
559
|
+
const body = event.content.split('\n').filter(line => !imagePlaceholderLines.has(line)).join('\n').trim()
|
|
560
|
+
const text = [quote, body].filter(Boolean).join('\n\n')
|
|
561
|
+
const content: ContentBlock[] = [{ type: 'text', text }]
|
|
562
|
+
for (const attachment of attachments) {
|
|
563
|
+
const imageRef = attachment.kind === 'image' ? db.attachmentById(attachment.id)?.imageRef : undefined
|
|
564
|
+
if (imageRef) {
|
|
565
|
+
content.push({ type: 'image', attachment: imageRef })
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return content
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function formatDisplayQuote(event: QQChatDisplayEvent): string {
|
|
572
|
+
const quote = event.quote
|
|
573
|
+
if (!quote) return ''
|
|
574
|
+
return [
|
|
575
|
+
'[引用消息]',
|
|
576
|
+
quote.senderId ? `发送人ID=${quote.senderId}` : '',
|
|
577
|
+
quote.senderName ? `显示名=${quote.senderName}` : '',
|
|
578
|
+
quote.messageId ? `消息ID=${quote.messageId}` : '',
|
|
579
|
+
`正文=${quote.text || '(空消息)'}`,
|
|
580
|
+
quote.attachments.length ? `附件=${quote.attachments.map(attachment => `${attachment.kind || 'file'}:${attachment.filename}${attachment.attachmentId ? ` (attachment_id=${attachment.attachmentId})` : ''}`).join('、')}` : '',
|
|
581
|
+
].filter(Boolean).join('\n')
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function displayEventPayload(event: QQChatDisplayEvent): QQChatDisplayEvent {
|
|
585
|
+
const attachments = (event.attachments || []).map(attachment => ({
|
|
586
|
+
id: String(attachment.id), kind: attachment.kind, filename: String(attachment.filename),
|
|
587
|
+
...(attachment.contentType ? { contentType: String(attachment.contentType) } : {}),
|
|
588
|
+
sizeBytes: Number(attachment.sizeBytes || 0), quoted: Boolean(attachment.quoted),
|
|
589
|
+
}))
|
|
590
|
+
const quote = event.quote ? {
|
|
591
|
+
...(event.quote.messageId ? { messageId: String(event.quote.messageId) } : {}),
|
|
592
|
+
...(event.quote.senderId ? { senderId: String(event.quote.senderId) } : {}),
|
|
593
|
+
...(event.quote.senderName ? { senderName: String(event.quote.senderName) } : {}),
|
|
594
|
+
text: String(event.quote.text || ''),
|
|
595
|
+
attachments: event.quote.attachments.map(attachment => ({
|
|
596
|
+
...(attachment.attachmentId ? { id: String(attachment.attachmentId) } : {}),
|
|
597
|
+
filename: String(attachment.filename),
|
|
598
|
+
...(attachment.contentType ? { contentType: String(attachment.contentType) } : {}),
|
|
599
|
+
...(attachment.size !== undefined ? { size: Number(attachment.size) } : {}),
|
|
600
|
+
...(attachment.width !== undefined ? { width: Number(attachment.width) } : {}),
|
|
601
|
+
...(attachment.height !== undefined ? { height: Number(attachment.height) } : {}),
|
|
602
|
+
...(attachment.durationMs !== undefined ? { durationMs: Number(attachment.durationMs) } : {}),
|
|
603
|
+
...(attachment.platformFileId ? { platformFileId: String(attachment.platformFileId) } : {}),
|
|
604
|
+
...(attachment.size !== undefined ? { sizeBytes: Number(attachment.size) } : {}),
|
|
605
|
+
...(attachment.quoted !== undefined ? { quoted: Boolean(attachment.quoted) } : {}),
|
|
606
|
+
...(attachment.kind ? { kind: attachment.kind } : {}),
|
|
607
|
+
})),
|
|
608
|
+
} : undefined
|
|
609
|
+
return {
|
|
610
|
+
messageId: String(event.messageId), chatType: event.chatType, chatId: String(event.chatId),
|
|
611
|
+
direction: event.direction, senderId: String(event.senderId), senderName: String(event.senderName),
|
|
612
|
+
...(event.isOwner !== undefined ? { isOwner: Boolean(event.isOwner) } : {}),
|
|
613
|
+
content: String(event.content), quotedText: String(event.quotedText), mentioned: Boolean(event.mentioned),
|
|
614
|
+
createdAt: Number(event.createdAt), ...(event.sessionId ? { sessionId: String(event.sessionId) } : {}),
|
|
615
|
+
attachments, ...(quote ? { quote } : {}),
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function extractText(content: readonly unknown[]): string {
|
|
620
|
+
return content.filter((block): block is { type: 'text'; text: string } => isRecord(block) && block.type === 'text' && typeof block.text === 'string').map(block => block.text).join('\n')
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function shortId(value: string): string { return value.length > 10 ? `…${value.slice(-10)}` : value }
|
|
624
|
+
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export const MEMORY_SNAPSHOT_TTL_MS = 30 * 60 * 1000
|
|
4
|
+
|
|
5
|
+
export interface MemorySnapshotState {
|
|
6
|
+
hash: string
|
|
7
|
+
lastInjectedAt: number
|
|
8
|
+
stale: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MemorySnapshotEvent {
|
|
12
|
+
type?: string
|
|
13
|
+
time?: number
|
|
14
|
+
data?: {
|
|
15
|
+
source?: { kind?: string; plugin?: string }
|
|
16
|
+
content?: readonly { type?: string; text?: string }[]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function memorySnapshotHash(text: string): string {
|
|
21
|
+
return createHash('sha256').update(text).digest('hex')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function shouldRefreshMemorySnapshot(previous: MemorySnapshotState | undefined, currentText: string, now: number, ttlMs = MEMORY_SNAPSHOT_TTL_MS): boolean {
|
|
25
|
+
if (!previous || previous.stale) return true
|
|
26
|
+
if (previous.hash !== memorySnapshotHash(currentText)) return true
|
|
27
|
+
return now - previous.lastInjectedAt >= ttlMs
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function restoreMemorySnapshotState(events: readonly MemorySnapshotEvent[], sourcePlugin: string): MemorySnapshotState | undefined {
|
|
31
|
+
const lastCompactionAt = events.reduce((latest, event) => event.type === 'compaction/end' ? Math.max(latest, Number(event.time) || 0) : latest, 0)
|
|
32
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
33
|
+
const event = events[index]
|
|
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
|
|
37
|
+
if (block?.type !== 'text' || typeof block.text !== 'string') continue
|
|
38
|
+
return { hash: memorySnapshotHash(block.text), lastInjectedAt: Number(event.time) || 0, stale: lastCompactionAt > (Number(event.time) || 0) }
|
|
39
|
+
}
|
|
40
|
+
return undefined
|
|
41
|
+
}
|