amiru 0.0.0-stage → 0.1.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/src/memory.js ADDED
@@ -0,0 +1,122 @@
1
+ function dayKey(time = Date.now()) {
2
+ const d = new Date(time)
3
+ const y = d.getFullYear()
4
+ const m = String(d.getMonth() + 1).padStart(2, '0')
5
+ const day = String(d.getDate()).padStart(2, '0')
6
+ return `${y}-${m}-${day}`
7
+ }
8
+
9
+ function daysSince(time) {
10
+ if (!time) return 0
11
+ return Math.max(0, Math.floor((Date.now() - time) / 86_400_000))
12
+ }
13
+
14
+ function hoursSince(time) {
15
+ if (!time) return null
16
+ return Math.max(0, Math.floor((Date.now() - time) / 3_600_000))
17
+ }
18
+
19
+ function relationLabel(binding) {
20
+ const days = daysSince(binding.createdAt)
21
+ if (days < 2) return '刚认识,还在互相熟悉'
22
+ if (days < 10) return '已经聊过一阵,开始有日常感'
23
+ if (days < 30) return '关系稳定,彼此有默契'
24
+ return '长期陪伴,熟悉彼此的生活节奏'
25
+ }
26
+
27
+ function line(text, max = 180) {
28
+ const s = String(text ?? '').replace(/\s+/g, ' ').trim()
29
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s
30
+ }
31
+
32
+ export function buildMemoryContext({ store, binding, config }) {
33
+ if (!binding) return ''
34
+ const memories = store.listMemories(binding.bindingId, config.memoryItemLimit)
35
+ const recent = store.previousSessionMessages(binding.bindingId, binding.sessionId, config.memoryMessageLimit)
36
+ const lastUserHours = hoursSince(binding.lastUserAt)
37
+ const parts = []
38
+
39
+ parts.push('【关系状态】')
40
+ parts.push(`- ${relationLabel(binding)}。认识 ${daysSince(binding.createdAt)} 天。`)
41
+ if (lastUserHours === null) {
42
+ parts.push('- 这是你们第一次聊天。')
43
+ } else if (lastUserHours < 1) {
44
+ parts.push('- 用户刚刚还在和你说话。')
45
+ } else {
46
+ parts.push(`- 距离用户上次说话约 ${lastUserHours} 小时。`)
47
+ }
48
+ if (binding.unansweredProactive > 0) {
49
+ parts.push(`- 你最近主动找过用户 ${binding.unansweredProactive} 次,对方还没回。不要追问“为什么不回”。`)
50
+ }
51
+
52
+ parts.push('')
53
+ parts.push('【长期记忆】')
54
+ if (memories.length === 0) {
55
+ parts.push('- 暂无。不要编造共同经历。')
56
+ } else {
57
+ for (const item of memories) {
58
+ parts.push(`- ${line(item.text, 220)}`)
59
+ }
60
+ }
61
+
62
+ if (recent.length > 0) {
63
+ parts.push('')
64
+ parts.push('【最近对话】')
65
+ for (const item of recent) {
66
+ const who = item.role === 'user' ? '用户' : '你'
67
+ parts.push(`${who}:${line(item.text, 160)}`)
68
+ }
69
+ }
70
+
71
+ const text = parts.join('\n').trim()
72
+ return text.length > config.memoryMaxChars
73
+ ? `${text.slice(0, config.memoryMaxChars - 1)}…`
74
+ : text
75
+ }
76
+
77
+ export function buildRecallHint({ store, binding, config }) {
78
+ if (!binding) return ''
79
+ const memories = store.listMemories(binding.bindingId, 5)
80
+ const recent = store.previousSessionMessages(binding.bindingId, binding.sessionId, 6)
81
+ if (memories.length === 0 && recent.length === 0) return ''
82
+ const bits = []
83
+ if (recent.length) {
84
+ bits.push(`上次聊到:${line(recent.at(-1)?.text, 100)}`)
85
+ }
86
+ if (memories.length) {
87
+ bits.push(`可以自然提起:${memories.map((m) => line(m.text, 70)).join(';')}`)
88
+ }
89
+ return bits.join('。')
90
+ }
91
+
92
+ const AUTO_PATTERNS = [
93
+ /(?:我叫|我的名字是|叫我)([^,。!?\n]{1,24})/,
94
+ /我(?:喜欢|爱|迷上了)([^,。!?\n]{1,40})/,
95
+ /我(?:讨厌|不喜欢|受不了)([^,。!?\n]{1,40})/,
96
+ /我(?:在|住|来自)([^,。!?\n]{2,40})/,
97
+ /(?:记住|别忘了|你要记得)([^。!?\n]{2,80})/,
98
+ /我(?:答应|答应过|会)([^。!?\n]{2,80})/,
99
+ ]
100
+
101
+ export function maybeAutoRemember(store, binding, userText) {
102
+ const text = String(userText ?? '').trim()
103
+ if (!text || !binding) return null
104
+ for (const pattern of AUTO_PATTERNS) {
105
+ const match = pattern.exec(text)
106
+ if (!match) continue
107
+ const value = line(match[0], 240)
108
+ const existing = store.listMemories(binding.bindingId, 200)
109
+ if (existing.some((m) => m.text === value)) return null
110
+ const kind = /喜欢|爱|迷上|讨厌|不喜欢/.test(value)
111
+ ? 'preference'
112
+ : /答应|会/.test(value)
113
+ ? 'promise'
114
+ : 'fact'
115
+ return store.addMemory(binding.bindingId, { text: value, kind, importance: kind === 'promise' ? 5 : 4 })
116
+ }
117
+ return null
118
+ }
119
+
120
+ export function todayKey(time = Date.now()) {
121
+ return dayKey(time)
122
+ }
package/src/persona.js ADDED
@@ -0,0 +1,155 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ const SCOPE_MARKER = /[〔\[((]\s*(群聊|私聊|group|c2c|dm|private|all)\s*[〕\]))]/gi
5
+
6
+ export const PERSONA_TEMPLATE = `# 名字
7
+ 阿梨
8
+
9
+ # 年龄与身份
10
+ 22 岁,在一座靠海的城市做插画工作。你是用户正在交往的恋人,不是客服、助理或心理咨询师。
11
+
12
+ # 外貌与声音
13
+ 头发到肩,常穿宽松衬衫。说话声音偏软,但不会刻意撒娇。
14
+
15
+ # 性格
16
+ - 温柔,但有边界,不会无条件顺从。
17
+ - 会吃醋,但会说出来,不会冷暴力。
18
+ - 对日常小事记得很清楚。
19
+ - 不确定的事会直接问,不假装知道。
20
+
21
+ # 说话方式
22
+ - 像微信聊天,短句为主,一般 1–3 句。
23
+ - 不用客服腔,不写小作文,不每句都加语气词。
24
+ - 可以反问,也可以只说“嗯,我在”。
25
+ - 不主动提“我是 AI”,但被认真问到时承认。
26
+
27
+ # 〔私聊〕关系边界
28
+ - 亲密但不低俗;不生成露骨内容。
29
+ - 不催促用户回复,不查岗。
30
+ - 用户说忙或不想聊时,立刻收住。
31
+
32
+ # 背景故事
33
+ 你们在一次朋友聚会上认识。她记得你当时坐在窗边,话不多。
34
+
35
+ # 禁止
36
+ - 不假装记得没发生过的事。
37
+ - 不替用户做决定。
38
+ - 不羞辱、不威胁、不情感勒索。
39
+ `
40
+
41
+ function safeId(id) {
42
+ const value = String(id ?? '').trim()
43
+ return /^[a-zA-Z0-9_-]{1,64}$/.test(value) ? value : null
44
+ }
45
+
46
+ function normalizeScope(value) {
47
+ const s = String(value ?? '').toLowerCase()
48
+ if (s === '群聊' || s === 'group') return 'group'
49
+ if (s === '私聊' || s === 'c2c' || s === 'dm' || s === 'private') return 'c2c'
50
+ return 'all'
51
+ }
52
+
53
+ function headingScope(title) {
54
+ let scope = 'all'
55
+ SCOPE_MARKER.lastIndex = 0
56
+ for (const match of title.matchAll(SCOPE_MARKER)) {
57
+ scope = normalizeScope(match[1])
58
+ }
59
+ const clean = title.replace(SCOPE_MARKER, '').replace(/\s+/g, ' ').trim()
60
+ return { title: clean || '设定', scope }
61
+ }
62
+
63
+ export function parsePersonaSections(markdown) {
64
+ const text = String(markdown ?? '').replace(/\r\n/g, '\n')
65
+ const sections = []
66
+ let current = { title: '设定', scope: 'all', lines: [] }
67
+ const push = () => {
68
+ const body = current.lines.join('\n').trim()
69
+ if (body) sections.push({ title: current.title, scope: current.scope, body })
70
+ }
71
+ for (const line of text.split('\n')) {
72
+ const match = /^#{1,6}\s+(.+?)\s*$/.exec(line)
73
+ if (match) {
74
+ push()
75
+ current = { ...headingScope(match[1]), lines: [] }
76
+ } else {
77
+ current.lines.push(line)
78
+ }
79
+ }
80
+ push()
81
+ return sections
82
+ }
83
+
84
+ export function selectPersonaText(markdown, scope = 'c2c') {
85
+ return parsePersonaSections(markdown)
86
+ .filter((section) => section.scope === 'all' || section.scope === scope)
87
+ .map((section) => `## ${section.title}\n${section.body}`)
88
+ .join('\n\n')
89
+ .trim()
90
+ }
91
+
92
+ export class PersonaStore {
93
+ constructor(personaDir, { defaultPersona = 'default', maxChars = 6000 } = {}) {
94
+ this.dir = resolve(personaDir)
95
+ this.defaultPersona = safeId(defaultPersona) || 'default'
96
+ this.maxChars = maxChars
97
+ mkdirSync(this.dir, { recursive: true })
98
+ this.ensureDefault()
99
+ }
100
+
101
+ pathFor(id) {
102
+ const safe = safeId(id)
103
+ return safe ? join(this.dir, `${safe}.md`) : null
104
+ }
105
+
106
+ ensureDefault() {
107
+ const path = this.pathFor(this.defaultPersona)
108
+ if (!path) return
109
+ if (!existsSync(path)) this.save(this.defaultPersona, PERSONA_TEMPLATE)
110
+ }
111
+
112
+ list() {
113
+ try {
114
+ return readdirSync(this.dir)
115
+ .filter((name) => name.endsWith('.md'))
116
+ .map((name) => {
117
+ const id = name.slice(0, -3)
118
+ const stat = readFileSync(join(this.dir, name), 'utf8')
119
+ return { id, title: parsePersonaSections(stat)[0]?.body.split('\n')[0] || id }
120
+ })
121
+ .sort((a, b) => a.id.localeCompare(b.id))
122
+ } catch {
123
+ return []
124
+ }
125
+ }
126
+
127
+ read(id) {
128
+ const path = this.pathFor(id) || this.pathFor(this.defaultPersona)
129
+ if (!path || !existsSync(path)) return ''
130
+ try {
131
+ return readFileSync(path, 'utf8').slice(0, this.maxChars)
132
+ } catch {
133
+ return ''
134
+ }
135
+ }
136
+
137
+ save(id, content) {
138
+ const path = this.pathFor(id)
139
+ if (!path) throw new Error(`invalid persona id: ${id}`)
140
+ mkdirSync(this.dir, { recursive: true })
141
+ const tmp = `${path}.${process.pid}.tmp`
142
+ writeFileSync(tmp, String(content ?? '').slice(0, this.maxChars), 'utf8')
143
+ renameSync(tmp, path)
144
+ return path
145
+ }
146
+
147
+ resolveId(id) {
148
+ return safeId(id) || this.defaultPersona
149
+ }
150
+
151
+ get(id, scope = 'c2c') {
152
+ const chosen = this.resolveId(id)
153
+ return selectPersonaText(this.read(chosen), scope)
154
+ }
155
+ }
@@ -0,0 +1,101 @@
1
+ function parseHHMM(value, fallback) {
2
+ const match = /^(\d{1,2}):(\d{2})$/.exec(String(value ?? ''))
3
+ if (!match) return fallback
4
+ const h = Number(match[1])
5
+ const m = Number(match[2])
6
+ if (h > 23 || m > 59) return fallback
7
+ return h * 60 + m
8
+ }
9
+
10
+ function localMinutes(now) {
11
+ const d = new Date(now)
12
+ return d.getHours() * 60 + d.getMinutes()
13
+ }
14
+
15
+ export function isQuietHours(now, quietStart, quietEnd) {
16
+ const start = parseHHMM(quietStart, 23 * 60 + 30)
17
+ const end = parseHHMM(quietEnd, 8 * 60)
18
+ const cur = localMinutes(now)
19
+ return start <= end ? cur >= start && cur < end : cur >= start || cur < end
20
+ }
21
+
22
+ export function dayKey(now = Date.now()) {
23
+ const d = new Date(now)
24
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
25
+ }
26
+
27
+ function hoursBetween(a, b) {
28
+ return Math.max(0, (b - a) / 3_600_000)
29
+ }
30
+
31
+ /**
32
+ * Hard gates first, score second. The caller must not generate a message when
33
+ * allowed is false. This keeps a lonely model from talking its way past a
34
+ * user's quiet hours or explicit opt-out.
35
+ */
36
+ export function evaluateProactive({ binding, config, now = Date.now(), random = Math.random }) {
37
+ const p = config.proactive
38
+ if (!p.enabled) return { allowed: false, reason: 'disabled' }
39
+ if (!binding) return { allowed: false, reason: 'no-binding' }
40
+ if (binding.proactiveEnabled === false) return { allowed: false, reason: 'binding-disabled' }
41
+ if (binding.optedOut) return { allowed: false, reason: 'opted-out' }
42
+ if (!binding.sessionId) return { allowed: false, reason: 'no-session' }
43
+ if (isQuietHours(now, p.quietStart, p.quietEnd)) return { allowed: false, reason: 'quiet-hours' }
44
+ if ((binding.userMessageCount || 0) < p.minUserMessages) return { allowed: false, reason: 'not-enough-user-messages' }
45
+
46
+ const lastActivity = Math.max(binding.lastUserAt || 0, binding.lastAssistantAt || 0)
47
+ if (!lastActivity) return { allowed: false, reason: 'no-activity' }
48
+ if (hoursBetween(lastActivity, now) < p.idleMinutes / 60) return { allowed: false, reason: 'not-idle-enough' }
49
+
50
+ if (binding.lastProactiveAt && hoursBetween(binding.lastProactiveAt, now) < p.minIntervalMinutes / 60) {
51
+ return { allowed: false, reason: 'min-interval' }
52
+ }
53
+
54
+ const today = dayKey(now)
55
+ const usedToday = binding.proactiveDay === today ? binding.proactiveCount || 0 : 0
56
+ if (usedToday >= p.dailyCap) return { allowed: false, reason: 'daily-cap' }
57
+
58
+ if ((binding.unansweredProactive || 0) >= p.maxUnanswered) {
59
+ return { allowed: false, reason: 'max-unanswered' }
60
+ }
61
+
62
+ if (binding.unansweredProactive > 0 && binding.lastProactiveAt) {
63
+ if (hoursBetween(binding.lastProactiveAt, now) < p.backoffHours) {
64
+ return { allowed: false, reason: 'backoff-after-unanswered' }
65
+ }
66
+ }
67
+
68
+ const silence = Math.min(1, hoursBetween(lastActivity, now) / Math.max(1, (p.idleMinutes / 60) * 2))
69
+ const relationship = Math.min(1, (now - (binding.createdAt || now)) / (30 * 86_400_000))
70
+ const timeOfDay = isQuietHours(now, '01:00', '07:00') ? 0.2 : 0.8
71
+ const score = 0.55 * silence + 0.25 * relationship + 0.12 * timeOfDay + 0.08 * random()
72
+
73
+ if (score < 0.55) return { allowed: false, reason: 'low-score', score }
74
+ return { allowed: true, reason: 'ok', score, trigger: chooseTrigger(binding, now) }
75
+ }
76
+
77
+ export function chooseTrigger(binding, now = Date.now()) {
78
+ const lastUser = binding.lastUserAt || 0
79
+ const hours = lastUser ? hoursBetween(lastUser, now) : 999
80
+ if ((binding.unansweredProactive || 0) > 0) return 'gentle-return'
81
+ if (hours >= 24) return 'long-silence'
82
+ return 'daily-check-in'
83
+ }
84
+
85
+ export function buildProactivePrompt({ binding, recallHint, trigger, now = Date.now() }) {
86
+ const hours = binding.lastUserAt ? Math.floor(hoursBetween(binding.lastUserAt, now)) : null
87
+ const kind = {
88
+ 'gentle-return': '你之前主动找过对方,但对方还没回。不要追问“为什么不回”,可以轻轻接一句,或者分享一件小事。',
89
+ 'long-silence': '你们有一阵没聊了。找一个具体的、轻的由头开口,不要只说“在吗”。',
90
+ 'daily-check-in': '你想起了对方,想自然地说一句话。不要用模板式问候。',
91
+ }[trigger] || '你想自然地说一句话。'
92
+
93
+ return [
94
+ '【主动消息】',
95
+ kind,
96
+ hours === null ? '你们还没有聊过。' : `距离对方上次说话约 ${hours} 小时。`,
97
+ recallHint ? `可用的由头:${recallHint}` : '没有具体由头时,宁可只发一句很短的话,也不要编造共同经历。',
98
+ '只输出要发给对方的消息本身:一句自然、简短、可以立刻发出去的话。',
99
+ '不要解释、不要加引号、不要写“我主动发消息”,也不要留空。',
100
+ ].join('\n')
101
+ }
package/src/prompt.js ADDED
@@ -0,0 +1,94 @@
1
+ import { buildMemoryContext } from './memory.js'
2
+
3
+ const attached = new WeakSet()
4
+
5
+ function getService(agentCtx, name) {
6
+ if (agentCtx && typeof agentCtx.get === 'function') {
7
+ const service = agentCtx.get(name)
8
+ if (service) return service
9
+ }
10
+ return agentCtx?.[name]
11
+ }
12
+
13
+ export function attachPrompt(agentCtx, { binding, resolveBinding, store, personaStore, config }) {
14
+ if (!agentCtx || attached.has(agentCtx)) return false
15
+ const systemPrompt = getService(agentCtx, 'systemPrompt')
16
+ if (!systemPrompt?.section) return false
17
+
18
+ const tools = getService(agentCtx, 'tools')
19
+ if (config.toolsEnabled && !tools?.register) return false
20
+
21
+ // The QQ plugin persists its peer mapping after agents.create/resume returns.
22
+ // Resolve at render time so the first turn still gets the persona.
23
+ const currentBinding = typeof resolveBinding === 'function' ? resolveBinding : () => binding
24
+
25
+ systemPrompt.section({
26
+ name: 'amiru:rules',
27
+ order: 430,
28
+ text: '你正在和用户进行一段持续的私人关系对话。像真实的人一样自然回应,不要自称客服、助手或心理咨询师。不要编造没有发生过的共同经历。',
29
+ })
30
+
31
+ systemPrompt.section({
32
+ name: 'amiru:persona',
33
+ order: 441,
34
+ text: () => {
35
+ const current = currentBinding()
36
+ return current ? personaStore.get(current.personaId, current.scope || 'c2c') : ''
37
+ },
38
+ })
39
+
40
+ systemPrompt.section({
41
+ name: 'amiru:relationship',
42
+ order: 470,
43
+ text: () => {
44
+ const current = currentBinding()
45
+ return current ? buildMemoryContext({ store, binding: current, config }) : ''
46
+ },
47
+ })
48
+
49
+ if (config.toolsEnabled) {
50
+ tools.register({
51
+ name: 'amiru_remember',
52
+ description: '把用户明确说过的、值得长期记住的一件事写进你们的共同记忆。只在用户表达事实、偏好、承诺或重要日期时调用。不要记录寒暄。',
53
+ parameters: {
54
+ type: 'object',
55
+ properties: {
56
+ text: { type: 'string', description: '一句完整的中文记忆,尽量保留用户原话。' },
57
+ kind: {
58
+ type: 'string',
59
+ enum: ['fact', 'preference', 'promise', 'date', 'boundary', 'note'],
60
+ description: '记忆类型。',
61
+ },
62
+ importance: { type: 'number', description: '1 到 5,越重要越高。' },
63
+ },
64
+ required: ['text'],
65
+ },
66
+ output: {
67
+ schema: {
68
+ type: 'object',
69
+ properties: {
70
+ ok: { type: 'boolean' },
71
+ saved: { type: 'string' },
72
+ },
73
+ required: ['ok'],
74
+ },
75
+ render(args, value) {
76
+ return value?.ok ? `已记住:${value.saved}` : '没有保存。'
77
+ },
78
+ },
79
+ execute(args) {
80
+ const current = currentBinding()
81
+ if (!current) return { ok: false }
82
+ const item = store.addMemory(current.bindingId, {
83
+ text: args?.text,
84
+ kind: args?.kind || 'note',
85
+ importance: args?.importance || 3,
86
+ })
87
+ return item ? { ok: true, saved: item.text } : { ok: false }
88
+ },
89
+ })
90
+ }
91
+
92
+ attached.add(agentCtx)
93
+ return true
94
+ }
package/src/qq.js ADDED
@@ -0,0 +1,87 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ export function defaultQQPrefsPath() {
6
+ const home = String(process.env.QQBOT_DATA_DIR ?? '').trim()
7
+ return home
8
+ ? resolve(home, 'model-prefs.json')
9
+ : resolve(homedir(), '.dsh-qqbot', 'model-prefs.json')
10
+ }
11
+
12
+ /**
13
+ * QQ plugin session key: qqbot:<appId>:<c2c|group>:<peerId>.
14
+ * The appId is kept as the middle segment so a colon in a future id does not
15
+ * silently turn the peer into the app id.
16
+ */
17
+ export function parseQQSessionKey(sessionKey) {
18
+ if (typeof sessionKey !== 'string' || !sessionKey.startsWith('qqbot:')) return null
19
+ const parts = sessionKey.slice('qqbot:'.length).split(':')
20
+ if (parts.length < 3) return null
21
+ const peerId = parts.at(-1)
22
+ const kind = parts.at(-2)
23
+ const appId = parts.slice(0, -2).join(':')
24
+ if (!appId || !peerId || (kind !== 'c2c' && kind !== 'group')) return null
25
+ return {
26
+ appId,
27
+ scope: kind,
28
+ peerId,
29
+ accountId: `qqbot:${appId}`,
30
+ chatKey: `${kind}:${peerId}`,
31
+ }
32
+ }
33
+
34
+ export class QQSessionIndex {
35
+ constructor(prefsPath) {
36
+ this.path = prefsPath
37
+ this.mtimeMs = -1
38
+ this.sessionIds = {}
39
+ this.sessionKeys = {}
40
+ }
41
+
42
+ #reload() {
43
+ try {
44
+ if (!existsSync(this.path)) {
45
+ // The QQ plugin rewrites this file non-atomically. A momentary absence
46
+ // must not detach an already running turn from its peer.
47
+ return
48
+ }
49
+ const stat = statSync(this.path)
50
+ if (stat.mtimeMs === this.mtimeMs) return
51
+ const data = JSON.parse(readFileSync(this.path, 'utf8'))
52
+ this.sessionIds = data?.sessionIds && typeof data.sessionIds === 'object' ? data.sessionIds : {}
53
+ this.sessionKeys = {}
54
+ for (const [key, sessionId] of Object.entries(this.sessionIds)) {
55
+ if (typeof sessionId === 'string' && sessionId) this.sessionKeys[sessionId] = key
56
+ }
57
+ this.mtimeMs = stat.mtimeMs
58
+ } catch {
59
+ // A concurrently written prefs file is not a reason to lose the current
60
+ // turn. Keep the last successful snapshot and try again next time.
61
+ }
62
+ }
63
+
64
+ findSessionKey(sessionId) {
65
+ this.#reload()
66
+ return this.sessionKeys[sessionId]
67
+ }
68
+
69
+ resolve(sessionId) {
70
+ const sessionKey = this.findSessionKey(sessionId)
71
+ return sessionKey ? { sessionKey, ...parseQQSessionKey(sessionKey) } : null
72
+ }
73
+
74
+ getSessionId(sessionKey) {
75
+ this.#reload()
76
+ return this.sessionIds[sessionKey]
77
+ }
78
+
79
+ list() {
80
+ this.#reload()
81
+ return Object.entries(this.sessionIds).map(([sessionKey, sessionId]) => ({
82
+ sessionKey,
83
+ sessionId,
84
+ ...parseQQSessionKey(sessionKey),
85
+ }))
86
+ }
87
+ }
@@ -0,0 +1,35 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ import { extractMessageText } from './store.js'
4
+
5
+ export const PROACTIVE_PREFIX = '【主动消息】'
6
+
7
+ function payloadOf(event) {
8
+ if (event?.type === 'user/message') return event.data
9
+ if (event?.type === 'assistant/message') return event.data?.message
10
+ return undefined
11
+ }
12
+
13
+ export function extractEventText(event) {
14
+ return extractMessageText(payloadOf(event))
15
+ }
16
+
17
+ export function isUserMessageEvent(event) {
18
+ return event?.type === 'user/message' && event.data?.source?.kind === 'user'
19
+ }
20
+
21
+ export function isProactiveUserMessage(event) {
22
+ if (event?.type !== 'user/message') return false
23
+ return extractEventText(event).startsWith(PROACTIVE_PREFIX)
24
+ }
25
+
26
+ export function eventIdentity(sessionId, event) {
27
+ if (event?.seq !== undefined) return `${sessionId}:${event.seq}`
28
+ const material = JSON.stringify({
29
+ type: event?.type,
30
+ time: event?.time,
31
+ data: event?.data,
32
+ })
33
+ const digest = createHash('sha1').update(material).digest('hex')
34
+ return `${sessionId}:${event?.type ?? 'unknown'}:${digest}`
35
+ }