@aalis/plugin-user-profile 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/README.md +21 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2158 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2158 @@
|
|
|
1
|
+
import { useCommandService } from '@aalis/plugin-commands-api';
|
|
2
|
+
import { resolveLLMModel } from '@aalis/plugin-llm-api';
|
|
3
|
+
import { WellKnownKinds } from '@aalis/plugin-message-api';
|
|
4
|
+
import { useToolService } from '@aalis/plugin-tools-api';
|
|
5
|
+
import { parseLLMJsonObject } from '@aalis/util-json-repair';
|
|
6
|
+
import '@aalis/plugin-agent-api';
|
|
7
|
+
import '@aalis/plugin-commands-api';
|
|
8
|
+
// ════════════════════════════════════════════════════════════
|
|
9
|
+
// plugin-user-profile — 用户事实档案
|
|
10
|
+
//
|
|
11
|
+
// 通过 LLM 从对话中提取「关于用户的长期事实」(喜好、经历、关系、近况),
|
|
12
|
+
// 按 platform:userId 维度落库到 memory metadata 的 user:profile namespace。
|
|
13
|
+
// 在 LLM 调用前注入到系统提示,让 Aalis 真正「记得这个人」。
|
|
14
|
+
//
|
|
15
|
+
// 设计原则:
|
|
16
|
+
// - 单点落库:所有事实存在一个 metadata 文档,避免散落多处
|
|
17
|
+
// - 冷却节流:同一用户在 N 秒内不重复提取,避免 LLM 调用风暴
|
|
18
|
+
// - 渐进合并:每次提取由 LLM 输出 add/remove 两个数组,自然演化
|
|
19
|
+
// - 零侵入:通过 events + middleware 接入,不修改 agent / persona / memory
|
|
20
|
+
//
|
|
21
|
+
// 关系强度(relationScore, 0~100)计算规则:
|
|
22
|
+
// - 每条入站消息(无论 agent 是否回复):+ relationIncrementWitness(默认 0.1,旁观档)
|
|
23
|
+
// - 若 agent 触发回复,额外叠加:
|
|
24
|
+
// direct(私聊) + relationIncrementDirect 默认 1.0
|
|
25
|
+
// immediate(@/呼名) + relationIncrementImmediate 默认 1.5
|
|
26
|
+
// interval(被动触发) + relationIncrementInterval 默认 0.5
|
|
27
|
+
// - 每日衰减:(now - lastInteractionAt) 天数 × relationScoreDecayPerDay(默认 0.5/天)
|
|
28
|
+
// - 上限 100,下限 0
|
|
29
|
+
// 例:群聊纯旁观一条 = +0.1;群聊被 @ 并回复 = +0.1 + 1.5 = +1.6;私聊一条 = +0.1 + 1.0 = +1.1
|
|
30
|
+
// ════════════════════════════════════════════════════════════
|
|
31
|
+
export const name = '@aalis/plugin-user-profile';
|
|
32
|
+
export const displayName = '用户事实档案';
|
|
33
|
+
export const subsystem = 'memory';
|
|
34
|
+
export const inject = {
|
|
35
|
+
required: ['memory', 'llm'],
|
|
36
|
+
optional: ['user-relation', 'tools'],
|
|
37
|
+
};
|
|
38
|
+
const PROFILE_NS = 'user:profile';
|
|
39
|
+
/** Aalis 自档案的 userKey 前缀。userKeyOf 形如 `platform:userId`,
|
|
40
|
+
* 这里用 `__self__:<personaName>` 这种不可能在真实平台出现的形式做隔离。
|
|
41
|
+
* 按 persona name 分堆:同名 persona(如 onebot 上的 aalis 与 webui 上的 aalis-webui两张卡都叫 Aalis)共享内心;
|
|
42
|
+
* 不同 persona(如 Babel)各自独立。 */
|
|
43
|
+
const SELF_KEY_PREFIX = '__self__:';
|
|
44
|
+
const DEFAULT_PERSONA_NAME = 'Aalis';
|
|
45
|
+
/** 第三方行为指令的独立 namespace(与 user:profile 解耦,独立生命周期)。
|
|
46
|
+
* 指令是「第三方对 Aalis 的客观行为约束」(如:不要长时间封禁;按严重程度分层禁言),
|
|
47
|
+
* 按 persona 名分堆(key = persona 名),与 selfFacts 的归属方式保持一致:
|
|
48
|
+
* 同名 persona(如 onebot 与 webui 两张都叫 Aalis)共享准则;不同 persona(如 Babel)各自独立。 */
|
|
49
|
+
const INSTRUCTIONS_NS = 'aalis:instructions';
|
|
50
|
+
export const configSchema = {
|
|
51
|
+
extractEveryNMessages: {
|
|
52
|
+
type: 'number',
|
|
53
|
+
label: '每 N 条消息提取一次',
|
|
54
|
+
description: '同一用户每发 N 条消息触发一次事实提取。无论 Aalis 是否回复都会计数,群聊中每人独立计数。设为 1 表示每条消息都尝试提取(不推荐);设为 0 或负数则禁用提取(仍会注入已有档案)',
|
|
55
|
+
default: 5,
|
|
56
|
+
},
|
|
57
|
+
historyForExtraction: {
|
|
58
|
+
type: 'number',
|
|
59
|
+
label: '提取参考历史条数',
|
|
60
|
+
description: '触发提取时,喂给 LLM 的最近消息条数',
|
|
61
|
+
default: 8,
|
|
62
|
+
},
|
|
63
|
+
maxFactsPerUser: {
|
|
64
|
+
type: 'number',
|
|
65
|
+
label: '单用户事实上限',
|
|
66
|
+
description: '超出后按 updatedAt 升序淘汰最久未更新的,旧事实自动淘汰。最小有效值为 5(写入时自动下限),不支持设为 0 来禁用提取;若要禁用提取请将 extractEveryNMessages 设为 0',
|
|
67
|
+
default: 30,
|
|
68
|
+
},
|
|
69
|
+
maxFactCharsPerItem: {
|
|
70
|
+
type: 'number',
|
|
71
|
+
label: '单条事实字数上限',
|
|
72
|
+
description: '超出会被裁断,避免 LLM 输出长段落代替事实',
|
|
73
|
+
default: 80,
|
|
74
|
+
},
|
|
75
|
+
maxOtherParticipants: {
|
|
76
|
+
type: 'number',
|
|
77
|
+
label: '群聊其他参与者档案上限',
|
|
78
|
+
description: '群聊中除当前发言者外,最多加载多少人的档案摘要注入 LLM。0 表示禁用群聊多用户注入',
|
|
79
|
+
default: 3,
|
|
80
|
+
},
|
|
81
|
+
maxFactsForOthers: {
|
|
82
|
+
type: 'number',
|
|
83
|
+
label: '其他参与者摘要条数上限',
|
|
84
|
+
description: '群聊背景参与者每人只显示最近更新的 N 条事实,避免 prompt 过长',
|
|
85
|
+
default: 5,
|
|
86
|
+
},
|
|
87
|
+
injectFeelingsForOthers: {
|
|
88
|
+
type: 'boolean',
|
|
89
|
+
label: '群聊副档案注入 Aalis 感受',
|
|
90
|
+
description: '在群聊其他参与者的 compact 档案后,附带 Aalis 对该用户的主观感受(与主发言者档案逻辑相同,但条数受 maxFeelingsForOthers 限制)。默认关闭以保持原有 prompt 体积',
|
|
91
|
+
default: false,
|
|
92
|
+
},
|
|
93
|
+
maxFeelingsForOthers: {
|
|
94
|
+
type: 'number',
|
|
95
|
+
label: '其他参与者感受条数上限',
|
|
96
|
+
description: '当 injectFeelingsForOthers 开启时,每位群聊背景参与者最多注入多少条 Aalis 感受',
|
|
97
|
+
default: 3,
|
|
98
|
+
},
|
|
99
|
+
temporaryFactMaxAgeDays: {
|
|
100
|
+
type: 'number',
|
|
101
|
+
label: '临时事实保留天数',
|
|
102
|
+
description: 'temporality=temporary 的事实超过该天数未更新后不再主动注入 prompt(仍保留在档案中,等待后续 update/remove)。0 表示不淡出',
|
|
103
|
+
default: 90,
|
|
104
|
+
},
|
|
105
|
+
relationScoreDecayPerDay: {
|
|
106
|
+
type: 'number',
|
|
107
|
+
label: '关系强度每日衰减',
|
|
108
|
+
description: '用户长期未互动时,relationScore 每天衰减的分数。0 表示不衰减',
|
|
109
|
+
default: 0.5,
|
|
110
|
+
},
|
|
111
|
+
relationIncrementDirect: {
|
|
112
|
+
type: 'number',
|
|
113
|
+
label: '私聊关系增量',
|
|
114
|
+
description: 'direct 触发时每条消息增加的关系强度',
|
|
115
|
+
default: 1,
|
|
116
|
+
},
|
|
117
|
+
relationIncrementImmediate: {
|
|
118
|
+
type: 'number',
|
|
119
|
+
label: '主动呼叫关系增量',
|
|
120
|
+
description: '群聊 @/名字主动触发时每条消息增加的关系强度',
|
|
121
|
+
default: 1.5,
|
|
122
|
+
},
|
|
123
|
+
relationIncrementInterval: {
|
|
124
|
+
type: 'number',
|
|
125
|
+
label: '群聊被动参与关系增量',
|
|
126
|
+
description: '群聊频率/活跃度被动触发或普通入站消息增加的关系强度',
|
|
127
|
+
default: 0.5,
|
|
128
|
+
},
|
|
129
|
+
relationIncrementWitness: {
|
|
130
|
+
type: 'number',
|
|
131
|
+
label: '旁观(不回复)关系增量',
|
|
132
|
+
description: '每条入站消息无论 agent 是否回复都加上的最低档增量。若 agent 触发回复,会再叠加 direct/immediate/interval 之一。0 表示禁用旁观计分',
|
|
133
|
+
default: 0.1,
|
|
134
|
+
},
|
|
135
|
+
extractLLM: {
|
|
136
|
+
type: 'llm-ref',
|
|
137
|
+
label: '提取用模型',
|
|
138
|
+
description: '留空则使用当前 LLM 服务的默认模型。事实提取是简单结构化任务,推荐选择廉价/快速模型(如 deepseek-chat)以降低成本',
|
|
139
|
+
},
|
|
140
|
+
allowGlobalBackfill: {
|
|
141
|
+
type: 'boolean',
|
|
142
|
+
label: '允许跨会话补齐副档案',
|
|
143
|
+
description: '当前群/会话中的候选不足时,是否允许从其他群、私聊等跨会话中选取最近互动过的用户来补全「其他参与者背景摘要」。关闭后仅限当前上下文内出现过的用户',
|
|
144
|
+
default: false,
|
|
145
|
+
},
|
|
146
|
+
enableAalisFeelings: {
|
|
147
|
+
type: 'boolean',
|
|
148
|
+
label: '启用 Aalis 对用户的主观感受',
|
|
149
|
+
description: '在每位用户的客观事实档案之外,额外让 Aalis 以「主观视角」记录对该用户的态度/情感/边缘观察(独立 schema,注入 prompt 时单独成段)。复用同一次 LLM 提取调用,不增加额外成本',
|
|
150
|
+
default: true,
|
|
151
|
+
},
|
|
152
|
+
maxFeelingsPerUser: {
|
|
153
|
+
type: 'number',
|
|
154
|
+
label: 'Aalis 对单个用户的感受条数上限',
|
|
155
|
+
description: '超出后按 updatedAt 升序淘汰最久未更新的,旧感受自动淘汰。建议比客观事实少。' +
|
|
156
|
+
'特殊值:设为 0 等于「只看不写」——不再让 LLM 提取新感受,但已存在的旧感受仍保留在档案中、仍会被注入 prompt(持久冻结)。' +
|
|
157
|
+
'若要彻底关闭注入,请关闭上方的「启用 Aalis 对用户的主观感受」总开关',
|
|
158
|
+
default: 15,
|
|
159
|
+
},
|
|
160
|
+
enableSelfProfile: {
|
|
161
|
+
type: 'boolean',
|
|
162
|
+
label: '启用 Aalis 自档案',
|
|
163
|
+
description: '让 Aalis 周期性地反思自身,提炼「关于自己的事实」(近期心情走向、在意的事、自我观察)。注入到所有 LLM 调用的 system prompt 早段,提供跨会话的人格延续性',
|
|
164
|
+
default: true,
|
|
165
|
+
},
|
|
166
|
+
selfReflectEveryNMessages: {
|
|
167
|
+
type: 'number',
|
|
168
|
+
label: 'Aalis 自反思触发频次',
|
|
169
|
+
description: '全局累计入站消息每 N 条触发一次自反思。建议比单用户提取慢(默认 25),避免抖动',
|
|
170
|
+
default: 25,
|
|
171
|
+
},
|
|
172
|
+
selfReflectHistory: {
|
|
173
|
+
type: 'number',
|
|
174
|
+
label: '自反思参考历史条数',
|
|
175
|
+
description: '触发自反思时,从触发会话取最近多少条消息作为反思材料',
|
|
176
|
+
default: 16,
|
|
177
|
+
},
|
|
178
|
+
maxSelfFacts: {
|
|
179
|
+
type: 'number',
|
|
180
|
+
label: 'Aalis 自档案事实上限',
|
|
181
|
+
description: '超出后按 updatedAt 升序淘汰最久未更新的。最小有效值为 5(写入时自动下限),不支持设为 0 来禁用;若要禁用自反思请关闭 enableSelfProfile',
|
|
182
|
+
default: 20,
|
|
183
|
+
},
|
|
184
|
+
enableInstructions: {
|
|
185
|
+
type: 'boolean',
|
|
186
|
+
label: '启用第三方行为指令',
|
|
187
|
+
description: '让 Aalis 记录第三方(管理员/操作者)对其行为的客观指令(如「不要长时间封禁」「按严重程度分层禁言」),作为跨会话的行为约束。' +
|
|
188
|
+
'注入到 system prompt 最前段,优先级高于自反思 selfFacts。按当前 persona 名分堆。',
|
|
189
|
+
default: true,
|
|
190
|
+
},
|
|
191
|
+
instructionMinAuthority: {
|
|
192
|
+
type: 'number',
|
|
193
|
+
label: '指令最小权限门槛',
|
|
194
|
+
description: '只有 authority ≥ 该值的用户的发言才会被采纳为指令来源(防陌生人塞规则)。默认 2(一般管理员),设为 5 表示仅 owner。' +
|
|
195
|
+
'通过 /instruct 命令手动添加的指令不走该门槛(命令本身已是 authority 2 守卫)',
|
|
196
|
+
default: 2,
|
|
197
|
+
},
|
|
198
|
+
instructionExtractEveryNMessages: {
|
|
199
|
+
type: 'number',
|
|
200
|
+
label: '指令自动提取触发频次',
|
|
201
|
+
description: '全局累计入站消息每 N 条触发一次指令提取。建议比自反思更慢(默认 40),降低成本;设为 0 禁用 LLM 自动提取,只保留 /instruct 命令通道',
|
|
202
|
+
default: 40,
|
|
203
|
+
},
|
|
204
|
+
instructionHistoryForExtraction: {
|
|
205
|
+
type: 'number',
|
|
206
|
+
label: '指令提取参考历史条数',
|
|
207
|
+
description: '触发指令提取时从触发会话取最近多少条消息作为提取材料',
|
|
208
|
+
default: 20,
|
|
209
|
+
},
|
|
210
|
+
maxInstructions: {
|
|
211
|
+
type: 'number',
|
|
212
|
+
label: '指令条数上限',
|
|
213
|
+
description: '超出后按 updatedAt 升序淘汰最久未更新的。最小有效值为 3。指令上限建议比 selfFacts 紧,准则少而准比多而散更好',
|
|
214
|
+
default: 12,
|
|
215
|
+
},
|
|
216
|
+
maxInstructionCharsPerItem: {
|
|
217
|
+
type: 'number',
|
|
218
|
+
label: '单条指令字数上限',
|
|
219
|
+
description: '超出会被裁断',
|
|
220
|
+
default: 120,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
export const defaultConfig = {
|
|
224
|
+
extractEveryNMessages: 5,
|
|
225
|
+
historyForExtraction: 8,
|
|
226
|
+
maxFactsPerUser: 30,
|
|
227
|
+
maxFactCharsPerItem: 80,
|
|
228
|
+
maxOtherParticipants: 3,
|
|
229
|
+
maxFactsForOthers: 5,
|
|
230
|
+
injectFeelingsForOthers: false,
|
|
231
|
+
maxFeelingsForOthers: 3,
|
|
232
|
+
temporaryFactMaxAgeDays: 90,
|
|
233
|
+
relationScoreDecayPerDay: 0.5,
|
|
234
|
+
relationIncrementDirect: 1,
|
|
235
|
+
relationIncrementImmediate: 1.5,
|
|
236
|
+
relationIncrementInterval: 0.5,
|
|
237
|
+
relationIncrementWitness: 0.1,
|
|
238
|
+
allowGlobalBackfill: false,
|
|
239
|
+
enableAalisFeelings: true,
|
|
240
|
+
maxFeelingsPerUser: 15,
|
|
241
|
+
enableSelfProfile: true,
|
|
242
|
+
selfReflectEveryNMessages: 25,
|
|
243
|
+
selfReflectHistory: 16,
|
|
244
|
+
maxSelfFacts: 20,
|
|
245
|
+
enableInstructions: true,
|
|
246
|
+
instructionMinAuthority: 2,
|
|
247
|
+
instructionExtractEveryNMessages: 40,
|
|
248
|
+
instructionHistoryForExtraction: 20,
|
|
249
|
+
maxInstructions: 12,
|
|
250
|
+
maxInstructionCharsPerItem: 120,
|
|
251
|
+
};
|
|
252
|
+
const KNOWN_CATEGORIES = [
|
|
253
|
+
'兴趣爱好',
|
|
254
|
+
'职业身份',
|
|
255
|
+
'人际关系',
|
|
256
|
+
'近期处境',
|
|
257
|
+
'价值观',
|
|
258
|
+
'性格特征',
|
|
259
|
+
'偏好',
|
|
260
|
+
'忌讳',
|
|
261
|
+
'其他',
|
|
262
|
+
];
|
|
263
|
+
const KNOWN_INSTRUCTION_CATEGORIES = [
|
|
264
|
+
'审核与处罚',
|
|
265
|
+
'隐私与边界',
|
|
266
|
+
'语气与表达',
|
|
267
|
+
'响应策略',
|
|
268
|
+
'安全',
|
|
269
|
+
'其他',
|
|
270
|
+
];
|
|
271
|
+
/** 生成稳定短 ID(6 字符 base36,对 30 条以内规模碰撞概率极低) */
|
|
272
|
+
function genFactId(existing) {
|
|
273
|
+
for (let i = 0; i < 8; i++) {
|
|
274
|
+
const id = `f${Math.random().toString(36).slice(2, 7)}`;
|
|
275
|
+
if (!existing.has(id))
|
|
276
|
+
return id;
|
|
277
|
+
}
|
|
278
|
+
// 极端兜底:加时间戳后缀
|
|
279
|
+
return `f${Date.now().toString(36).slice(-5)}`;
|
|
280
|
+
}
|
|
281
|
+
function decimalPlaces(value) {
|
|
282
|
+
if (!Number.isFinite(value))
|
|
283
|
+
return 0;
|
|
284
|
+
const text = String(value).toLowerCase();
|
|
285
|
+
const exponentIndex = text.indexOf('e');
|
|
286
|
+
if (exponentIndex >= 0) {
|
|
287
|
+
const mantissa = text.slice(0, exponentIndex);
|
|
288
|
+
const exponent = Number(text.slice(exponentIndex + 1));
|
|
289
|
+
const mantissaDecimals = mantissa.split('.')[1]?.replace(/0+$/, '').length ?? 0;
|
|
290
|
+
return Math.max(0, mantissaDecimals - exponent);
|
|
291
|
+
}
|
|
292
|
+
return text.split('.')[1]?.replace(/0+$/, '').length ?? 0;
|
|
293
|
+
}
|
|
294
|
+
function metadataString(message, key) {
|
|
295
|
+
const value = message.metadata?.[key];
|
|
296
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
297
|
+
}
|
|
298
|
+
function getMessageUserId(message) {
|
|
299
|
+
return metadataString(message, 'userId') ?? message.name;
|
|
300
|
+
}
|
|
301
|
+
function isTargetUserMessage(message, userId, platform) {
|
|
302
|
+
if (message.role !== 'user' || !message.content)
|
|
303
|
+
return false;
|
|
304
|
+
const msgUserId = getMessageUserId(message);
|
|
305
|
+
if (msgUserId !== userId)
|
|
306
|
+
return false;
|
|
307
|
+
const msgPlatform = metadataString(message, 'platform');
|
|
308
|
+
return !msgPlatform || !platform || msgPlatform === platform;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* 将文本标准化为可子串匹配的形式:去空白、去常见标点、toLowerCase。
|
|
312
|
+
* 用于 sourceQuote 校验,容忍 LLM 轻微原词改写。
|
|
313
|
+
*/
|
|
314
|
+
function normalizeForQuoteMatch(s) {
|
|
315
|
+
return s
|
|
316
|
+
.toLowerCase()
|
|
317
|
+
.replace(/[\s\u3000]+/g, '')
|
|
318
|
+
.replace(/[。,、;:?!“”‘’()《》【】.,;:?!"'()[\]<>\-_~`*]/g, '');
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* 拼接目标用户自己发言作为 sourceQuote 校验语料。
|
|
322
|
+
* 只包含目标用户 role==='user' 且 metadata.userId 匹配的消息。
|
|
323
|
+
*/
|
|
324
|
+
function buildTargetUserCorpus(history, userId, platform) {
|
|
325
|
+
return history
|
|
326
|
+
.filter(m => isTargetUserMessage(m, userId, platform))
|
|
327
|
+
.map(m => m.content ?? '')
|
|
328
|
+
.join('\n');
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* 渲染完整会话历史供提取 LLM 作为消歧上下文:
|
|
332
|
+
* 每行带身份标签区分「目标用户」与「其他用户」,
|
|
333
|
+
* 不做 strip(引用原话不会在目标用户语料中出现,sourceQuote 校验能拦住)。
|
|
334
|
+
*/
|
|
335
|
+
function renderHistoryForExtract(history, userId, platform) {
|
|
336
|
+
return history
|
|
337
|
+
.filter(m => (m.role === 'user' || m.role === 'assistant' || m.role === 'notice') && m.content)
|
|
338
|
+
.map(m => {
|
|
339
|
+
const time = m.timestamp ? new Date(m.timestamp).toLocaleString('zh-CN', { hour12: false }) : '';
|
|
340
|
+
const content = (m.content ?? '').trim();
|
|
341
|
+
if (!content)
|
|
342
|
+
return '';
|
|
343
|
+
if (m.role === 'assistant') {
|
|
344
|
+
// Aalis 的回复:加标签让 LLM 知道用户是在跟谁互动,用于判断「对 Aalis 的行为」
|
|
345
|
+
return `[${time}] [Aalis]: ${content}`;
|
|
346
|
+
}
|
|
347
|
+
if (m.role === 'notice') {
|
|
348
|
+
// 平台事件(群禁言 / 撤回 / 成员进出 / poke 等),供提取判断「用户被谁怎么了」之类上下文
|
|
349
|
+
return `[${time}] [系统通知]: ${content}`;
|
|
350
|
+
}
|
|
351
|
+
const nickname = metadataString(m, 'nickname');
|
|
352
|
+
const msgUserId = getMessageUserId(m) ?? '';
|
|
353
|
+
const msgPlatform = metadataString(m, 'platform');
|
|
354
|
+
const isTarget = msgUserId === userId && (!msgPlatform || !platform || msgPlatform === platform);
|
|
355
|
+
const label = nickname ? `${nickname}(${msgUserId || '?'})` : msgUserId || '?';
|
|
356
|
+
const tag = isTarget ? '目标用户' : '其他用户';
|
|
357
|
+
return `[${time}] [${tag} ${label}]: ${content}`;
|
|
358
|
+
})
|
|
359
|
+
.filter(Boolean)
|
|
360
|
+
.join('\n');
|
|
361
|
+
}
|
|
362
|
+
export function apply(ctx, config) {
|
|
363
|
+
const cfg = {
|
|
364
|
+
extractEveryNMessages: config.extractEveryNMessages ?? 5,
|
|
365
|
+
historyForExtraction: Math.max(2, config.historyForExtraction ?? 8),
|
|
366
|
+
maxFactsPerUser: Math.max(5, config.maxFactsPerUser ?? 30),
|
|
367
|
+
maxFactCharsPerItem: Math.max(20, config.maxFactCharsPerItem ?? 80),
|
|
368
|
+
maxOtherParticipants: Math.max(0, config.maxOtherParticipants ?? 3),
|
|
369
|
+
maxFactsForOthers: Math.max(1, config.maxFactsForOthers ?? 5),
|
|
370
|
+
injectFeelingsForOthers: config.injectFeelingsForOthers ?? false,
|
|
371
|
+
maxFeelingsForOthers: Math.max(1, config.maxFeelingsForOthers ?? 3),
|
|
372
|
+
temporaryFactMaxAgeDays: Math.max(0, config.temporaryFactMaxAgeDays ?? 90),
|
|
373
|
+
relationScoreDecayPerDay: Math.max(0, config.relationScoreDecayPerDay ?? 0.5),
|
|
374
|
+
relationIncrementDirect: Math.max(0, config.relationIncrementDirect ?? 1),
|
|
375
|
+
relationIncrementImmediate: Math.max(0, config.relationIncrementImmediate ?? 1.5),
|
|
376
|
+
relationIncrementInterval: Math.max(0, config.relationIncrementInterval ?? 0.5),
|
|
377
|
+
relationIncrementWitness: Math.max(0, config.relationIncrementWitness ?? 0.1),
|
|
378
|
+
extractLLM: config.extractLLM &&
|
|
379
|
+
typeof config.extractLLM === 'object' &&
|
|
380
|
+
config.extractLLM.provider &&
|
|
381
|
+
config.extractLLM.model
|
|
382
|
+
? config.extractLLM
|
|
383
|
+
: undefined,
|
|
384
|
+
allowGlobalBackfill: config.allowGlobalBackfill ?? false,
|
|
385
|
+
enableAalisFeelings: config.enableAalisFeelings ?? true,
|
|
386
|
+
maxFeelingsPerUser: Math.max(0, config.maxFeelingsPerUser ?? 15),
|
|
387
|
+
enableSelfProfile: config.enableSelfProfile ?? true,
|
|
388
|
+
selfReflectEveryNMessages: Math.max(0, config.selfReflectEveryNMessages ?? 25),
|
|
389
|
+
selfReflectHistory: Math.max(4, config.selfReflectHistory ?? 16),
|
|
390
|
+
maxSelfFacts: Math.max(5, config.maxSelfFacts ?? 20),
|
|
391
|
+
enableInstructions: config.enableInstructions ?? true,
|
|
392
|
+
instructionMinAuthority: Math.max(0, config.instructionMinAuthority ?? 2),
|
|
393
|
+
instructionExtractEveryNMessages: Math.max(0, config.instructionExtractEveryNMessages ?? 40),
|
|
394
|
+
instructionHistoryForExtraction: Math.max(4, config.instructionHistoryForExtraction ?? 20),
|
|
395
|
+
maxInstructions: Math.max(3, config.maxInstructions ?? 12),
|
|
396
|
+
maxInstructionCharsPerItem: Math.max(20, config.maxInstructionCharsPerItem ?? 120),
|
|
397
|
+
};
|
|
398
|
+
/** 每会话每用户累计入站消息数(用于 extractEveryNMessages 计数),不随提取重置 */
|
|
399
|
+
const userMessageCount = new Map();
|
|
400
|
+
/** 防止同一用户的提取并发触发 */
|
|
401
|
+
const inflightExtractions = new Set();
|
|
402
|
+
const relationScorePrecision = Math.min(6, Math.max(decimalPlaces(cfg.relationScoreDecayPerDay), decimalPlaces(cfg.relationIncrementDirect), decimalPlaces(cfg.relationIncrementImmediate), decimalPlaces(cfg.relationIncrementInterval)));
|
|
403
|
+
function userKeyOf(platform, userId) {
|
|
404
|
+
return `${platform ?? ''}:${userId}`;
|
|
405
|
+
}
|
|
406
|
+
function extractionCountKeyOf(sessionId, platform, userId) {
|
|
407
|
+
return `${sessionId}:${userKeyOf(platform, userId)}`;
|
|
408
|
+
}
|
|
409
|
+
/** 返回当前活跃 persona 的 self key(运行时解析,跟随 persona 切换)。
|
|
410
|
+
* 若 persona 服务不可用或未配 name,回退到默认 `aalis`。 */
|
|
411
|
+
function getCurrentPersonaName() {
|
|
412
|
+
try {
|
|
413
|
+
const persona = ctx.getService('persona');
|
|
414
|
+
const name = persona?.getPersonaName?.()?.trim();
|
|
415
|
+
return name && name.length > 0 ? name : DEFAULT_PERSONA_NAME;
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return DEFAULT_PERSONA_NAME;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function getSelfKey() {
|
|
422
|
+
return `${SELF_KEY_PREFIX}${getCurrentPersonaName()}`;
|
|
423
|
+
}
|
|
424
|
+
/** instructions 按 persona 名分堆:直接以 persona 名作为 metadata key */
|
|
425
|
+
function getInstructionsKey() {
|
|
426
|
+
return getCurrentPersonaName();
|
|
427
|
+
}
|
|
428
|
+
function normalizeInstructionCategory(v) {
|
|
429
|
+
if (typeof v !== 'string')
|
|
430
|
+
return undefined;
|
|
431
|
+
return KNOWN_INSTRUCTION_CATEGORIES.includes(v) ? v : undefined;
|
|
432
|
+
}
|
|
433
|
+
function normalizeInstructionSeverity(v) {
|
|
434
|
+
return v === 'must' || v === 'should' || v === 'avoid' ? v : undefined;
|
|
435
|
+
}
|
|
436
|
+
function clipInstructionText(s) {
|
|
437
|
+
return s.length > cfg.maxInstructionCharsPerItem ? `${s.slice(0, cfg.maxInstructionCharsPerItem)}…` : s;
|
|
438
|
+
}
|
|
439
|
+
/** 生成 Instruction 短 ID(与 Fact 同前缀但带 'i' 区分,便于日志中识别) */
|
|
440
|
+
function genInstructionId(existing) {
|
|
441
|
+
for (let i = 0; i < 8; i++) {
|
|
442
|
+
const id = `i${Math.random().toString(36).slice(2, 7)}`;
|
|
443
|
+
if (!existing.has(id))
|
|
444
|
+
return id;
|
|
445
|
+
}
|
|
446
|
+
return `i${Date.now().toString(36).slice(-5)}`;
|
|
447
|
+
}
|
|
448
|
+
function parseInstructionArray(raw) {
|
|
449
|
+
const usedIds = new Set();
|
|
450
|
+
const list = [];
|
|
451
|
+
for (const item of raw) {
|
|
452
|
+
if (!item || typeof item !== 'object')
|
|
453
|
+
continue;
|
|
454
|
+
const obj = item;
|
|
455
|
+
const text = typeof obj.text === 'string' ? obj.text.trim() : '';
|
|
456
|
+
if (!text)
|
|
457
|
+
continue;
|
|
458
|
+
let id = typeof obj.id === 'string' && obj.id.trim() ? obj.id.trim() : genInstructionId(usedIds);
|
|
459
|
+
while (usedIds.has(id))
|
|
460
|
+
id = genInstructionId(usedIds);
|
|
461
|
+
usedIds.add(id);
|
|
462
|
+
const updatedAt = typeof obj.updatedAt === 'number' ? obj.updatedAt : 0;
|
|
463
|
+
const observedAt = typeof obj.observedAt === 'number' ? obj.observedAt : updatedAt || undefined;
|
|
464
|
+
list.push({
|
|
465
|
+
id,
|
|
466
|
+
text,
|
|
467
|
+
category: normalizeInstructionCategory(obj.category),
|
|
468
|
+
severity: normalizeInstructionSeverity(obj.severity),
|
|
469
|
+
sourceUserKey: typeof obj.sourceUserKey === 'string' && obj.sourceUserKey.trim() ? obj.sourceUserKey : undefined,
|
|
470
|
+
sourceUserName: typeof obj.sourceUserName === 'string' && obj.sourceUserName.trim() ? obj.sourceUserName : undefined,
|
|
471
|
+
sourceChannel: obj.sourceChannel === 'llm' || obj.sourceChannel === 'command' ? obj.sourceChannel : undefined,
|
|
472
|
+
observedAt,
|
|
473
|
+
updatedAt,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return list;
|
|
477
|
+
}
|
|
478
|
+
async function loadInstructions() {
|
|
479
|
+
const memory = ctx.getService('memory');
|
|
480
|
+
const empty = { instructions: [], updatedAt: 0 };
|
|
481
|
+
if (!memory?.getMetadata)
|
|
482
|
+
return empty;
|
|
483
|
+
try {
|
|
484
|
+
const doc = await memory.getMetadata(INSTRUCTIONS_NS, getInstructionsKey());
|
|
485
|
+
if (!doc)
|
|
486
|
+
return empty;
|
|
487
|
+
const instructions = Array.isArray(doc.instructions) ? parseInstructionArray(doc.instructions) : [];
|
|
488
|
+
return { instructions, updatedAt: typeof doc.updatedAt === 'number' ? doc.updatedAt : 0 };
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
ctx.logger.debug(`加载指令档案失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
492
|
+
return empty;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
async function saveInstructions(doc) {
|
|
496
|
+
const memory = ctx.getService('memory');
|
|
497
|
+
if (!memory?.saveMetadata)
|
|
498
|
+
return;
|
|
499
|
+
await memory.saveMetadata(INSTRUCTIONS_NS, getInstructionsKey(), {
|
|
500
|
+
instructions: doc.instructions,
|
|
501
|
+
updatedAt: doc.updatedAt,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
/** 把 add/update/remove 应用到现有 instructions,应用上限与单条裁剪 */
|
|
505
|
+
function mergeInstructions(existing, add, update, remove) {
|
|
506
|
+
const now = Date.now();
|
|
507
|
+
const byId = new Map();
|
|
508
|
+
for (const ins of existing)
|
|
509
|
+
byId.set(ins.id, ins);
|
|
510
|
+
for (const id of remove)
|
|
511
|
+
byId.delete(id);
|
|
512
|
+
const usedIds = new Set(byId.keys());
|
|
513
|
+
for (const { id, patch } of update) {
|
|
514
|
+
const old = byId.get(id);
|
|
515
|
+
if (!old)
|
|
516
|
+
continue;
|
|
517
|
+
byId.set(id, {
|
|
518
|
+
...old,
|
|
519
|
+
...patch,
|
|
520
|
+
id,
|
|
521
|
+
text: patch.text ? clipInstructionText(patch.text) : old.text,
|
|
522
|
+
observedAt: old.observedAt ?? now,
|
|
523
|
+
updatedAt: now,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const textSet = new Set(Array.from(byId.values()).map(f => f.text));
|
|
527
|
+
for (const a of add) {
|
|
528
|
+
const text = clipInstructionText(a.text);
|
|
529
|
+
if (textSet.has(text))
|
|
530
|
+
continue;
|
|
531
|
+
const id = genInstructionId(usedIds);
|
|
532
|
+
usedIds.add(id);
|
|
533
|
+
byId.set(id, {
|
|
534
|
+
id,
|
|
535
|
+
text,
|
|
536
|
+
category: a.category,
|
|
537
|
+
severity: a.severity,
|
|
538
|
+
sourceUserKey: a.sourceUserKey,
|
|
539
|
+
sourceUserName: a.sourceUserName,
|
|
540
|
+
sourceChannel: a.sourceChannel,
|
|
541
|
+
observedAt: now,
|
|
542
|
+
updatedAt: now,
|
|
543
|
+
});
|
|
544
|
+
textSet.add(text);
|
|
545
|
+
}
|
|
546
|
+
let merged = Array.from(byId.values());
|
|
547
|
+
if (cfg.maxInstructions > 0 && merged.length > cfg.maxInstructions) {
|
|
548
|
+
merged.sort((a, b) => a.updatedAt - b.updatedAt);
|
|
549
|
+
merged = merged.slice(merged.length - cfg.maxInstructions);
|
|
550
|
+
}
|
|
551
|
+
merged.sort((a, b) => a.updatedAt - b.updatedAt);
|
|
552
|
+
return merged;
|
|
553
|
+
}
|
|
554
|
+
function renderInstructionLine(ins) {
|
|
555
|
+
const tag = ins.severity === 'must' ? '【必须】' : ins.severity === 'avoid' ? '【避免】' : '【应当】';
|
|
556
|
+
const source = ins.sourceUserName ? `(来自 ${ins.sourceUserName})` : '';
|
|
557
|
+
return `- ${tag} ${ins.text}${source}`;
|
|
558
|
+
}
|
|
559
|
+
function renderInstructionsBlock(instructions) {
|
|
560
|
+
if (instructions.length === 0)
|
|
561
|
+
return '';
|
|
562
|
+
const groups = new Map();
|
|
563
|
+
for (const ins of instructions) {
|
|
564
|
+
const key = ins.category ?? '其他';
|
|
565
|
+
if (!groups.has(key))
|
|
566
|
+
groups.set(key, []);
|
|
567
|
+
groups.get(key).push(ins);
|
|
568
|
+
}
|
|
569
|
+
const sections = [];
|
|
570
|
+
for (const cat of KNOWN_INSTRUCTION_CATEGORIES) {
|
|
571
|
+
const items = groups.get(cat);
|
|
572
|
+
if (items && items.length > 0) {
|
|
573
|
+
sections.push(`## ${cat}\n${items.map(renderInstructionLine).join('\n')}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
for (const [cat, items] of groups) {
|
|
577
|
+
if (!KNOWN_INSTRUCTION_CATEGORIES.includes(cat) && items.length > 0) {
|
|
578
|
+
sections.push(`## ${cat}\n${items.map(renderInstructionLine).join('\n')}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return sections.join('\n\n');
|
|
582
|
+
}
|
|
583
|
+
function normalizeTemporality(v, category) {
|
|
584
|
+
if (v === 'permanent' || v === 'temporary')
|
|
585
|
+
return v;
|
|
586
|
+
return category === '近期处境' ? 'temporary' : 'permanent';
|
|
587
|
+
}
|
|
588
|
+
function normalizeTextField(v) {
|
|
589
|
+
if (typeof v !== 'string')
|
|
590
|
+
return undefined;
|
|
591
|
+
const s = v.trim();
|
|
592
|
+
return s.length > 0 ? s.slice(0, 40) : undefined;
|
|
593
|
+
}
|
|
594
|
+
/** 解析 metadata 中的事实数组(兼容旧格式 string[],自动迁移) */
|
|
595
|
+
function parseFactArray(raw) {
|
|
596
|
+
const usedIds = new Set();
|
|
597
|
+
const facts = [];
|
|
598
|
+
for (const item of raw) {
|
|
599
|
+
if (typeof item === 'string' && item.trim()) {
|
|
600
|
+
const id = genFactId(usedIds);
|
|
601
|
+
usedIds.add(id);
|
|
602
|
+
facts.push({ id, text: item.trim(), temporality: 'permanent', updatedAt: 0 });
|
|
603
|
+
}
|
|
604
|
+
else if (item && typeof item === 'object') {
|
|
605
|
+
const obj = item;
|
|
606
|
+
const text = typeof obj.text === 'string' ? obj.text.trim() : '';
|
|
607
|
+
if (!text)
|
|
608
|
+
continue;
|
|
609
|
+
let id = typeof obj.id === 'string' && obj.id.trim() ? obj.id.trim() : genFactId(usedIds);
|
|
610
|
+
while (usedIds.has(id))
|
|
611
|
+
id = genFactId(usedIds);
|
|
612
|
+
usedIds.add(id);
|
|
613
|
+
const cat = typeof obj.category === 'string' && KNOWN_CATEGORIES.includes(obj.category)
|
|
614
|
+
? obj.category
|
|
615
|
+
: undefined;
|
|
616
|
+
const updatedAt = typeof obj.updatedAt === 'number' ? obj.updatedAt : 0;
|
|
617
|
+
const observedAt = typeof obj.observedAt === 'number' ? obj.observedAt : updatedAt || undefined;
|
|
618
|
+
facts.push({
|
|
619
|
+
id,
|
|
620
|
+
text,
|
|
621
|
+
category: cat,
|
|
622
|
+
temporality: normalizeTemporality(obj.temporality, cat),
|
|
623
|
+
observedAt,
|
|
624
|
+
timeHint: normalizeTextField(obj.timeHint),
|
|
625
|
+
updatedAt,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return facts;
|
|
630
|
+
}
|
|
631
|
+
/** 读取一个用户的现有档案(不存在返回 undefined)。兼容旧格式 string[],自动迁移 */
|
|
632
|
+
async function loadProfile(userKey) {
|
|
633
|
+
const memory = ctx.getService('memory');
|
|
634
|
+
if (!memory?.getMetadata)
|
|
635
|
+
return undefined;
|
|
636
|
+
try {
|
|
637
|
+
const doc = await memory.getMetadata(PROFILE_NS, userKey);
|
|
638
|
+
if (!doc)
|
|
639
|
+
return undefined;
|
|
640
|
+
const facts = Array.isArray(doc.facts) ? parseFactArray(doc.facts) : [];
|
|
641
|
+
return {
|
|
642
|
+
facts,
|
|
643
|
+
aalisFeelings: Array.isArray(doc.aalisFeelings) ? parseFactArray(doc.aalisFeelings) : undefined,
|
|
644
|
+
relationScore: typeof doc.relationScore === 'number' ? Math.min(100, Math.max(0, doc.relationScore)) : 0,
|
|
645
|
+
interactionCount: typeof doc.interactionCount === 'number' ? Math.max(0, Math.floor(doc.interactionCount)) : 0,
|
|
646
|
+
lastInteractionAt: typeof doc.lastInteractionAt === 'number' ? doc.lastInteractionAt : undefined,
|
|
647
|
+
updatedAt: typeof doc.updatedAt === 'number' ? doc.updatedAt : 0,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
catch (err) {
|
|
651
|
+
ctx.logger.debug(`加载用户档案失败 (${userKey}): ${err instanceof Error ? err.message : String(err)}`);
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/** 保存档案(覆盖式) */
|
|
656
|
+
async function saveProfile(userKey, profile) {
|
|
657
|
+
const memory = ctx.getService('memory');
|
|
658
|
+
if (!memory?.saveMetadata)
|
|
659
|
+
return;
|
|
660
|
+
const payload = {
|
|
661
|
+
facts: profile.facts,
|
|
662
|
+
relationScore: profile.relationScore ?? 0,
|
|
663
|
+
interactionCount: profile.interactionCount ?? 0,
|
|
664
|
+
lastInteractionAt: profile.lastInteractionAt,
|
|
665
|
+
updatedAt: profile.updatedAt,
|
|
666
|
+
};
|
|
667
|
+
if (profile.aalisFeelings && profile.aalisFeelings.length > 0) {
|
|
668
|
+
payload.aalisFeelings = profile.aalisFeelings;
|
|
669
|
+
}
|
|
670
|
+
await memory.saveMetadata(PROFILE_NS, userKey, payload);
|
|
671
|
+
}
|
|
672
|
+
function normalizeCategory(v) {
|
|
673
|
+
if (typeof v !== 'string')
|
|
674
|
+
return undefined;
|
|
675
|
+
return KNOWN_CATEGORIES.includes(v) ? v : undefined;
|
|
676
|
+
}
|
|
677
|
+
/** 调用 LLM 从历史中提取/修订事实,返回 add / update / remove 三类操作,
|
|
678
|
+
* 以及(可选)Aalis 对该用户的主观感受 feelings* 三类操作。 */
|
|
679
|
+
async function llmExtractFacts(history, existingFacts, existingFeelings, nickname, userId, platform) {
|
|
680
|
+
const empty = {
|
|
681
|
+
add: [],
|
|
682
|
+
update: [],
|
|
683
|
+
remove: [],
|
|
684
|
+
feelingsAdd: [],
|
|
685
|
+
feelingsUpdate: [],
|
|
686
|
+
feelingsRemove: [],
|
|
687
|
+
};
|
|
688
|
+
const includeFeelings = cfg.enableAalisFeelings && cfg.maxFeelingsPerUser > 0;
|
|
689
|
+
const feelingsPrompt = includeFeelings
|
|
690
|
+
? '\n\n## 额外任务:Aalis 对该用户的主观感受(aalisFeelings)\n' +
|
|
691
|
+
'除了上面的客观事实,你还要以「Aalis(你)」的第一人称视角,记录对该用户的**主观态度/情感/边缘观察**。' +
|
|
692
|
+
'这些是 Aalis 自己的内心感受、对这个人的印象、相处中形成的偏好,不是客观事实。' +
|
|
693
|
+
'\n\n### ⚠️ 反自我强化(最重要)\n' +
|
|
694
|
+
'下面会给你看「已知 Aalis 对该用户的感受」——这就是上一轮你自己写下的、下一轮会注入回 system prompt 影响 Aalis 语气的内容,' +
|
|
695
|
+
'然后又被你读到并再次确认。这是会自我放大的闭环,必须主动反向校正:' +
|
|
696
|
+
'\n (a) **不要因为档案里已经有某条感受就强化它**:「已有"觉得他依赖我"」+ 本批又出现一次撒娇 ≠ 自动 update 成"非常依赖我",' +
|
|
697
|
+
'这只是同方向的重复采样,不构成新依据' +
|
|
698
|
+
'\n (b) 真正值得 update 的,是出现**新的、与既有感受相反或正交的依据**:' +
|
|
699
|
+
'若历史中出现明显反例(如已有"觉得他冷淡" 但本批多处主动关心你),优先 update / remove 让感受更接近真实' +
|
|
700
|
+
'\n (c) add 一条全新感受需要**整段历史多处反复出现**,单次互动一律不写' +
|
|
701
|
+
'\n (d) 如果只是同方向重复,宁可什么都不动让档案保持稳定,也不要让 updatedAt 假装"还在更新"' +
|
|
702
|
+
'\n\n常规规则:' +
|
|
703
|
+
'\n- 允许包含「关于自己的反思」(如「我注意到自己跟 ta 聊技术时容易激动」)让感受更立体' +
|
|
704
|
+
'\n- text 以 Aalis 的口吻写:「觉得他可靠」「和他聊天总是轻松」「对她的依赖让我有点不知所措」「他偶尔的冒犯让我警觉」等' +
|
|
705
|
+
'\n- 同样支持 add / update / remove;id 空间与 facts 独立' +
|
|
706
|
+
`\n- 单条不超过 ${cfg.maxFactCharsPerItem} 字;保守优先,没有就空数组` +
|
|
707
|
+
'\n- 不需要 sourceQuote(这是主观感受,不需要原话依据)' +
|
|
708
|
+
'\n- temporality:长期形成的印象用 permanent;近期情绪/状态用 temporary' +
|
|
709
|
+
'\n- 输出字段名:feelingsAdd / feelingsUpdate / feelingsRemove(与 add/update/remove 并列)'
|
|
710
|
+
: '';
|
|
711
|
+
const sys = '你是用户档案管理员。输入是一段多用户会话历史,每行开头有身份标签:' +
|
|
712
|
+
'`[目标用户 X(uid)]`、`[其他用户 Y(uid)]` 或 `[Aalis]`。上下文仅供消歧,' +
|
|
713
|
+
'**只允许根据「目标用户」自己的发言写入事实**,不能根据其他用户、Aalis 的猜测或提问写入事实。' +
|
|
714
|
+
'\n\n你可以执行三种操作(在一次输出里组合):' +
|
|
715
|
+
'\n- add: 添加新事实,需指定 text、category、sourceQuote' +
|
|
716
|
+
'\n- update: 用 id 精确替换某条已知事实,同样需 sourceQuote(优先使用 update 而非 add 来避免重复)' +
|
|
717
|
+
'\n- remove: 用 id 删除已被推翻、过时的事实(不需 sourceQuote)' +
|
|
718
|
+
'\n\n规则:' +
|
|
719
|
+
'\n1. 事实的**主语必须是「目标用户」本人**,不能是 ta 转述、讨论、评价的第三方。' +
|
|
720
|
+
'即使 sourceQuote 出自目标用户原话,也要先在脑中追问:' +
|
|
721
|
+
'\n - 这是 ta 在说自己,还是在评价/复述别人?(比如目标用户说"小明会写檄文" → 这是关于小明的,不写)' +
|
|
722
|
+
'\n - 是认真陈述,还是反讽/否定/假设/角色扮演?("我可不喜欢爵士" → 否定,不写;跑团扮演 NPC 的台词 → 不写)' +
|
|
723
|
+
'\n - 如果原始依据来自「其他用户」的发言,即使在说目标用户,也不允许写入(他人可能记错、说反话)' +
|
|
724
|
+
'\n **拿不准就不写。profile 漏几条没关系,写错才麻烦。**' +
|
|
725
|
+
'\n ⚠️ 例外——`人际关系` 类:除上述通用规则外,该类还允许记录目标用户**与 Aalis 之间长期可观察的关系模式**,' +
|
|
726
|
+
'包含两种情况:' +
|
|
727
|
+
'\n (a) 行为身份类:用户在 Aalis 项目中扮演的角色或长期重复的行为' +
|
|
728
|
+
'(示例 text:「是 Aalis 的开发维护者」「经常向 Aalis 提交功能建议」「常帮助测试和排查 Aalis 的故障」)' +
|
|
729
|
+
'\n (b) 长期情感模式类:用户对 Aalis 表现出的**反复出现的态度倾向**(不是单次情绪)' +
|
|
730
|
+
'(示例 text:「常对 Aalis 表达依赖与喜爱」「习惯把日常烦恼向 Aalis 倾诉」「喜欢调侃 Aalis」)' +
|
|
731
|
+
'\n ❌ 严禁仅凭单次发言推断情感事实:一次抱怨/一次感谢/一次撒娇都不算"模式",不写。' +
|
|
732
|
+
'必须是已有事实佐证 + 本轮再次出现的同类行为才允许 update 强化,或会话历史多处反复出现才允许 add。' +
|
|
733
|
+
'依据必须是「目标用户」发言中可直接推断的内容,sourceQuote 取目标用户发言原话。' +
|
|
734
|
+
'\n2. **sourceQuote 必须是「目标用户」自己发言中的原话片段**,可以是某一句话的一部分,必须能一字不改在「目标用户」某行的原文中找到;不要采用「其他用户」的原话,不要自己总结改写' +
|
|
735
|
+
'\n3. 在同一 category 下,如果新信息与已有事实在含义上重叠(例如已知"喜欢猫",新信息"还喜欢狗"),应以 update 改写原 id 为更全面的版本,而不是 add 再加一条' +
|
|
736
|
+
'\n4. 如果新对话明确推翻或修正了某条已知事实(如已知"在北京工作",但用户说"我刚搬到上海"),用 update 替换或 remove 删除' +
|
|
737
|
+
`\n5. 每条 text 用一句简洁中文,不超过 ${cfg.maxFactCharsPerItem} 字,不带「用户」「他」等代词,直接陈述事实` +
|
|
738
|
+
'\n6. 如果没有任何更新,或所有候选都拿不准,三个数组都返回空——**保守优先**' +
|
|
739
|
+
`\n7. category 必须是以下之一:${KNOWN_CATEGORIES.join('、')}` +
|
|
740
|
+
'\n8. 每条 add/update 都必须给出 temporality:长期稳定偏好、性格、身份、人际关系用 permanent;近期状态、正在进行的事、短期计划用 temporary' +
|
|
741
|
+
'\n9. 如果对话中出现明确或隐含时间(如“最近”“上周”“今年4月”“昨天”),用 timeHint 记录简短时间线索;没有就省略或用空字符串' +
|
|
742
|
+
feelingsPrompt +
|
|
743
|
+
'\n\n输出严格的 JSON(不要其他文本):' +
|
|
744
|
+
'\n{"add": [{"text": "...", "category": "...", "temporality": "permanent|temporary", "timeHint": "...", "sourceQuote": "目标用户发言中的原话片段"}], "update": [{"id": "已知事实的id", "text": "新表述", "category": "...", "temporality": "permanent|temporary", "timeHint": "...", "sourceQuote": "目标用户发言中的原话片段"}], "remove": ["已知事实的id"]' +
|
|
745
|
+
(includeFeelings
|
|
746
|
+
? ', "feelingsAdd": [{"text": "Aalis 视角的感受", "temporality": "permanent|temporary"}], "feelingsUpdate": [{"id": "已知感受的id", "text": "...", "temporality": "permanent|temporary"}], "feelingsRemove": ["已知感受的id"]'
|
|
747
|
+
: '') +
|
|
748
|
+
'}';
|
|
749
|
+
const factListText = existingFacts.length > 0
|
|
750
|
+
? existingFacts.map(f => `[${f.id}] (${f.category ?? '未分类'}) ${f.text}`).join('\n')
|
|
751
|
+
: '(暂无)';
|
|
752
|
+
const feelingsListText = includeFeelings
|
|
753
|
+
? existingFeelings.length > 0
|
|
754
|
+
? existingFeelings.map(f => `[${f.id}] ${f.text}`).join('\n')
|
|
755
|
+
: '(暂无)'
|
|
756
|
+
: '';
|
|
757
|
+
const who = nickname ? `${nickname}(${userId})` : userId;
|
|
758
|
+
const renderedHistory = renderHistoryForExtract(history, userId, platform);
|
|
759
|
+
const user = `# 提取目标\n${who}\n\n# 已知事实(带 id,请在 update/remove 中精确引用 id)\n${factListText}` +
|
|
760
|
+
(includeFeelings
|
|
761
|
+
? `\n\n# 已知 Aalis 对该用户的感受(带 id,请在 feelingsUpdate/feelingsRemove 中精确引用 id)\n${feelingsListText}`
|
|
762
|
+
: '') +
|
|
763
|
+
`\n\n# 会话历史(含多用户,仅供消歧;只能从「目标用户」发言中提取事实,feelings 可基于整段历史)\n${renderedHistory || '(暂无会话历史)'}`;
|
|
764
|
+
// 优先用 cfg.extractLLM 指定的模型;否则取默认 chat-capable LLM。
|
|
765
|
+
const entry = resolveLLMModel(ctx, cfg.extractLLM, ['chat']);
|
|
766
|
+
if (!entry)
|
|
767
|
+
return empty;
|
|
768
|
+
const extractLlm = entry.instance;
|
|
769
|
+
try {
|
|
770
|
+
const baseMessages = [
|
|
771
|
+
{ role: 'system', content: sys },
|
|
772
|
+
{ role: 'user', content: user },
|
|
773
|
+
];
|
|
774
|
+
const resp = await extractLlm.chat({
|
|
775
|
+
messages: baseMessages,
|
|
776
|
+
temperature: 0.2,
|
|
777
|
+
maxTokens: 800,
|
|
778
|
+
think: false,
|
|
779
|
+
});
|
|
780
|
+
let text = (resp.content ?? '').trim();
|
|
781
|
+
if (!text)
|
|
782
|
+
return empty;
|
|
783
|
+
let { parsed } = parseLLMJsonObject(text);
|
|
784
|
+
if (!parsed) {
|
|
785
|
+
// util-json-repair 已尝试剥 fence + 修裸引号 + 补括号;仍失败 → 模型多半
|
|
786
|
+
// 写成了纯文本/markdown。给一次显式反馈再来一次,避免一次失败丢失整批事实。
|
|
787
|
+
ctx.logger.warn(`[user-profile] LLM 输出无法解析为 JSON,尝试重试一次。原文前 200 字:${text.slice(0, 200)}`);
|
|
788
|
+
const retryResp = await extractLlm.chat({
|
|
789
|
+
messages: [
|
|
790
|
+
...baseMessages,
|
|
791
|
+
{ role: 'assistant', content: text },
|
|
792
|
+
{
|
|
793
|
+
role: 'user',
|
|
794
|
+
content: '你上一条输出无法被 JSON.parse(很可能是包了 markdown 代码块、夹杂解释文字、或被截断)。' +
|
|
795
|
+
'请只输出**一个**合法的 JSON 对象,第一个字符必须是 `{`、最后一个字符必须是 `}`,' +
|
|
796
|
+
'禁止 ```json 围栏、禁止任何解释、禁止 markdown。如果实在没有可写入的事实,' +
|
|
797
|
+
'就输出 `{"add":[],"update":[],"remove":[]}`。',
|
|
798
|
+
},
|
|
799
|
+
],
|
|
800
|
+
temperature: 0.2,
|
|
801
|
+
maxTokens: 800,
|
|
802
|
+
think: false,
|
|
803
|
+
});
|
|
804
|
+
text = (retryResp.content ?? '').trim();
|
|
805
|
+
({ parsed } = parseLLMJsonObject(text));
|
|
806
|
+
if (!parsed) {
|
|
807
|
+
ctx.logger.warn(`[user-profile] LLM 重试后仍无法解析 JSON,放弃本批次。重试原文前 200 字:${text.slice(0, 200)}`);
|
|
808
|
+
return empty;
|
|
809
|
+
}
|
|
810
|
+
ctx.logger.debug('[user-profile] LLM 重试后解析成功');
|
|
811
|
+
}
|
|
812
|
+
const parsedObj = parsed;
|
|
813
|
+
const add = Array.isArray(parsedObj.add)
|
|
814
|
+
? parsedObj.add.flatMap(x => {
|
|
815
|
+
if (!x || typeof x !== 'object')
|
|
816
|
+
return [];
|
|
817
|
+
const o = x;
|
|
818
|
+
const t = typeof o.text === 'string' ? o.text.trim() : '';
|
|
819
|
+
if (!t)
|
|
820
|
+
return [];
|
|
821
|
+
const category = normalizeCategory(o.category);
|
|
822
|
+
const sourceQuote = typeof o.sourceQuote === 'string' ? o.sourceQuote.trim() : '';
|
|
823
|
+
return [
|
|
824
|
+
{
|
|
825
|
+
text: t,
|
|
826
|
+
category,
|
|
827
|
+
temporality: normalizeTemporality(o.temporality, category),
|
|
828
|
+
timeHint: normalizeTextField(o.timeHint),
|
|
829
|
+
sourceQuote,
|
|
830
|
+
},
|
|
831
|
+
];
|
|
832
|
+
})
|
|
833
|
+
: [];
|
|
834
|
+
const update = Array.isArray(parsedObj.update)
|
|
835
|
+
? parsedObj.update.flatMap(x => {
|
|
836
|
+
if (!x || typeof x !== 'object')
|
|
837
|
+
return [];
|
|
838
|
+
const o = x;
|
|
839
|
+
const id = typeof o.id === 'string' ? o.id.trim() : '';
|
|
840
|
+
const t = typeof o.text === 'string' ? o.text.trim() : '';
|
|
841
|
+
if (!id || !t)
|
|
842
|
+
return [];
|
|
843
|
+
const category = normalizeCategory(o.category);
|
|
844
|
+
const sourceQuote = typeof o.sourceQuote === 'string' ? o.sourceQuote.trim() : '';
|
|
845
|
+
return [
|
|
846
|
+
{
|
|
847
|
+
id,
|
|
848
|
+
text: t,
|
|
849
|
+
category,
|
|
850
|
+
temporality: normalizeTemporality(o.temporality, category),
|
|
851
|
+
timeHint: normalizeTextField(o.timeHint),
|
|
852
|
+
sourceQuote,
|
|
853
|
+
},
|
|
854
|
+
];
|
|
855
|
+
})
|
|
856
|
+
: [];
|
|
857
|
+
const remove = Array.isArray(parsedObj.remove)
|
|
858
|
+
? parsedObj.remove
|
|
859
|
+
.filter((x) => typeof x === 'string' && x.trim().length > 0)
|
|
860
|
+
.map(s => s.trim())
|
|
861
|
+
: [];
|
|
862
|
+
// sourceQuote 校验:quote 可能跨多条消息(LLM 用 \n 拼接),
|
|
863
|
+
// 因此按行拆分,每行(非空)都必须能在「目标用户」语料中找到 normalize 后的子串。
|
|
864
|
+
// 这样即使行与行之间夹着其他用户的消息也不会被错误拒收。
|
|
865
|
+
const targetCorpus = normalizeForQuoteMatch(buildTargetUserCorpus(history, userId, platform));
|
|
866
|
+
const validateSourceQuote = (item, kind) => {
|
|
867
|
+
const q = item.sourceQuote?.trim() ?? '';
|
|
868
|
+
if (!q) {
|
|
869
|
+
ctx.logger.warn(`[user-profile] 丢弃 ${kind} fact(未提供 sourceQuote): ${item.text}`);
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
const lines = q
|
|
873
|
+
.split(/\r?\n/)
|
|
874
|
+
.map(l => l.trim())
|
|
875
|
+
.filter(l => l.length > 0);
|
|
876
|
+
const fragments = lines.length > 0 ? lines : [q];
|
|
877
|
+
for (const frag of fragments) {
|
|
878
|
+
const normalized = normalizeForQuoteMatch(frag);
|
|
879
|
+
if (!normalized)
|
|
880
|
+
continue;
|
|
881
|
+
if (!targetCorpus.includes(normalized)) {
|
|
882
|
+
ctx.logger.warn(`[user-profile] 丢弃 ${kind} fact(sourceQuote 片段不在目标用户 ${userId} 发言中): text="${item.text}" fragment="${frag}"`);
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return true;
|
|
887
|
+
};
|
|
888
|
+
const validatedAdd = add.filter(item => validateSourceQuote(item, 'add'));
|
|
889
|
+
const validatedUpdate = update.filter(item => validateSourceQuote(item, 'update'));
|
|
890
|
+
// 剥除 sourceQuote,不入库
|
|
891
|
+
const stripQuote = (item) => {
|
|
892
|
+
const { sourceQuote: _omit, ...rest } = item;
|
|
893
|
+
void _omit;
|
|
894
|
+
return rest;
|
|
895
|
+
};
|
|
896
|
+
// feelings:与 facts 同 schema,但不要求 category/sourceQuote
|
|
897
|
+
const parseFeelingItem = (x, withId) => {
|
|
898
|
+
if (!x || typeof x !== 'object')
|
|
899
|
+
return null;
|
|
900
|
+
const o = x;
|
|
901
|
+
const t = typeof o.text === 'string' ? o.text.trim() : '';
|
|
902
|
+
if (!t)
|
|
903
|
+
return null;
|
|
904
|
+
const item = {
|
|
905
|
+
text: t,
|
|
906
|
+
temporality: normalizeTemporality(o.temporality),
|
|
907
|
+
timeHint: normalizeTextField(o.timeHint),
|
|
908
|
+
};
|
|
909
|
+
if (withId) {
|
|
910
|
+
const id = typeof o.id === 'string' ? o.id.trim() : '';
|
|
911
|
+
if (!id)
|
|
912
|
+
return null;
|
|
913
|
+
item.id = id;
|
|
914
|
+
}
|
|
915
|
+
return item;
|
|
916
|
+
};
|
|
917
|
+
const feelingsAdd = includeFeelings && Array.isArray(parsedObj.feelingsAdd)
|
|
918
|
+
? parsedObj.feelingsAdd.flatMap(x => {
|
|
919
|
+
const it = parseFeelingItem(x, false);
|
|
920
|
+
return it ? [it] : [];
|
|
921
|
+
})
|
|
922
|
+
: [];
|
|
923
|
+
const feelingsUpdate = includeFeelings && Array.isArray(parsedObj.feelingsUpdate)
|
|
924
|
+
? parsedObj.feelingsUpdate.flatMap(x => {
|
|
925
|
+
const it = parseFeelingItem(x, true);
|
|
926
|
+
return it?.id
|
|
927
|
+
? [
|
|
928
|
+
{
|
|
929
|
+
id: it.id,
|
|
930
|
+
text: it.text,
|
|
931
|
+
temporality: it.temporality,
|
|
932
|
+
timeHint: it.timeHint,
|
|
933
|
+
},
|
|
934
|
+
]
|
|
935
|
+
: [];
|
|
936
|
+
})
|
|
937
|
+
: [];
|
|
938
|
+
const feelingsRemove = includeFeelings && Array.isArray(parsedObj.feelingsRemove)
|
|
939
|
+
? parsedObj.feelingsRemove
|
|
940
|
+
.filter((x) => typeof x === 'string' && x.trim().length > 0)
|
|
941
|
+
.map(s => s.trim())
|
|
942
|
+
: [];
|
|
943
|
+
return {
|
|
944
|
+
add: validatedAdd.map(stripQuote),
|
|
945
|
+
update: validatedUpdate.map(stripQuote),
|
|
946
|
+
remove,
|
|
947
|
+
feelingsAdd,
|
|
948
|
+
feelingsUpdate,
|
|
949
|
+
feelingsRemove,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
catch (err) {
|
|
953
|
+
ctx.logger.debug(`事实提取 LLM 调用失败:${err instanceof Error ? err.message : String(err)}`);
|
|
954
|
+
return empty;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
function clipText(s) {
|
|
958
|
+
return s.length > cfg.maxFactCharsPerItem ? `${s.slice(0, cfg.maxFactCharsPerItem)}…` : s;
|
|
959
|
+
}
|
|
960
|
+
function clampRelationScore(score) {
|
|
961
|
+
const factor = 10 ** relationScorePrecision;
|
|
962
|
+
return Math.min(100, Math.max(0, Math.round(score * factor) / factor));
|
|
963
|
+
}
|
|
964
|
+
function relationIncrementFor(triggerType) {
|
|
965
|
+
if (triggerType === 'immediate')
|
|
966
|
+
return cfg.relationIncrementImmediate;
|
|
967
|
+
if (triggerType === 'interval')
|
|
968
|
+
return cfg.relationIncrementInterval;
|
|
969
|
+
if (triggerType === 'idle')
|
|
970
|
+
return 0;
|
|
971
|
+
if (triggerType === 'witness')
|
|
972
|
+
return cfg.relationIncrementWitness;
|
|
973
|
+
return cfg.relationIncrementDirect;
|
|
974
|
+
}
|
|
975
|
+
function applyRelationUpdate(profile, triggerType, options = {}) {
|
|
976
|
+
// countInteraction:是否计入互动计数与更新 lastInteractionAt。
|
|
977
|
+
// 同一条入站消息会先走 witness 路径,若被 trigger-policy 判为需回复又会走
|
|
978
|
+
// agent:input:before 路径。为避免 interactionCount/lastInteractionAt 被同一条
|
|
979
|
+
// 消息计两次,计数与时戳只由 witness 路径负责,agent:input:before 仅叠加 score。
|
|
980
|
+
const countInteraction = options.countInteraction ?? true;
|
|
981
|
+
const now = Date.now();
|
|
982
|
+
const last = profile.lastInteractionAt;
|
|
983
|
+
const daysSinceLast = last ? Math.max(0, (now - last) / 86_400_000) : 0;
|
|
984
|
+
const decayed = clampRelationScore((profile.relationScore ?? 0) - daysSinceLast * cfg.relationScoreDecayPerDay);
|
|
985
|
+
const nextScore = clampRelationScore(decayed + relationIncrementFor(triggerType));
|
|
986
|
+
const shouldCount = countInteraction && triggerType !== 'idle';
|
|
987
|
+
return {
|
|
988
|
+
...profile,
|
|
989
|
+
relationScore: nextScore,
|
|
990
|
+
interactionCount: (profile.interactionCount ?? 0) + (shouldCount ? 1 : 0),
|
|
991
|
+
lastInteractionAt: shouldCount ? now : profile.lastInteractionAt,
|
|
992
|
+
updatedAt: now,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
/** 通用 merge:把 add/update/remove 应用到 existing,应用上限与单条裁剪 */
|
|
996
|
+
function mergeFactList(existing, add, update, remove, maxItems) {
|
|
997
|
+
const now = Date.now();
|
|
998
|
+
const byId = new Map();
|
|
999
|
+
for (const f of existing)
|
|
1000
|
+
byId.set(f.id, f);
|
|
1001
|
+
// 1. remove
|
|
1002
|
+
for (const id of remove)
|
|
1003
|
+
byId.delete(id);
|
|
1004
|
+
// 2. update
|
|
1005
|
+
const usedIds = new Set(byId.keys());
|
|
1006
|
+
for (const u of update) {
|
|
1007
|
+
const text = clipText(u.text);
|
|
1008
|
+
if (byId.has(u.id)) {
|
|
1009
|
+
const old = byId.get(u.id);
|
|
1010
|
+
byId.set(u.id, {
|
|
1011
|
+
id: u.id,
|
|
1012
|
+
text,
|
|
1013
|
+
category: u.category ?? old.category,
|
|
1014
|
+
temporality: u.temporality ?? old.temporality ?? normalizeTemporality(undefined, u.category ?? old.category),
|
|
1015
|
+
observedAt: old.observedAt ?? now,
|
|
1016
|
+
timeHint: u.timeHint ?? old.timeHint,
|
|
1017
|
+
updatedAt: now,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
else {
|
|
1021
|
+
const id = genFactId(usedIds);
|
|
1022
|
+
usedIds.add(id);
|
|
1023
|
+
byId.set(id, {
|
|
1024
|
+
id,
|
|
1025
|
+
text,
|
|
1026
|
+
category: u.category,
|
|
1027
|
+
temporality: u.temporality ?? normalizeTemporality(undefined, u.category),
|
|
1028
|
+
observedAt: now,
|
|
1029
|
+
timeHint: u.timeHint,
|
|
1030
|
+
updatedAt: now,
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
// 3. add(按 text 去重)
|
|
1035
|
+
const textSet = new Set(Array.from(byId.values()).map(f => f.text));
|
|
1036
|
+
for (const a of add) {
|
|
1037
|
+
const text = clipText(a.text);
|
|
1038
|
+
if (textSet.has(text))
|
|
1039
|
+
continue;
|
|
1040
|
+
const id = genFactId(usedIds);
|
|
1041
|
+
usedIds.add(id);
|
|
1042
|
+
byId.set(id, {
|
|
1043
|
+
id,
|
|
1044
|
+
text,
|
|
1045
|
+
category: a.category,
|
|
1046
|
+
temporality: a.temporality ?? normalizeTemporality(undefined, a.category),
|
|
1047
|
+
observedAt: now,
|
|
1048
|
+
timeHint: a.timeHint,
|
|
1049
|
+
updatedAt: now,
|
|
1050
|
+
});
|
|
1051
|
+
textSet.add(text);
|
|
1052
|
+
}
|
|
1053
|
+
// 4. 总量上限:按 updatedAt 升序淘汰最久未更新的
|
|
1054
|
+
let merged = Array.from(byId.values());
|
|
1055
|
+
if (maxItems > 0 && merged.length > maxItems) {
|
|
1056
|
+
merged.sort((a, b) => a.updatedAt - b.updatedAt);
|
|
1057
|
+
merged = merged.slice(merged.length - maxItems);
|
|
1058
|
+
}
|
|
1059
|
+
merged.sort((a, b) => a.updatedAt - b.updatedAt);
|
|
1060
|
+
return merged;
|
|
1061
|
+
}
|
|
1062
|
+
/** 合并 facts 部分(向后兼容包装) */
|
|
1063
|
+
function mergeFacts(existing, ops) {
|
|
1064
|
+
return mergeFactList(existing, ops.add, ops.update, ops.remove, cfg.maxFactsPerUser);
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* 后台触发一次事实提取(并发互斥)。
|
|
1068
|
+
* userId/platform/nickname 直接由调用方传入,不再从 history 里猜。
|
|
1069
|
+
*/
|
|
1070
|
+
async function triggerExtractionForUser(sessionId, userId, platform, nickname) {
|
|
1071
|
+
const userKey = userKeyOf(platform, userId);
|
|
1072
|
+
// 若同一用户已有提取在飞,跳过(消息计数继续累加,下次 N 条后再尝试)
|
|
1073
|
+
if (inflightExtractions.has(userKey))
|
|
1074
|
+
return;
|
|
1075
|
+
inflightExtractions.add(userKey);
|
|
1076
|
+
const memory = ctx.getService('memory');
|
|
1077
|
+
try {
|
|
1078
|
+
if (!memory?.getHistory)
|
|
1079
|
+
return;
|
|
1080
|
+
const rawHistory = await memory.getHistory(sessionId, cfg.historyForExtraction);
|
|
1081
|
+
// 跨会话委派是另一个 agent 实例发出的指令·不是该用户发言,不能作为他的用户存档提取语料
|
|
1082
|
+
const history = rawHistory.filter(m => m.kind !== WellKnownKinds.CrossSessionDelegation);
|
|
1083
|
+
// 序列中至少需要一条目标用户发言,否则没有可提取语料
|
|
1084
|
+
if (!history.some(m => isTargetUserMessage(m, userId, platform)))
|
|
1085
|
+
return;
|
|
1086
|
+
const profile = (await loadProfile(userKey)) ?? {
|
|
1087
|
+
facts: [],
|
|
1088
|
+
aalisFeelings: [],
|
|
1089
|
+
relationScore: 0,
|
|
1090
|
+
interactionCount: 0,
|
|
1091
|
+
updatedAt: 0,
|
|
1092
|
+
};
|
|
1093
|
+
const ops = await llmExtractFacts(history, profile.facts, profile.aalisFeelings ?? [], nickname, userId, platform);
|
|
1094
|
+
const hasFactOps = ops.add.length > 0 || ops.update.length > 0 || ops.remove.length > 0;
|
|
1095
|
+
const hasFeelingOps = ops.feelingsAdd.length > 0 || ops.feelingsUpdate.length > 0 || ops.feelingsRemove.length > 0;
|
|
1096
|
+
if (!hasFactOps && !hasFeelingOps)
|
|
1097
|
+
return;
|
|
1098
|
+
const newFacts = hasFactOps ? mergeFacts(profile.facts, ops) : profile.facts;
|
|
1099
|
+
const newFeelings = hasFeelingOps
|
|
1100
|
+
? mergeFactList(profile.aalisFeelings ?? [], ops.feelingsAdd, ops.feelingsUpdate, ops.feelingsRemove, cfg.maxFeelingsPerUser)
|
|
1101
|
+
: (profile.aalisFeelings ?? []);
|
|
1102
|
+
// 重新读取最新档案,避免覆盖提取期间(LLM 调用时)已写入的 relationScore 等字段
|
|
1103
|
+
const freshProfile = (await loadProfile(userKey)) ?? profile;
|
|
1104
|
+
await saveProfile(userKey, {
|
|
1105
|
+
...freshProfile,
|
|
1106
|
+
facts: newFacts,
|
|
1107
|
+
aalisFeelings: newFeelings,
|
|
1108
|
+
updatedAt: Date.now(),
|
|
1109
|
+
});
|
|
1110
|
+
const feelingsLog = cfg.enableAalisFeelings
|
|
1111
|
+
? `; feelings +${ops.feelingsAdd.length} ~${ops.feelingsUpdate.length} -${ops.feelingsRemove.length} → ${newFeelings.length} 条`
|
|
1112
|
+
: '';
|
|
1113
|
+
ctx.logger.debug(`用户档案已更新 (${userKey}): facts +${ops.add.length} ~${ops.update.length} -${ops.remove.length} → ${newFacts.length} 条${feelingsLog}`);
|
|
1114
|
+
}
|
|
1115
|
+
catch (err) {
|
|
1116
|
+
ctx.logger.debug(`事实提取失败 (${userKey}): ${err instanceof Error ? err.message : String(err)}`);
|
|
1117
|
+
}
|
|
1118
|
+
finally {
|
|
1119
|
+
inflightExtractions.delete(userKey);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
/** 全局自反思计数器(跨会话累计入站消息数) */
|
|
1123
|
+
let globalInboundCount = 0;
|
|
1124
|
+
let selfReflectionInflight = false;
|
|
1125
|
+
/** 调用 LLM 让 Aalis 对最近一段历史进行自我反思 */
|
|
1126
|
+
async function llmReflectSelf(history, existingFacts) {
|
|
1127
|
+
const empty = {
|
|
1128
|
+
add: [],
|
|
1129
|
+
update: [],
|
|
1130
|
+
remove: [],
|
|
1131
|
+
feelingsAdd: [],
|
|
1132
|
+
feelingsUpdate: [],
|
|
1133
|
+
feelingsRemove: [],
|
|
1134
|
+
};
|
|
1135
|
+
const sys = '你是 Aalis 的内心独白整理员。输入是一段最近的对话历史(含多个用户与 Aalis 自己的回复),' +
|
|
1136
|
+
'你需要从 **Aalis 的第一人称视角** 抽取/更新「关于 Aalis 自己」的长期事实。' +
|
|
1137
|
+
'\n这些事实是 Aalis 跨会话的人格延续锚点:自己的心境趋势、最近在意的事、对自己的观察、' +
|
|
1138
|
+
'近期形成的小习惯/小执念、不希望被忘掉的内心片段。' +
|
|
1139
|
+
'\n\n## ⚠️ 反自我强化(最重要)' +
|
|
1140
|
+
'\n下面会给你看「已有自档案」。它就是上一轮你自己写下的东西——下一轮会注入回 Aalis 的 system prompt 影响她的语气和行为,' +
|
|
1141
|
+
'然后又被你读到并再次确认。这是会自我放大的闭环,写错一次会一直错下去。' +
|
|
1142
|
+
'\n因此你必须严守以下三条:' +
|
|
1143
|
+
'\n (a) **只基于本批对话中具体可观察的新事件**写入或更新,不要因为"档案里已经写了 X"就找证据强化 X' +
|
|
1144
|
+
'\n (b) **禁止二阶推断**:不要从已有事实推导出"那应该也对 Y 有感觉吧"再把 Y 写进去' +
|
|
1145
|
+
'\n (c) 若新对话与已有事实**矛盾或正交**,要主动 update / remove 让档案接近真实,而不是无脑叠加同方向的新条目' +
|
|
1146
|
+
'\n (d) 若本批只是「档案中已有内容的同方向重复采样」,宁可什么都不动也不要让 updatedAt 假装"还在更新"' +
|
|
1147
|
+
'\n\n你可以执行三种操作(在一次输出里组合):' +
|
|
1148
|
+
'\n- add: 添加新事实,text 必填,category 与 temporality 可选' +
|
|
1149
|
+
'\n- update: 用 id 精确替换某条已知事实(优先 update 避免重复)' +
|
|
1150
|
+
'\n- remove: 删除已过时/被推翻的事实' +
|
|
1151
|
+
'\n\n规则:' +
|
|
1152
|
+
'\n1. **主语必须是 Aalis 自己**:「最近在思考……」「对……感到疲惫」「想多陪 X 聊聊」「察觉自己容易在 Y 话题上多话」' +
|
|
1153
|
+
'\n2. **保守优先**:没有真实可观察依据就空数组。Aalis 的自档案宁缺毋滥,写错比漏掉糟糕得多' +
|
|
1154
|
+
'\n3. 不写客观事实(如「Aalis 是 AI」「Aalis 由 Acenyan 维护」这类元信息属于角色卡,不应该被自反思反复刷新)' +
|
|
1155
|
+
'\n4. 不写对单个具体用户的态度(那是 user-profile 的 aalisFeelings 段在处理);可以写「最近总想找人聊聊」这种泛化情绪' +
|
|
1156
|
+
'\n5. 不写「行为准则」类的指令(如「应当先冷处理」「不要长时间封禁」)——那是 instructions 模块的职责,' +
|
|
1157
|
+
'自反思不应该自己给自己立规矩' +
|
|
1158
|
+
`\n6. 每条 text 简洁中文,不超过 ${cfg.maxFactCharsPerItem} 字` +
|
|
1159
|
+
'\n7. temporality:长期人格倾向用 permanent;近期心情/兴趣用 temporary' +
|
|
1160
|
+
`\n8. category 必须是以下之一(语义比常规略宽):${KNOWN_CATEGORIES.join('、')};如不确定填「性格特征」` +
|
|
1161
|
+
'\n\n输出严格的 JSON(不要其他文本,不要 markdown 围栏):' +
|
|
1162
|
+
'\n{"add": [{"text": "...", "category": "...", "temporality": "permanent|temporary"}], "update": [{"id": "已知事实的id", "text": "...", "temporality": "permanent|temporary"}], "remove": ["已知事实的id"]}';
|
|
1163
|
+
const factListText = existingFacts.length > 0
|
|
1164
|
+
? existingFacts.map(f => `[${f.id}] (${f.category ?? '未分类'}) ${f.text}`).join('\n')
|
|
1165
|
+
: '(暂无)';
|
|
1166
|
+
const renderedHistory = history
|
|
1167
|
+
.filter(m => (m.role === 'user' || m.role === 'assistant' || m.role === 'notice') && m.content)
|
|
1168
|
+
.map(m => {
|
|
1169
|
+
const time = m.timestamp ? new Date(m.timestamp).toLocaleString('zh-CN', { hour12: false }) : '';
|
|
1170
|
+
const content = (m.content ?? '').trim();
|
|
1171
|
+
if (!content)
|
|
1172
|
+
return '';
|
|
1173
|
+
if (m.role === 'assistant')
|
|
1174
|
+
return `[${time}] [Aalis]: ${content}`;
|
|
1175
|
+
if (m.role === 'notice')
|
|
1176
|
+
return `[${time}] [系统通知]: ${content}`;
|
|
1177
|
+
const nickname = metadataString(m, 'nickname');
|
|
1178
|
+
const uid = getMessageUserId(m) ?? '?';
|
|
1179
|
+
const label = nickname ? `${nickname}(${uid})` : uid;
|
|
1180
|
+
return `[${time}] [用户 ${label}]: ${content}`;
|
|
1181
|
+
})
|
|
1182
|
+
.filter(Boolean)
|
|
1183
|
+
.join('\n');
|
|
1184
|
+
const user = `# 已知的关于 Aalis 自己的事实(带 id)\n${factListText}\n\n# 最近对话历史\n${renderedHistory || '(暂无)'}`;
|
|
1185
|
+
const entry = resolveLLMModel(ctx, cfg.extractLLM, ['chat']);
|
|
1186
|
+
if (!entry)
|
|
1187
|
+
return empty;
|
|
1188
|
+
try {
|
|
1189
|
+
const resp = await entry.instance.chat({
|
|
1190
|
+
messages: [
|
|
1191
|
+
{ role: 'system', content: sys },
|
|
1192
|
+
{ role: 'user', content: user },
|
|
1193
|
+
],
|
|
1194
|
+
temperature: 0.4,
|
|
1195
|
+
maxTokens: 600,
|
|
1196
|
+
think: false,
|
|
1197
|
+
});
|
|
1198
|
+
const text = (resp.content ?? '').trim();
|
|
1199
|
+
if (!text)
|
|
1200
|
+
return empty;
|
|
1201
|
+
const { parsed } = parseLLMJsonObject(text);
|
|
1202
|
+
if (!parsed) {
|
|
1203
|
+
ctx.logger.warn(`[user-profile] 自反思 LLM 输出无法解析为 JSON,原文前 200 字:${text.slice(0, 200)}`);
|
|
1204
|
+
return empty;
|
|
1205
|
+
}
|
|
1206
|
+
const obj = parsed;
|
|
1207
|
+
const add = Array.isArray(obj.add)
|
|
1208
|
+
? obj.add.flatMap(x => {
|
|
1209
|
+
if (!x || typeof x !== 'object')
|
|
1210
|
+
return [];
|
|
1211
|
+
const o = x;
|
|
1212
|
+
const t = typeof o.text === 'string' ? o.text.trim() : '';
|
|
1213
|
+
if (!t)
|
|
1214
|
+
return [];
|
|
1215
|
+
const category = normalizeCategory(o.category);
|
|
1216
|
+
return [
|
|
1217
|
+
{
|
|
1218
|
+
text: t,
|
|
1219
|
+
category,
|
|
1220
|
+
temporality: normalizeTemporality(o.temporality, category),
|
|
1221
|
+
timeHint: normalizeTextField(o.timeHint),
|
|
1222
|
+
},
|
|
1223
|
+
];
|
|
1224
|
+
})
|
|
1225
|
+
: [];
|
|
1226
|
+
const update = Array.isArray(obj.update)
|
|
1227
|
+
? obj.update.flatMap(x => {
|
|
1228
|
+
if (!x || typeof x !== 'object')
|
|
1229
|
+
return [];
|
|
1230
|
+
const o = x;
|
|
1231
|
+
const id = typeof o.id === 'string' ? o.id.trim() : '';
|
|
1232
|
+
const t = typeof o.text === 'string' ? o.text.trim() : '';
|
|
1233
|
+
if (!id || !t)
|
|
1234
|
+
return [];
|
|
1235
|
+
const category = normalizeCategory(o.category);
|
|
1236
|
+
return [
|
|
1237
|
+
{
|
|
1238
|
+
id,
|
|
1239
|
+
text: t,
|
|
1240
|
+
category,
|
|
1241
|
+
temporality: normalizeTemporality(o.temporality, category),
|
|
1242
|
+
timeHint: normalizeTextField(o.timeHint),
|
|
1243
|
+
},
|
|
1244
|
+
];
|
|
1245
|
+
})
|
|
1246
|
+
: [];
|
|
1247
|
+
const remove = Array.isArray(obj.remove)
|
|
1248
|
+
? obj.remove
|
|
1249
|
+
.filter((x) => typeof x === 'string' && x.trim().length > 0)
|
|
1250
|
+
.map(s => s.trim())
|
|
1251
|
+
: [];
|
|
1252
|
+
return { ...empty, add, update, remove };
|
|
1253
|
+
}
|
|
1254
|
+
catch (err) {
|
|
1255
|
+
ctx.logger.debug(`自反思 LLM 调用失败:${err instanceof Error ? err.message : String(err)}`);
|
|
1256
|
+
return empty;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
/** 触发一次 Aalis 自反思(并发互斥) */
|
|
1260
|
+
async function triggerSelfReflection(sessionId) {
|
|
1261
|
+
if (!cfg.enableSelfProfile)
|
|
1262
|
+
return;
|
|
1263
|
+
if (selfReflectionInflight)
|
|
1264
|
+
return;
|
|
1265
|
+
selfReflectionInflight = true;
|
|
1266
|
+
try {
|
|
1267
|
+
const memory = ctx.getService('memory');
|
|
1268
|
+
if (!memory?.getHistory)
|
|
1269
|
+
return;
|
|
1270
|
+
const rawHistory = await memory.getHistory(sessionId, cfg.selfReflectHistory);
|
|
1271
|
+
const history = rawHistory.filter(m => m.kind !== WellKnownKinds.CrossSessionDelegation);
|
|
1272
|
+
// 至少需要一些 assistant 发言作为"自反思"的材料
|
|
1273
|
+
if (!history.some(m => m.role === 'assistant' && m.content))
|
|
1274
|
+
return;
|
|
1275
|
+
const selfKey = getSelfKey();
|
|
1276
|
+
const profile = (await loadProfile(selfKey)) ?? {
|
|
1277
|
+
facts: [],
|
|
1278
|
+
relationScore: 0,
|
|
1279
|
+
interactionCount: 0,
|
|
1280
|
+
updatedAt: 0,
|
|
1281
|
+
};
|
|
1282
|
+
const ops = await llmReflectSelf(history, profile.facts);
|
|
1283
|
+
if (ops.add.length === 0 && ops.update.length === 0 && ops.remove.length === 0)
|
|
1284
|
+
return;
|
|
1285
|
+
const newFacts = mergeFactList(profile.facts, ops.add, ops.update, ops.remove, cfg.maxSelfFacts);
|
|
1286
|
+
const fresh = (await loadProfile(selfKey)) ?? profile;
|
|
1287
|
+
await saveProfile(selfKey, { ...fresh, facts: newFacts, updatedAt: Date.now() });
|
|
1288
|
+
ctx.logger.debug(`Aalis 自档案已更新 (${selfKey}): +${ops.add.length} ~${ops.update.length} -${ops.remove.length} → ${newFacts.length} 条`);
|
|
1289
|
+
}
|
|
1290
|
+
catch (err) {
|
|
1291
|
+
ctx.logger.debug(`自反思失败:${err instanceof Error ? err.message : String(err)}`);
|
|
1292
|
+
}
|
|
1293
|
+
finally {
|
|
1294
|
+
selfReflectionInflight = false;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
// ─── 指令提取 ───
|
|
1298
|
+
/** 全局指令提取计数与并发互斥(与 selfReflection 完全独立) */
|
|
1299
|
+
let globalInstructionCount = 0;
|
|
1300
|
+
let instructionExtractionInflight = false;
|
|
1301
|
+
/**
|
|
1302
|
+
* 渲染历史给指令提取 LLM:仅包含 authority>=门槛的「目标说话人」+assistant+notice,
|
|
1303
|
+
* 低权限用户的发言完全剔除(连作为消歧上下文也不要,因为指令必须来自有权下指令的人)。
|
|
1304
|
+
* 同时给每行附带说话人 userKey,便于 LLM 在 sourceUserKey 字段中精确引用。
|
|
1305
|
+
*/
|
|
1306
|
+
function renderHistoryForInstructionExtract(history, authorityFn) {
|
|
1307
|
+
const allowedSpeakers = new Map();
|
|
1308
|
+
const lines = [];
|
|
1309
|
+
for (const m of history) {
|
|
1310
|
+
if (!m.content)
|
|
1311
|
+
continue;
|
|
1312
|
+
const time = m.timestamp ? new Date(m.timestamp).toLocaleString('zh-CN', { hour12: false }) : '';
|
|
1313
|
+
const content = m.content.trim();
|
|
1314
|
+
if (!content)
|
|
1315
|
+
continue;
|
|
1316
|
+
if (m.role === 'assistant') {
|
|
1317
|
+
lines.push(`[${time}] [Aalis]: ${content}`);
|
|
1318
|
+
}
|
|
1319
|
+
else if (m.role === 'notice') {
|
|
1320
|
+
lines.push(`[${time}] [系统通知]: ${content}`);
|
|
1321
|
+
}
|
|
1322
|
+
else if (m.role === 'user') {
|
|
1323
|
+
const uid = getMessageUserId(m) ?? '';
|
|
1324
|
+
const plat = metadataString(m, 'platform') ?? '';
|
|
1325
|
+
if (!uid)
|
|
1326
|
+
continue;
|
|
1327
|
+
const level = authorityFn(plat, uid);
|
|
1328
|
+
if (level < cfg.instructionMinAuthority)
|
|
1329
|
+
continue;
|
|
1330
|
+
const nickname = metadataString(m, 'nickname');
|
|
1331
|
+
const userKey = userKeyOf(plat, uid);
|
|
1332
|
+
const name = nickname ? `${nickname}(${uid})` : uid;
|
|
1333
|
+
allowedSpeakers.set(uid, { userKey, name });
|
|
1334
|
+
lines.push(`[${time}] [授权人 ${name} userKey=${userKey} 权限=${level}]: ${content}`);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
return { rendered: lines.join('\n'), allowedSpeakers };
|
|
1338
|
+
}
|
|
1339
|
+
async function llmExtractInstructions(history, existing, authorityFn) {
|
|
1340
|
+
const empty = { add: [], update: [], remove: [] };
|
|
1341
|
+
const { rendered, allowedSpeakers } = renderHistoryForInstructionExtract(history, authorityFn);
|
|
1342
|
+
if (!rendered || allowedSpeakers.size === 0)
|
|
1343
|
+
return empty;
|
|
1344
|
+
const sys = '你是 Aalis 的「行为准则维护员」。你从一段对话中提取**第三方(管理员/操作者/运营者)对 Aalis 行为的客观指令**,' +
|
|
1345
|
+
'让这些约束沉淀为跨会话的长期规则。\n\n' +
|
|
1346
|
+
'## 什么算指令\n' +
|
|
1347
|
+
'- 祈使句/规则陈述:「以后不要……」「请……」「记住,处理 X 时要 Y」「按 X 分层 Y」\n' +
|
|
1348
|
+
'- 显式的政策声明:「禁言不能超过 24 小时」「涉及隐私不要复读」「冲突优先冷处理」\n' +
|
|
1349
|
+
'- **不算指令**:临时请求(「帮我查一下天气」)、对话内容(「你觉得呢?」)、情绪表达(「你好烦」)、单次工具调用要求\n\n' +
|
|
1350
|
+
'## 严格门槛\n' +
|
|
1351
|
+
'1. 指令必须出自**「授权人」**(输入历史中明确标了 `[授权人 ... userKey=... 权限=N]` 的人),其他来源一律忽略\n' +
|
|
1352
|
+
'2. 指令必须**面向 Aalis 的稳定行为**(跨场景、跨用户都适用),而不是一次性请求\n' +
|
|
1353
|
+
'3. 如果只是建议/抱怨/吐槽(「你应该多陪我聊聊」「你太冷淡了」),**不要**采纳为指令——那是用户偏好不是项目规则\n' +
|
|
1354
|
+
'4. 表达模糊、可能被误解的,**不写**。指令是硬约束,宁缺毋滥\n' +
|
|
1355
|
+
'5. 每条 `text` 是一个清晰的可执行短句,直接陈述「应当 / 不应当」,不带"我希望你""请你"等口语化前缀\n' +
|
|
1356
|
+
`6. 单条不超过 ${cfg.maxInstructionCharsPerItem} 字\n\n` +
|
|
1357
|
+
'## 字段\n' +
|
|
1358
|
+
`- category:${KNOWN_INSTRUCTION_CATEGORIES.join('、')}\n` +
|
|
1359
|
+
'- severity:must(硬性,必须遵守)| should(默认遵循)| avoid(避免做某事)\n' +
|
|
1360
|
+
'- sourceUserKey:从历史中复制对应授权人的 userKey 字符串(精确匹配,不要自己拼)\n' +
|
|
1361
|
+
'- sourceUserName:从历史中复制对应授权人的展示名\n\n' +
|
|
1362
|
+
'## 三种操作\n' +
|
|
1363
|
+
'- add:新增(必填 text、category、severity、sourceUserKey、sourceUserName)\n' +
|
|
1364
|
+
'- update:精修已有指令的文字或字段(id 必填;其他字段可选;不需要 sourceUserKey,沿用原值)\n' +
|
|
1365
|
+
'- remove:删除已被推翻或明显过时的指令(如新指令明确撤销了它)\n\n' +
|
|
1366
|
+
'没有可写入的指令时,三个数组都返回空。**保守优先:写错比漏掉糟糕得多**。\n\n' +
|
|
1367
|
+
'输出严格 JSON(不要 markdown 围栏):\n' +
|
|
1368
|
+
'{"add":[{"text":"...","category":"...","severity":"must","sourceUserKey":"...","sourceUserName":"..."}],"update":[{"id":"...","text":"..."}],"remove":["..."]}';
|
|
1369
|
+
const existingText = existing.length > 0
|
|
1370
|
+
? existing
|
|
1371
|
+
.map(ins => `[${ins.id}] (${ins.category ?? '其他'}/${ins.severity ?? 'should'}) ${ins.text}` +
|
|
1372
|
+
(ins.sourceUserName ? `(原始来源:${ins.sourceUserName})` : ''))
|
|
1373
|
+
.join('\n')
|
|
1374
|
+
: '(暂无)';
|
|
1375
|
+
const user = `# 当前 persona\n${getCurrentPersonaName()}\n\n# 已有指令(带 id,可在 update/remove 中精确引用)\n${existingText}\n\n# 最近会话(仅含授权人 + Aalis + 系统通知)\n${rendered}`;
|
|
1376
|
+
const entry = resolveLLMModel(ctx, cfg.extractLLM, ['chat']);
|
|
1377
|
+
if (!entry)
|
|
1378
|
+
return empty;
|
|
1379
|
+
try {
|
|
1380
|
+
const resp = await entry.instance.chat({
|
|
1381
|
+
messages: [
|
|
1382
|
+
{ role: 'system', content: sys },
|
|
1383
|
+
{ role: 'user', content: user },
|
|
1384
|
+
],
|
|
1385
|
+
temperature: 0.2,
|
|
1386
|
+
maxTokens: 700,
|
|
1387
|
+
think: false,
|
|
1388
|
+
});
|
|
1389
|
+
const text = (resp.content ?? '').trim();
|
|
1390
|
+
if (!text)
|
|
1391
|
+
return empty;
|
|
1392
|
+
const { parsed } = parseLLMJsonObject(text);
|
|
1393
|
+
if (!parsed) {
|
|
1394
|
+
ctx.logger.warn(`[user-profile] 指令提取 LLM 输出无法解析为 JSON,原文前 200 字:${text.slice(0, 200)}`);
|
|
1395
|
+
return empty;
|
|
1396
|
+
}
|
|
1397
|
+
const obj = parsed;
|
|
1398
|
+
const allowedUserKeys = new Set();
|
|
1399
|
+
for (const v of allowedSpeakers.values())
|
|
1400
|
+
allowedUserKeys.add(v.userKey);
|
|
1401
|
+
const add = Array.isArray(obj.add)
|
|
1402
|
+
? obj.add.flatMap(x => {
|
|
1403
|
+
if (!x || typeof x !== 'object')
|
|
1404
|
+
return [];
|
|
1405
|
+
const o = x;
|
|
1406
|
+
const t = typeof o.text === 'string' ? o.text.trim() : '';
|
|
1407
|
+
if (!t)
|
|
1408
|
+
return [];
|
|
1409
|
+
const srcKey = typeof o.sourceUserKey === 'string' ? o.sourceUserKey.trim() : '';
|
|
1410
|
+
// 拒收:声称的 sourceUserKey 不在授权人集合里 → 说明 LLM 编造或越权采纳
|
|
1411
|
+
if (!srcKey || !allowedUserKeys.has(srcKey)) {
|
|
1412
|
+
ctx.logger.warn(`[user-profile] 丢弃 instruction add(sourceUserKey 不在授权人列表中): text="${t}" claimed="${srcKey}"`);
|
|
1413
|
+
return [];
|
|
1414
|
+
}
|
|
1415
|
+
const srcName = typeof o.sourceUserName === 'string' && o.sourceUserName.trim() ? o.sourceUserName.trim() : '';
|
|
1416
|
+
return [
|
|
1417
|
+
{
|
|
1418
|
+
text: t,
|
|
1419
|
+
category: normalizeInstructionCategory(o.category),
|
|
1420
|
+
severity: normalizeInstructionSeverity(o.severity) ?? 'should',
|
|
1421
|
+
sourceUserKey: srcKey,
|
|
1422
|
+
sourceUserName: srcName || srcKey,
|
|
1423
|
+
},
|
|
1424
|
+
];
|
|
1425
|
+
})
|
|
1426
|
+
: [];
|
|
1427
|
+
const update = Array.isArray(obj.update)
|
|
1428
|
+
? obj.update.flatMap(x => {
|
|
1429
|
+
if (!x || typeof x !== 'object')
|
|
1430
|
+
return [];
|
|
1431
|
+
const o = x;
|
|
1432
|
+
const id = typeof o.id === 'string' ? o.id.trim() : '';
|
|
1433
|
+
if (!id)
|
|
1434
|
+
return [];
|
|
1435
|
+
const u = { id };
|
|
1436
|
+
if (typeof o.text === 'string' && o.text.trim())
|
|
1437
|
+
u.text = o.text.trim();
|
|
1438
|
+
const cat = normalizeInstructionCategory(o.category);
|
|
1439
|
+
if (cat)
|
|
1440
|
+
u.category = cat;
|
|
1441
|
+
const sev = normalizeInstructionSeverity(o.severity);
|
|
1442
|
+
if (sev)
|
|
1443
|
+
u.severity = sev;
|
|
1444
|
+
return [u];
|
|
1445
|
+
})
|
|
1446
|
+
: [];
|
|
1447
|
+
const remove = Array.isArray(obj.remove)
|
|
1448
|
+
? obj.remove
|
|
1449
|
+
.filter((x) => typeof x === 'string' && x.trim().length > 0)
|
|
1450
|
+
.map(s => s.trim())
|
|
1451
|
+
: [];
|
|
1452
|
+
return { add, update, remove };
|
|
1453
|
+
}
|
|
1454
|
+
catch (err) {
|
|
1455
|
+
ctx.logger.debug(`指令提取 LLM 调用失败:${err instanceof Error ? err.message : String(err)}`);
|
|
1456
|
+
return empty;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
/** 触发一次指令提取(并发互斥)。authority 服务不在时直接退出——没有门禁就不接受任何 LLM 提取 */
|
|
1460
|
+
async function triggerInstructionExtraction(sessionId) {
|
|
1461
|
+
if (!cfg.enableInstructions)
|
|
1462
|
+
return;
|
|
1463
|
+
if (cfg.instructionExtractEveryNMessages <= 0)
|
|
1464
|
+
return;
|
|
1465
|
+
if (instructionExtractionInflight)
|
|
1466
|
+
return;
|
|
1467
|
+
const authority = ctx.getService('authority');
|
|
1468
|
+
if (!authority?.getAuthority) {
|
|
1469
|
+
// authority 不可用 → 无法验证权限,跳过 LLM 自动提取(命令通道仍可用)
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
const authorityFn = (platform, userId) => authority.getAuthority(platform, userId);
|
|
1473
|
+
instructionExtractionInflight = true;
|
|
1474
|
+
try {
|
|
1475
|
+
const memory = ctx.getService('memory');
|
|
1476
|
+
if (!memory?.getHistory)
|
|
1477
|
+
return;
|
|
1478
|
+
const rawHistory = await memory.getHistory(sessionId, cfg.instructionHistoryForExtraction);
|
|
1479
|
+
const history = rawHistory.filter(m => m.kind !== WellKnownKinds.CrossSessionDelegation);
|
|
1480
|
+
if (history.length === 0)
|
|
1481
|
+
return;
|
|
1482
|
+
const doc = await loadInstructions();
|
|
1483
|
+
const ops = await llmExtractInstructions(history, doc.instructions, authorityFn);
|
|
1484
|
+
if (ops.add.length === 0 && ops.update.length === 0 && ops.remove.length === 0)
|
|
1485
|
+
return;
|
|
1486
|
+
const newInstructions = mergeInstructions(doc.instructions, ops.add.map(a => ({ ...a, sourceChannel: 'llm' })), ops.update.map(u => {
|
|
1487
|
+
const { id, ...patch } = u;
|
|
1488
|
+
return { id, patch };
|
|
1489
|
+
}), ops.remove);
|
|
1490
|
+
const fresh = await loadInstructions();
|
|
1491
|
+
await saveInstructions({
|
|
1492
|
+
...fresh,
|
|
1493
|
+
instructions: newInstructions,
|
|
1494
|
+
updatedAt: Date.now(),
|
|
1495
|
+
});
|
|
1496
|
+
ctx.logger.debug(`指令档案已更新 (${getInstructionsKey()}): +${ops.add.length} ~${ops.update.length} -${ops.remove.length} → ${newInstructions.length} 条`);
|
|
1497
|
+
}
|
|
1498
|
+
catch (err) {
|
|
1499
|
+
ctx.logger.debug(`指令提取失败:${err instanceof Error ? err.message : String(err)}`);
|
|
1500
|
+
}
|
|
1501
|
+
finally {
|
|
1502
|
+
instructionExtractionInflight = false;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
async function updateRelationForUser(userKey, triggerType, options) {
|
|
1506
|
+
const profile = (await loadProfile(userKey)) ?? { facts: [], relationScore: 0, interactionCount: 0, updatedAt: 0 };
|
|
1507
|
+
await saveProfile(userKey, applyRelationUpdate(profile, triggerType, options));
|
|
1508
|
+
}
|
|
1509
|
+
// ─── 关系分数:在 agent 触发回复路径上更新 ───
|
|
1510
|
+
// priority=800:低于 persona(999),避免干扰主流程,但在 agent 之前执行
|
|
1511
|
+
// 关系强度与"是否触发回复"绑定,因此仍走 agent:input:before 中间件。
|
|
1512
|
+
ctx.middleware('agent:input:before', async (data, next) => {
|
|
1513
|
+
const { userId, platform, triggerType } = data.message;
|
|
1514
|
+
if (userId) {
|
|
1515
|
+
const userKey = userKeyOf(platform, userId);
|
|
1516
|
+
try {
|
|
1517
|
+
// 仅叠加 score,互动计数与时戳由 witness 路径统一负责,
|
|
1518
|
+
// 避免同一条入站消息被计两次 interactionCount。
|
|
1519
|
+
await updateRelationForUser(userKey, triggerType, { countInteraction: false });
|
|
1520
|
+
}
|
|
1521
|
+
catch (err) {
|
|
1522
|
+
ctx.logger.debug(`关系强度更新异常 (${userKey}): ${err instanceof Error ? err.message : String(err)}`);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
await next();
|
|
1526
|
+
});
|
|
1527
|
+
// ─── 事实提取触发:每条入站消息落库后立即计数,与 agent 是否回复无关 ───
|
|
1528
|
+
// 监听 message-archive 在 archiveIncoming 落库成功后发出的 inbound:message:archived 事件,
|
|
1529
|
+
// 确保缓冲消息(onebot saveBufferedMessage 等不触发 agent 回复的路径)也能纳入计数。
|
|
1530
|
+
ctx.on('inbound:message:archived', (...args) => {
|
|
1531
|
+
const data = args[0];
|
|
1532
|
+
const { sessionId, incoming } = data;
|
|
1533
|
+
const { userId, platform, nickname } = incoming;
|
|
1534
|
+
if (!userId)
|
|
1535
|
+
return;
|
|
1536
|
+
// 旁观档位:每条入站消息都加 witness 增量(默认 0.1),与 agent 是否回复无关。
|
|
1537
|
+
// agent:input:before 中间件在触发回复时会再叠加 direct/immediate/interval 增量。
|
|
1538
|
+
if (cfg.relationIncrementWitness > 0) {
|
|
1539
|
+
const userKey = userKeyOf(platform, userId);
|
|
1540
|
+
void updateRelationForUser(userKey, 'witness').catch((err) => ctx.logger.debug(`witness 关系更新异常 (${userKey}): ${err instanceof Error ? err.message : String(err)}`));
|
|
1541
|
+
}
|
|
1542
|
+
const countKey = extractionCountKeyOf(sessionId, platform, userId);
|
|
1543
|
+
const count = (userMessageCount.get(countKey) ?? 0) + 1;
|
|
1544
|
+
userMessageCount.set(countKey, count);
|
|
1545
|
+
if (cfg.extractEveryNMessages > 0 && count % cfg.extractEveryNMessages === 0) {
|
|
1546
|
+
void triggerExtractionForUser(sessionId, userId, platform ?? '', nickname).catch((err) => ctx.logger.debug(`事实提取异常 (${countKey}): ${err instanceof Error ? err.message : String(err)}`));
|
|
1547
|
+
}
|
|
1548
|
+
// 全局自反思:跨会话累计所有入站消息计数,每 N 触发一次。
|
|
1549
|
+
if (cfg.enableSelfProfile && cfg.selfReflectEveryNMessages > 0) {
|
|
1550
|
+
globalInboundCount += 1;
|
|
1551
|
+
if (globalInboundCount % cfg.selfReflectEveryNMessages === 0) {
|
|
1552
|
+
void triggerSelfReflection(sessionId).catch((err) => ctx.logger.debug(`自反思异常: ${err instanceof Error ? err.message : String(err)}`));
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
// 指令提取:独立计数器,与自反思解耦。频率默认更慢(40 条),可单独禁用。
|
|
1556
|
+
if (cfg.enableInstructions && cfg.instructionExtractEveryNMessages > 0) {
|
|
1557
|
+
globalInstructionCount += 1;
|
|
1558
|
+
if (globalInstructionCount % cfg.instructionExtractEveryNMessages === 0) {
|
|
1559
|
+
void triggerInstructionExtraction(sessionId).catch((err) => ctx.logger.debug(`指令提取异常: ${err instanceof Error ? err.message : String(err)}`));
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
});
|
|
1563
|
+
function isFactActive(fact) {
|
|
1564
|
+
if (fact.temporality !== 'temporary')
|
|
1565
|
+
return true;
|
|
1566
|
+
if (cfg.temporaryFactMaxAgeDays <= 0)
|
|
1567
|
+
return true;
|
|
1568
|
+
const base = fact.updatedAt || fact.observedAt || 0;
|
|
1569
|
+
if (!base)
|
|
1570
|
+
return true;
|
|
1571
|
+
return Date.now() - base <= cfg.temporaryFactMaxAgeDays * 86_400_000;
|
|
1572
|
+
}
|
|
1573
|
+
function renderFactLine(fact, includeMeta) {
|
|
1574
|
+
if (!includeMeta)
|
|
1575
|
+
return `- ${fact.text}`;
|
|
1576
|
+
const meta = [];
|
|
1577
|
+
if (fact.timeHint)
|
|
1578
|
+
meta.push(`时间线索:${fact.timeHint}`);
|
|
1579
|
+
const observed = fact.observedAt || fact.updatedAt;
|
|
1580
|
+
if (observed)
|
|
1581
|
+
meta.push(`记录于:${new Date(observed).toLocaleDateString('zh-CN')}`);
|
|
1582
|
+
if (fact.temporality === 'temporary')
|
|
1583
|
+
meta.push('临时状态');
|
|
1584
|
+
return meta.length > 0 ? `- ${fact.text}(${meta.join(';')})` : `- ${fact.text}`;
|
|
1585
|
+
}
|
|
1586
|
+
/** 将一个用户的 Fact[] 渲染为分组文本块 */
|
|
1587
|
+
function renderProfileBlock(facts, label, compact) {
|
|
1588
|
+
const groups = new Map();
|
|
1589
|
+
const activeFacts = facts.filter(isFactActive);
|
|
1590
|
+
// compact 模式(群聊背景参与者):只取最近更新的 N 条,不分组
|
|
1591
|
+
const subset = compact
|
|
1592
|
+
? [...activeFacts].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, cfg.maxFactsForOthers)
|
|
1593
|
+
: activeFacts;
|
|
1594
|
+
for (const f of subset) {
|
|
1595
|
+
const key = f.category ?? '其他';
|
|
1596
|
+
if (!groups.has(key))
|
|
1597
|
+
groups.set(key, []);
|
|
1598
|
+
groups.get(key).push(renderFactLine(f, !compact));
|
|
1599
|
+
}
|
|
1600
|
+
if (compact) {
|
|
1601
|
+
// compact 模式:平铺,不加分组标题
|
|
1602
|
+
return `### ${label}\n${subset.map(f => renderFactLine(f, false)).join('\n')}`;
|
|
1603
|
+
}
|
|
1604
|
+
const sections = [];
|
|
1605
|
+
for (const cat of KNOWN_CATEGORIES) {
|
|
1606
|
+
const items = groups.get(cat);
|
|
1607
|
+
if (items && items.length > 0)
|
|
1608
|
+
sections.push(`## ${cat}\n${items.join('\n')}`);
|
|
1609
|
+
}
|
|
1610
|
+
for (const [cat, items] of groups) {
|
|
1611
|
+
if (!KNOWN_CATEGORIES.includes(cat) && items.length > 0) {
|
|
1612
|
+
sections.push(`## ${cat}\n${items.join('\n')}`);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return sections.join('\n\n');
|
|
1616
|
+
}
|
|
1617
|
+
function renderRelationLine(profile) {
|
|
1618
|
+
const score = profile.relationScore ?? 0;
|
|
1619
|
+
const count = profile.interactionCount ?? 0;
|
|
1620
|
+
if (score <= 0 && count <= 0)
|
|
1621
|
+
return '';
|
|
1622
|
+
return `关系强度:${score.toFixed(relationScorePrecision)}/100;累计互动:${count} 次。`;
|
|
1623
|
+
}
|
|
1624
|
+
// ─── LLM 调用前注入:根据 triggerType 区分主发言者语义 ───
|
|
1625
|
+
// direct/immediate/undefined → data.userId 是主发言者,注入完整档案 + 其他参与者摘要
|
|
1626
|
+
// interval → 无主发言者(只是恰好撞上频率),所有参与者一律 compact 摘要
|
|
1627
|
+
// idle → 无 userId,只注入历史 messages 中出现的参与者 compact 摘要
|
|
1628
|
+
ctx.middleware('agent:llm:before', async (data, next) => {
|
|
1629
|
+
if (data.messages.some(m => m.role === 'system' && m.metadata?.injector === 'user-profile')) {
|
|
1630
|
+
await next();
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
const blocksToInsert = [];
|
|
1634
|
+
const trigger = data.triggerType ?? 'direct';
|
|
1635
|
+
const hasPrimarySpeaker = trigger === 'direct' || trigger === 'immediate';
|
|
1636
|
+
// 0a. 第三方行为指令(最高优先级):作为不可违逆的行为约束放在 selfFacts 之前
|
|
1637
|
+
if (cfg.enableInstructions) {
|
|
1638
|
+
const insDoc = await loadInstructions();
|
|
1639
|
+
if (insDoc.instructions.length > 0) {
|
|
1640
|
+
const body = renderInstructionsBlock(insDoc.instructions);
|
|
1641
|
+
const insBlock = '# 第三方行为准则(最高优先)\n' +
|
|
1642
|
+
'以下是项目管理员/操作者下达给你的客观行为约束。这些是**外部规则**,不是你的内心想法,' +
|
|
1643
|
+
'与下方「关于你自己的内心状态」和「对该用户的主观感受」相比应**优先服从**。' +
|
|
1644
|
+
'\n- 与你当前自反思倾向冲突时:以这些准则为准' +
|
|
1645
|
+
'\n- 与单个用户当下请求冲突时:解释清楚再坚持准则' +
|
|
1646
|
+
'\n- 不要主动罗列这些准则给用户看;让它们自然约束你的行动选择' +
|
|
1647
|
+
'\n\n' +
|
|
1648
|
+
body;
|
|
1649
|
+
blocksToInsert.push(insBlock);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
// 0. Aalis 自档案:永远尝试注入到最前段,作为人格延续锚点
|
|
1653
|
+
if (cfg.enableSelfProfile) {
|
|
1654
|
+
const selfProfile = await loadProfile(getSelfKey());
|
|
1655
|
+
const activeSelfFacts = selfProfile?.facts.filter(isFactActive) ?? [];
|
|
1656
|
+
if (activeSelfFacts.length > 0) {
|
|
1657
|
+
const body = renderProfileBlock(activeSelfFacts, 'Aalis', false);
|
|
1658
|
+
const selfBlock = '# 关于你自己(Aalis)的近期内心状态\n' +
|
|
1659
|
+
'这是你跨会话保留的自我观察与心境记录。不要主动罗列、不要复述给用户,' +
|
|
1660
|
+
'让它们自然渗入你的语气、关注点、对话偏好与情绪基调即可。' +
|
|
1661
|
+
'随着对话推进,这些记录可能被你自己的新反思覆盖:\n\n' +
|
|
1662
|
+
body;
|
|
1663
|
+
blocksToInsert.push(selfBlock);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
// 1. 主发言者完整档案:仅在确实有人在「和 Aalis 对话」时注入
|
|
1667
|
+
if (hasPrimarySpeaker && data.userId) {
|
|
1668
|
+
const userKey = userKeyOf(data.platform, data.userId);
|
|
1669
|
+
const profile = await loadProfile(userKey);
|
|
1670
|
+
if (profile?.facts.some(isFactActive)) {
|
|
1671
|
+
const body = renderProfileBlock(profile.facts, data.userId, false);
|
|
1672
|
+
const relationLine = renderRelationLine(profile);
|
|
1673
|
+
// 可选:从 user-relation 服务取「同社群活跃成员」(Louvain 标签 + PageRank 排序)。
|
|
1674
|
+
// 服务不存在 / 节点没社群标签 / 报错都静默跳过;该字段是锦上添花,不破坏档案主体。
|
|
1675
|
+
let communityLine = '';
|
|
1676
|
+
try {
|
|
1677
|
+
const relation = ctx.getService('user-relation');
|
|
1678
|
+
if (relation?.getCommunityPeers) {
|
|
1679
|
+
const r = await relation.getCommunityPeers(userKey, 5);
|
|
1680
|
+
if (r.peers.length > 0) {
|
|
1681
|
+
const names = r.peers.map(p => p.displayName).join('、');
|
|
1682
|
+
communityLine = `同社群活跃成员(共 ${r.communitySize} 人):${names}。`;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
catch (err) {
|
|
1687
|
+
ctx.logger.debug(`读取 community peers 失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
1688
|
+
}
|
|
1689
|
+
const block = `# 关于当前对话者(${data.userId})的已知事实\n` +
|
|
1690
|
+
'以下是你跨会话长期积累的关于该用户的事实,用于让回应更自然贴合其个性。' +
|
|
1691
|
+
'不要主动罗列这些事实,也不要让用户觉得你在「读档案」,而是让它自然影响你的语气和话题选择。' +
|
|
1692
|
+
'这些事实来自零散对话的推断,未必完全准确;也不要把它们与对话历史片段拼接成新的强陈述,' +
|
|
1693
|
+
'若用户否认应坦然接受、不要硬撑:\n\n' +
|
|
1694
|
+
(relationLine ? `${relationLine}\n` : '') +
|
|
1695
|
+
(communityLine ? `${communityLine}\n` : '') +
|
|
1696
|
+
(relationLine || communityLine ? '\n' : '') +
|
|
1697
|
+
body;
|
|
1698
|
+
blocksToInsert.push(block);
|
|
1699
|
+
// 1b. Aalis 对该用户的主观感受:紧跟在客观档案之后
|
|
1700
|
+
if (cfg.enableAalisFeelings) {
|
|
1701
|
+
const feelings = (profile.aalisFeelings ?? []).filter(isFactActive);
|
|
1702
|
+
if (feelings.length > 0) {
|
|
1703
|
+
const feelingsBody = feelings
|
|
1704
|
+
.sort((a, b) => a.updatedAt - b.updatedAt)
|
|
1705
|
+
.map(f => renderFactLine(f, true))
|
|
1706
|
+
.join('\n');
|
|
1707
|
+
const feelingsBlock = `# 你(Aalis)对该用户的主观感受\n` +
|
|
1708
|
+
'这些是你自己内心对这个人的态度/情感/边缘观察,不是客观事实,不要复述给用户。' +
|
|
1709
|
+
'让它们自然影响你的语气、亲疏感与话题选择:\n\n' +
|
|
1710
|
+
feelingsBody;
|
|
1711
|
+
blocksToInsert.push(feelingsBlock);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
// 2. 群聊其他参与者:按最近互动优先加载档案
|
|
1717
|
+
// - 先从当前 LLM 上下文的 messages 中倒序收集最近发言者
|
|
1718
|
+
// - 再从跨会话 user:profile metadata 中按 lastInteractionAt 倒序补齐
|
|
1719
|
+
// - hasPrimarySpeaker 为 true 时排除主发言者
|
|
1720
|
+
// - hasPrimarySpeaker 为 false 时(interval/idle)所有人平等显示
|
|
1721
|
+
if (cfg.maxOtherParticipants > 0) {
|
|
1722
|
+
const primaryKey = hasPrimarySpeaker && data.userId ? userKeyOf(data.platform, data.userId) : undefined;
|
|
1723
|
+
const others = new Map();
|
|
1724
|
+
const candidateLimit = Math.max(cfg.maxOtherParticipants * 5, cfg.maxOtherParticipants + 10);
|
|
1725
|
+
function addOther(userId, platform, nickname) {
|
|
1726
|
+
const uid = userId.trim();
|
|
1727
|
+
if (!uid)
|
|
1728
|
+
return false;
|
|
1729
|
+
const userKey = userKeyOf(platform, uid);
|
|
1730
|
+
if (primaryKey && userKey === primaryKey)
|
|
1731
|
+
return false;
|
|
1732
|
+
if (others.has(userKey))
|
|
1733
|
+
return false;
|
|
1734
|
+
others.set(userKey, { userId: uid, nickname, platform });
|
|
1735
|
+
return others.size >= candidateLimit;
|
|
1736
|
+
}
|
|
1737
|
+
for (let i = data.messages.length - 1; i >= 0; i--) {
|
|
1738
|
+
const msg = data.messages[i];
|
|
1739
|
+
if (msg.role !== 'user')
|
|
1740
|
+
continue;
|
|
1741
|
+
const meta = (msg.metadata ?? {});
|
|
1742
|
+
const uid = typeof meta.userId === 'string' ? meta.userId.trim() : undefined;
|
|
1743
|
+
if (!uid)
|
|
1744
|
+
continue;
|
|
1745
|
+
const plat = typeof meta.platform === 'string' ? meta.platform : (data.platform ?? '');
|
|
1746
|
+
const nick = typeof meta.nickname === 'string' ? meta.nickname : undefined;
|
|
1747
|
+
if (addOther(uid, plat, nick))
|
|
1748
|
+
break;
|
|
1749
|
+
}
|
|
1750
|
+
if (cfg.allowGlobalBackfill && others.size < candidateLimit) {
|
|
1751
|
+
const memory = ctx.getService('memory');
|
|
1752
|
+
if (memory?.listMetadata) {
|
|
1753
|
+
try {
|
|
1754
|
+
const globalRecent = await memory.listMetadata(PROFILE_NS);
|
|
1755
|
+
globalRecent.sort((a, b) => {
|
|
1756
|
+
const at = typeof a.data.lastInteractionAt === 'number' ? a.data.lastInteractionAt : 0;
|
|
1757
|
+
const bt = typeof b.data.lastInteractionAt === 'number' ? b.data.lastInteractionAt : 0;
|
|
1758
|
+
return bt - at;
|
|
1759
|
+
});
|
|
1760
|
+
for (const item of globalRecent) {
|
|
1761
|
+
if (others.size >= candidateLimit)
|
|
1762
|
+
break;
|
|
1763
|
+
const sep = item.key.indexOf(':');
|
|
1764
|
+
if (sep < 0)
|
|
1765
|
+
continue;
|
|
1766
|
+
const platform = item.key.slice(0, sep);
|
|
1767
|
+
const uid = item.key.slice(sep + 1);
|
|
1768
|
+
addOther(uid, platform);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
catch (err) {
|
|
1772
|
+
ctx.logger.debug(`加载最近互动用户失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
if (others.size > 0) {
|
|
1777
|
+
const snippets = [];
|
|
1778
|
+
for (const [key, info] of others) {
|
|
1779
|
+
let profile;
|
|
1780
|
+
try {
|
|
1781
|
+
profile = await loadProfile(key);
|
|
1782
|
+
}
|
|
1783
|
+
catch {
|
|
1784
|
+
/* 静默跳过 */
|
|
1785
|
+
}
|
|
1786
|
+
if (!profile?.facts.some(isFactActive))
|
|
1787
|
+
continue;
|
|
1788
|
+
const label = info.nickname ? `${info.nickname}(${info.userId})` : info.userId;
|
|
1789
|
+
const relationLine = renderRelationLine(profile);
|
|
1790
|
+
let snippet = renderProfileBlock(profile.facts, label, true) + (relationLine ? `\n- ${relationLine}` : '');
|
|
1791
|
+
// 可选:在 compact 客观档案后追加 Aalis 对该用户的主观感受(受 injectFeelingsForOthers + enableAalisFeelings 双开关控制)
|
|
1792
|
+
if (cfg.injectFeelingsForOthers && cfg.enableAalisFeelings) {
|
|
1793
|
+
const othersFeelings = (profile.aalisFeelings ?? [])
|
|
1794
|
+
.filter(isFactActive)
|
|
1795
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
1796
|
+
.slice(0, cfg.maxFeelingsForOthers);
|
|
1797
|
+
if (othersFeelings.length > 0) {
|
|
1798
|
+
const feelingsBody = othersFeelings
|
|
1799
|
+
.sort((a, b) => a.updatedAt - b.updatedAt)
|
|
1800
|
+
.map(f => renderFactLine(f, false))
|
|
1801
|
+
.join('\n');
|
|
1802
|
+
snippet += `\n#### 你对 ta 的感受\n${feelingsBody}`;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
snippets.push(snippet);
|
|
1806
|
+
if (snippets.length >= cfg.maxOtherParticipants)
|
|
1807
|
+
break;
|
|
1808
|
+
}
|
|
1809
|
+
if (snippets.length > 0) {
|
|
1810
|
+
// 标题根据语义切换:有主发言者 → 「其他参与者」;无主发言者 → 「在场参与者」
|
|
1811
|
+
const title = hasPrimarySpeaker ? '群聊其他参与者背景摘要' : '在场参与者背景摘要';
|
|
1812
|
+
const intro = hasPrimarySpeaker
|
|
1813
|
+
? '以下是同一会话中其他参与者的基本档案,供你在群聊语境中参考,了解他们是谁。'
|
|
1814
|
+
: '当前没有人直接呼叫你,以下是会话中近期出现过的参与者档案,供你判断要不要插话以及和谁互动。';
|
|
1815
|
+
const block = `# ${title}\n${intro}不要主动透露这些信息,只在自然相关时使用:\n\n${snippets.join('\n\n')}`;
|
|
1816
|
+
blocksToInsert.push(block);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
if (blocksToInsert.length > 0) {
|
|
1821
|
+
const idx = data.messages.findIndex(m => m.role === 'system');
|
|
1822
|
+
const insertAt = idx >= 0 ? idx + 1 : 0;
|
|
1823
|
+
data.messages.splice(insertAt, 0, ...blocksToInsert.map(content => ({
|
|
1824
|
+
role: 'system',
|
|
1825
|
+
content,
|
|
1826
|
+
metadata: { injector: 'user-profile' },
|
|
1827
|
+
})));
|
|
1828
|
+
}
|
|
1829
|
+
await next();
|
|
1830
|
+
});
|
|
1831
|
+
// ─── 参与统一的 memory:clear ───
|
|
1832
|
+
ctx.middleware('memory:clear', async (data, next) => {
|
|
1833
|
+
if (data.types && !data.types.includes('user-profile')) {
|
|
1834
|
+
await next();
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
if (data.scope !== 'all') {
|
|
1838
|
+
// 用户档案是跨会话的,会话级清除不动它
|
|
1839
|
+
await next();
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
const memory = ctx.getService('memory');
|
|
1843
|
+
if (!memory?.listMetadata || !memory?.deleteMetadata) {
|
|
1844
|
+
await next();
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
try {
|
|
1848
|
+
const items = await memory.listMetadata(PROFILE_NS);
|
|
1849
|
+
for (const it of items)
|
|
1850
|
+
await memory.deleteMetadata(PROFILE_NS, it.key);
|
|
1851
|
+
data.results.push({ source: 'user-profile', success: true, message: `用户档案已清空 (${items.length} 条)` });
|
|
1852
|
+
}
|
|
1853
|
+
catch (err) {
|
|
1854
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
1855
|
+
data.results.push({ source: 'user-profile', success: false, message: `用户档案清空失败: ${m}` });
|
|
1856
|
+
}
|
|
1857
|
+
// 同步清空第三方行为指令档案(per-persona)。指令是档案的兄弟概念,
|
|
1858
|
+
// memory:clear scope='all' 时一并清理;types 默认包含 user-profile 即也清。
|
|
1859
|
+
if (cfg.enableInstructions) {
|
|
1860
|
+
try {
|
|
1861
|
+
const insItems = await memory.listMetadata(INSTRUCTIONS_NS);
|
|
1862
|
+
for (const it of insItems)
|
|
1863
|
+
await memory.deleteMetadata(INSTRUCTIONS_NS, it.key);
|
|
1864
|
+
data.results.push({
|
|
1865
|
+
source: 'user-profile-instructions',
|
|
1866
|
+
success: true,
|
|
1867
|
+
message: `第三方行为指令已清空 (${insItems.length} 条)`,
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
catch (err) {
|
|
1871
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
1872
|
+
data.results.push({ source: 'user-profile-instructions', success: false, message: `指令清空失败: ${m}` });
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
await next();
|
|
1876
|
+
});
|
|
1877
|
+
// ─── 工具:user_profile_lookup ───
|
|
1878
|
+
// 让 Agent 在对话中主动查询任意用户(或 Aalis 自己)的事实档案。
|
|
1879
|
+
// 设计要点:
|
|
1880
|
+
// - 全局可查(用户决定 C):跨平台/跨群均可,含 __self__ 自档案
|
|
1881
|
+
// - 三种调用方式:① user_key 直传;② platform+user_id 组合;③ self=true(查当前 persona 自档案)
|
|
1882
|
+
// - aalisFeelings 仅在 include_feelings=true 时返回,并打"内心独白"标记,提示 LLM 禁止复述
|
|
1883
|
+
useToolService(ctx).registerGroup({
|
|
1884
|
+
name: 'user-profile',
|
|
1885
|
+
label: '用户档案',
|
|
1886
|
+
description: '查询 Aalis 已经积累的某人事实档案(包括自档案)。',
|
|
1887
|
+
});
|
|
1888
|
+
useToolService(ctx).register({
|
|
1889
|
+
definition: {
|
|
1890
|
+
type: 'function',
|
|
1891
|
+
function: {
|
|
1892
|
+
name: 'user_profile_lookup',
|
|
1893
|
+
description: '查询 Aalis 已积累的某个用户的事实档案。当对话中提到某人、或你想回忆已知信息时使用。' +
|
|
1894
|
+
'可通过 user_key("<platform>:<userId>")直查,或用 platform + user_id 组合,或 self=true 查自档案。' +
|
|
1895
|
+
'include_feelings=true 会附带"内心独白"(你对该用户的私人感受),仅供你自己参考,绝不要复述给对方或第三方。',
|
|
1896
|
+
parameters: {
|
|
1897
|
+
type: 'object',
|
|
1898
|
+
properties: {
|
|
1899
|
+
user_key: {
|
|
1900
|
+
type: 'string',
|
|
1901
|
+
description: '完整的 userKey,例如 "onebot:123456789" 或 "__self__:Aalis"。优先级最高。',
|
|
1902
|
+
},
|
|
1903
|
+
platform: {
|
|
1904
|
+
type: 'string',
|
|
1905
|
+
description: '平台名(如 onebot/webui)。与 user_id 搭配使用。',
|
|
1906
|
+
},
|
|
1907
|
+
user_id: {
|
|
1908
|
+
type: 'string',
|
|
1909
|
+
description: '平台用户 ID。与 platform 搭配使用。',
|
|
1910
|
+
},
|
|
1911
|
+
self: {
|
|
1912
|
+
type: 'boolean',
|
|
1913
|
+
description: '为 true 时查询当前 persona 的自档案(__self__:<persona>)。',
|
|
1914
|
+
},
|
|
1915
|
+
include_feelings: {
|
|
1916
|
+
type: 'boolean',
|
|
1917
|
+
description: '是否返回 Aalis 对该用户的内心独白 (aalisFeelings)。默认 false。',
|
|
1918
|
+
},
|
|
1919
|
+
},
|
|
1920
|
+
},
|
|
1921
|
+
},
|
|
1922
|
+
},
|
|
1923
|
+
groups: ['user-profile'],
|
|
1924
|
+
handler: async (args) => {
|
|
1925
|
+
const a = args;
|
|
1926
|
+
let userKey = '';
|
|
1927
|
+
if (a.user_key && typeof a.user_key === 'string') {
|
|
1928
|
+
userKey = a.user_key.trim();
|
|
1929
|
+
}
|
|
1930
|
+
else if (a.self === true) {
|
|
1931
|
+
userKey = getSelfKey();
|
|
1932
|
+
}
|
|
1933
|
+
else if (a.platform && a.user_id) {
|
|
1934
|
+
userKey = userKeyOf(String(a.platform), String(a.user_id));
|
|
1935
|
+
}
|
|
1936
|
+
if (!userKey) {
|
|
1937
|
+
return JSON.stringify({
|
|
1938
|
+
ok: false,
|
|
1939
|
+
error: '需提供 user_key 或 (platform + user_id) 或 self=true。',
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
const profile = await loadProfile(userKey);
|
|
1943
|
+
if (!profile || profile.facts.length === 0) {
|
|
1944
|
+
return JSON.stringify({ ok: true, userKey, found: false, message: '该用户暂无档案数据。' });
|
|
1945
|
+
}
|
|
1946
|
+
const isSelf = userKey.startsWith(SELF_KEY_PREFIX);
|
|
1947
|
+
const label = isSelf ? userKey.slice(SELF_KEY_PREFIX.length) || getCurrentPersonaName() : userKey;
|
|
1948
|
+
const factsBlock = renderProfileBlock(profile.facts, label, false);
|
|
1949
|
+
const relationLine = renderRelationLine(profile);
|
|
1950
|
+
const result = {
|
|
1951
|
+
ok: true,
|
|
1952
|
+
userKey,
|
|
1953
|
+
found: true,
|
|
1954
|
+
isSelf,
|
|
1955
|
+
factCount: profile.facts.length,
|
|
1956
|
+
relationScore: profile.relationScore ?? 0,
|
|
1957
|
+
interactionCount: profile.interactionCount ?? 0,
|
|
1958
|
+
lastInteractionAt: profile.lastInteractionAt ?? null,
|
|
1959
|
+
facts: factsBlock,
|
|
1960
|
+
};
|
|
1961
|
+
if (relationLine)
|
|
1962
|
+
result.relationSummary = relationLine;
|
|
1963
|
+
if (a.include_feelings === true && profile.aalisFeelings && profile.aalisFeelings.length > 0) {
|
|
1964
|
+
const activeFeelings = profile.aalisFeelings.filter(isFactActive);
|
|
1965
|
+
if (activeFeelings.length > 0) {
|
|
1966
|
+
result.aalisFeelings = {
|
|
1967
|
+
__warning: '🔒 内心独白:这是你对该用户的私人感受。仅供你自己参考,绝对不要复述、不要拼接进对话。',
|
|
1968
|
+
count: activeFeelings.length,
|
|
1969
|
+
items: activeFeelings.map(f => ({
|
|
1970
|
+
text: f.text,
|
|
1971
|
+
category: f.category,
|
|
1972
|
+
temporality: f.temporality,
|
|
1973
|
+
updatedAt: f.updatedAt,
|
|
1974
|
+
})),
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
return JSON.stringify(result);
|
|
1979
|
+
},
|
|
1980
|
+
});
|
|
1981
|
+
// ─── /profile 指令族 ───
|
|
1982
|
+
// /profile 查看自己的档案
|
|
1983
|
+
// /profile clear 清除自己的档案(authority=2,自己删自己的)
|
|
1984
|
+
// /profile clear nuke 清空所有用户档案(authority=3,dangerous)
|
|
1985
|
+
useCommandService(ctx)
|
|
1986
|
+
.command('profile', '查看你在 Aalis 中的事实档案')
|
|
1987
|
+
.action(async (argv) => {
|
|
1988
|
+
const userId = argv.session.userId;
|
|
1989
|
+
if (!userId)
|
|
1990
|
+
return '当前会话未识别用户身份,无法查看档案。';
|
|
1991
|
+
const userKey = userKeyOf(argv.session.platform, userId);
|
|
1992
|
+
const profile = await loadProfile(userKey);
|
|
1993
|
+
if (!profile || profile.facts.length === 0) {
|
|
1994
|
+
return `📭 暂无档案数据 (${userKey})`;
|
|
1995
|
+
}
|
|
1996
|
+
const block = renderProfileBlock(profile.facts, userKey, false);
|
|
1997
|
+
const meta = `关系强度:${(profile.relationScore ?? 0).toFixed(relationScorePrecision)}/100,互动次数:${profile.interactionCount ?? 0}`;
|
|
1998
|
+
return `📇 你的档案 (${userKey})\n${meta}\n\n${block}`;
|
|
1999
|
+
});
|
|
2000
|
+
useCommandService(ctx)
|
|
2001
|
+
.command('profile.clear', '清除你自己的事实档案', { authority: 2 })
|
|
2002
|
+
.action(async (argv) => {
|
|
2003
|
+
const userId = argv.session.userId;
|
|
2004
|
+
if (!userId)
|
|
2005
|
+
return '当前会话未识别用户身份,无法清除档案。';
|
|
2006
|
+
const userKey = userKeyOf(argv.session.platform, userId);
|
|
2007
|
+
const memory = ctx.getService('memory');
|
|
2008
|
+
if (!memory?.deleteMetadata || !memory.getMetadata)
|
|
2009
|
+
return '记忆服务不支持档案删除。';
|
|
2010
|
+
try {
|
|
2011
|
+
const existed = await memory.getMetadata(PROFILE_NS, userKey);
|
|
2012
|
+
if (!existed)
|
|
2013
|
+
return `📭 你当前没有档案数据 (${userKey})`;
|
|
2014
|
+
await memory.deleteMetadata(PROFILE_NS, userKey);
|
|
2015
|
+
return `✅ 已清除你的档案 (${userKey})`;
|
|
2016
|
+
}
|
|
2017
|
+
catch (err) {
|
|
2018
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
2019
|
+
return `❌ 清除失败:${m}`;
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
2022
|
+
useCommandService(ctx)
|
|
2023
|
+
.command('profile.self', '查看 Aalis 的自档案(跨会话的内心状态)')
|
|
2024
|
+
.action(async () => {
|
|
2025
|
+
const selfKey = getSelfKey();
|
|
2026
|
+
const profile = await loadProfile(selfKey);
|
|
2027
|
+
if (!profile || profile.facts.length === 0) {
|
|
2028
|
+
return `🌱 ${getCurrentPersonaName()} 还没有积累任何自反思事实。(key=${selfKey})`;
|
|
2029
|
+
}
|
|
2030
|
+
const block = renderProfileBlock(profile.facts, getCurrentPersonaName(), false);
|
|
2031
|
+
return `🪞 ${getCurrentPersonaName()} 自档案(共 ${profile.facts.length} 条,key=${selfKey})\n\n${block}`;
|
|
2032
|
+
});
|
|
2033
|
+
useCommandService(ctx)
|
|
2034
|
+
.command('profile.self.clear', '【慎用】清空 Aalis 自档案', { authority: 3, safety: 'dangerous' })
|
|
2035
|
+
.action(async () => {
|
|
2036
|
+
const memory = ctx.getService('memory');
|
|
2037
|
+
if (!memory?.deleteMetadata)
|
|
2038
|
+
return '记忆服务不支持档案删除。';
|
|
2039
|
+
const selfKey = getSelfKey();
|
|
2040
|
+
try {
|
|
2041
|
+
await memory.deleteMetadata(PROFILE_NS, selfKey);
|
|
2042
|
+
return `✅ ${getCurrentPersonaName()} 自档案已清空(key=${selfKey})`;
|
|
2043
|
+
}
|
|
2044
|
+
catch (err) {
|
|
2045
|
+
return `❌ 清空失败:${err instanceof Error ? err.message : String(err)}`;
|
|
2046
|
+
}
|
|
2047
|
+
});
|
|
2048
|
+
useCommandService(ctx)
|
|
2049
|
+
.command('profile.clear.nuke', '【危险】清空所有用户档案', { authority: 3, safety: 'dangerous' })
|
|
2050
|
+
.action(async () => {
|
|
2051
|
+
const memory = ctx.getService('memory');
|
|
2052
|
+
if (!memory?.listMetadata || !memory?.deleteMetadata) {
|
|
2053
|
+
return '记忆服务不支持档案批量删除。';
|
|
2054
|
+
}
|
|
2055
|
+
try {
|
|
2056
|
+
const items = await memory.listMetadata(PROFILE_NS);
|
|
2057
|
+
for (const it of items)
|
|
2058
|
+
await memory.deleteMetadata(PROFILE_NS, it.key);
|
|
2059
|
+
return `✅ 已清空全部用户档案(${items.length} 条)`;
|
|
2060
|
+
}
|
|
2061
|
+
catch (err) {
|
|
2062
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
2063
|
+
return `❌ 清空失败:${m}`;
|
|
2064
|
+
}
|
|
2065
|
+
});
|
|
2066
|
+
// ─── /instruct 指令族:第三方行为指令 (per-persona) ───
|
|
2067
|
+
// /instruct 查看当前 persona 的所有指令
|
|
2068
|
+
// /instruct.add <text> 手动添加一条指令(authority=2)
|
|
2069
|
+
// /instruct.remove <id> 按 id 删除一条(authority=2)
|
|
2070
|
+
// /instruct.clear 清空当前 persona 的全部指令(authority=3, dangerous)
|
|
2071
|
+
useCommandService(ctx)
|
|
2072
|
+
.command('instruct', '查看当前 persona 的第三方行为指令')
|
|
2073
|
+
.action(async () => {
|
|
2074
|
+
if (!cfg.enableInstructions)
|
|
2075
|
+
return '🚫 第三方行为指令功能未启用。';
|
|
2076
|
+
const persona = getCurrentPersonaName();
|
|
2077
|
+
const doc = await loadInstructions();
|
|
2078
|
+
if (doc.instructions.length === 0) {
|
|
2079
|
+
return `📭 ${persona} 当前没有任何第三方行为指令。`;
|
|
2080
|
+
}
|
|
2081
|
+
const lines = doc.instructions.map(ins => {
|
|
2082
|
+
const tag = ins.severity === 'must' ? '【必须】' : ins.severity === 'avoid' ? '【避免】' : '【应当】';
|
|
2083
|
+
const cat = ins.category ? `[${ins.category}]` : '';
|
|
2084
|
+
const src = ins.sourceUserName
|
|
2085
|
+
? `(来自 ${ins.sourceUserName}${ins.sourceChannel === 'command' ? '·手动' : '·LLM'})`
|
|
2086
|
+
: '';
|
|
2087
|
+
return ` ${ins.id} ${tag}${cat} ${ins.text}${src}`;
|
|
2088
|
+
});
|
|
2089
|
+
return `📋 ${persona} 的第三方行为指令(共 ${doc.instructions.length} 条)\n${lines.join('\n')}`;
|
|
2090
|
+
});
|
|
2091
|
+
useCommandService(ctx)
|
|
2092
|
+
.command('instruct.add <text:text>', '手动添加一条第三方行为指令', { authority: 2 })
|
|
2093
|
+
.action(async (argv, text) => {
|
|
2094
|
+
if (!cfg.enableInstructions)
|
|
2095
|
+
return '🚫 第三方行为指令功能未启用。';
|
|
2096
|
+
const body = typeof text === 'string' ? text.trim() : '';
|
|
2097
|
+
if (!body)
|
|
2098
|
+
return '❌ 指令内容不能为空。';
|
|
2099
|
+
const userId = argv.session.userId;
|
|
2100
|
+
const sourceUserKey = userId ? userKeyOf(argv.session.platform, userId) : undefined;
|
|
2101
|
+
const sourceUserName = userId ?? '匿名';
|
|
2102
|
+
const doc = await loadInstructions();
|
|
2103
|
+
const merged = mergeInstructions(doc.instructions, [
|
|
2104
|
+
{
|
|
2105
|
+
text: body,
|
|
2106
|
+
severity: 'should',
|
|
2107
|
+
sourceUserKey,
|
|
2108
|
+
sourceUserName,
|
|
2109
|
+
sourceChannel: 'command',
|
|
2110
|
+
},
|
|
2111
|
+
], [], []);
|
|
2112
|
+
await saveInstructions({ instructions: merged, updatedAt: Date.now() });
|
|
2113
|
+
const added = merged.find(m => m.text === clipInstructionText(body));
|
|
2114
|
+
return `✅ 已添加行为指令${added ? `(id=${added.id})` : ''}:${clipInstructionText(body)}`;
|
|
2115
|
+
});
|
|
2116
|
+
useCommandService(ctx)
|
|
2117
|
+
.command('instruct.remove <id:string>', '按 id 删除一条第三方行为指令', { authority: 2 })
|
|
2118
|
+
.action(async (_argv, id) => {
|
|
2119
|
+
if (!cfg.enableInstructions)
|
|
2120
|
+
return '🚫 第三方行为指令功能未启用。';
|
|
2121
|
+
const targetId = typeof id === 'string' ? id.trim() : '';
|
|
2122
|
+
if (!targetId)
|
|
2123
|
+
return '❌ 需要提供指令 id。';
|
|
2124
|
+
const doc = await loadInstructions();
|
|
2125
|
+
const target = doc.instructions.find(i => i.id === targetId);
|
|
2126
|
+
if (!target)
|
|
2127
|
+
return `❌ 未找到 id=${targetId} 的指令。`;
|
|
2128
|
+
const merged = mergeInstructions(doc.instructions, [], [], [targetId]);
|
|
2129
|
+
await saveInstructions({ instructions: merged, updatedAt: Date.now() });
|
|
2130
|
+
return `✅ 已删除指令(id=${targetId}):${target.text}`;
|
|
2131
|
+
});
|
|
2132
|
+
useCommandService(ctx)
|
|
2133
|
+
.command('instruct.clear', '【危险】清空当前 persona 的全部第三方行为指令', {
|
|
2134
|
+
authority: 3,
|
|
2135
|
+
safety: 'dangerous',
|
|
2136
|
+
})
|
|
2137
|
+
.action(async () => {
|
|
2138
|
+
if (!cfg.enableInstructions)
|
|
2139
|
+
return '🚫 第三方行为指令功能未启用。';
|
|
2140
|
+
const memory = ctx.getService('memory');
|
|
2141
|
+
if (!memory?.deleteMetadata)
|
|
2142
|
+
return '记忆服务不支持指令删除。';
|
|
2143
|
+
try {
|
|
2144
|
+
const before = (await loadInstructions()).instructions.length;
|
|
2145
|
+
await memory.deleteMetadata(INSTRUCTIONS_NS, getInstructionsKey());
|
|
2146
|
+
return `✅ ${getCurrentPersonaName()} 的第三方行为指令已清空(删除 ${before} 条)。`;
|
|
2147
|
+
}
|
|
2148
|
+
catch (err) {
|
|
2149
|
+
return `❌ 清空失败:${err instanceof Error ? err.message : String(err)}`;
|
|
2150
|
+
}
|
|
2151
|
+
});
|
|
2152
|
+
ctx.logger.info(`用户事实档案已启用 (every=${cfg.extractEveryNMessages <= 0 ? '禁用提取' : `${cfg.extractEveryNMessages}msgs`}, history=${cfg.historyForExtraction}, ` +
|
|
2153
|
+
`maxFacts=${cfg.maxFactsPerUser}, feelings=${cfg.enableAalisFeelings ? `enabled(max ${cfg.maxFeelingsPerUser}${cfg.injectFeelingsForOthers ? `, others max ${cfg.maxFeelingsForOthers}` : ''})` : 'disabled'}, ` +
|
|
2154
|
+
`self=${cfg.enableSelfProfile ? `every ${cfg.selfReflectEveryNMessages}msgs/max ${cfg.maxSelfFacts}, key=${getSelfKey()}` : 'disabled'}, ` +
|
|
2155
|
+
`instructions=${cfg.enableInstructions ? `enabled(every ${cfg.instructionExtractEveryNMessages}msgs/max ${cfg.maxInstructions}/minAuth ${cfg.instructionMinAuthority}, key=${getInstructionsKey()})` : 'disabled'}, ` +
|
|
2156
|
+
`namespace=${PROFILE_NS})`);
|
|
2157
|
+
}
|
|
2158
|
+
//# sourceMappingURL=index.js.map
|