@aalis/plugin-user-profile 0.14.0 → 0.15.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/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +263 -226
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +90 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +90 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/texts.d.ts +179 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +96 -0
- package/dist/texts.js.map +1 -0
- package/package.json +18 -13
package/dist/index.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { commands } from '@aalis/api-commands';
|
|
2
2
|
import { contributions } from '@aalis/api-contributions';
|
|
3
3
|
import { hooks } from '@aalis/api-hooks';
|
|
4
|
-
import {
|
|
4
|
+
import { i18n, outputLanguageFor } from '@aalis/api-i18n';
|
|
5
|
+
import { chatModelPicker, lightestThinking, llm } from '@aalis/api-llm';
|
|
5
6
|
import { clearMetadataNamespaces, memory } from '@aalis/api-memory';
|
|
7
|
+
import { persona } from '@aalis/api-persona';
|
|
6
8
|
import { tools } from '@aalis/api-tools';
|
|
7
9
|
import { userRelation } from '@aalis/api-user-relation';
|
|
8
10
|
import { webuiServer } from '@aalis/api-webui';
|
|
9
11
|
import { config, definePlugin, events, logger, optional } from '@aalis/core';
|
|
10
12
|
import { defineConfig, parseConfig } from '@aalis/schema-config';
|
|
11
|
-
import {
|
|
13
|
+
import { TextError, textClauses, textLines, textOf } from '@aalis/schema-i18n';
|
|
14
|
+
import { DIRECTIVE_KINDS, WellKnownMetadataKeys, wasAddressed } from '@aalis/schema-message';
|
|
12
15
|
import { parseLLMJsonObject } from '@aalis/util-json-repair';
|
|
16
|
+
import { t } from './texts.js';
|
|
13
17
|
// ════════════════════════════════════════════════════════════
|
|
14
18
|
// plugin-user-profile — 用户事实档案
|
|
15
19
|
//
|
|
@@ -23,7 +27,7 @@ import { parseLLMJsonObject } from '@aalis/util-json-repair';
|
|
|
23
27
|
// - 渐进合并:每次提取由 LLM 输出 add/remove 两个数组,自然演化
|
|
24
28
|
// - 零侵入:通过 events + middleware 接入,不修改 agent / persona / memory
|
|
25
29
|
//
|
|
26
|
-
// 关系强度(relationScore, 0~100
|
|
30
|
+
// 关系强度(relationScore, 0~100)计算规则(只算真人的话,带来源的注入不加):
|
|
27
31
|
// - 每条入站消息(无论 agent 是否回复):+ relationIncrementWitness(默认 0.1,旁观档)
|
|
28
32
|
// - 若 agent 触发回复,额外叠加:
|
|
29
33
|
// direct(私聊) + relationIncrementDirect 默认 1.0
|
|
@@ -34,103 +38,105 @@ import { parseLLMJsonObject } from '@aalis/util-json-repair';
|
|
|
34
38
|
// 例:群聊纯旁观一条 = +0.1;群聊被 @ 并回复 = +0.1 + 1.5 = +1.6;私聊一条 = +0.1 + 1.0 = +1.1
|
|
35
39
|
// ════════════════════════════════════════════════════════════
|
|
36
40
|
const PROFILE_NS = 'user:profile';
|
|
41
|
+
/** 人设服务不在、或卡没取名时,提取提示里对机器人的称呼 */
|
|
42
|
+
const BOT_FALLBACK_NAME = '机器人';
|
|
37
43
|
const configSchema = defineConfig({
|
|
38
44
|
extractEveryNMessages: {
|
|
39
45
|
type: 'number',
|
|
40
|
-
label: '
|
|
41
|
-
description: '
|
|
46
|
+
label: t('extractEveryLabel'),
|
|
47
|
+
description: t('extractEveryDescription'),
|
|
42
48
|
default: 5,
|
|
43
49
|
},
|
|
44
50
|
historyForExtraction: {
|
|
45
51
|
type: 'number',
|
|
46
|
-
label: '
|
|
47
|
-
description: '
|
|
52
|
+
label: t('historyLabel'),
|
|
53
|
+
description: t('historyDescription'),
|
|
48
54
|
default: 8,
|
|
49
55
|
},
|
|
50
56
|
maxFactsPerUser: {
|
|
51
57
|
type: 'number',
|
|
52
|
-
label: '
|
|
53
|
-
description: '
|
|
58
|
+
label: t('maxFactsLabel'),
|
|
59
|
+
description: t('maxFactsDescription'),
|
|
54
60
|
default: 30,
|
|
55
61
|
},
|
|
56
62
|
maxFactCharsPerItem: {
|
|
57
63
|
type: 'number',
|
|
58
|
-
label: '
|
|
59
|
-
description: '
|
|
64
|
+
label: t('maxFactCharsLabel'),
|
|
65
|
+
description: t('maxFactCharsDescription'),
|
|
60
66
|
default: 80,
|
|
61
67
|
},
|
|
62
68
|
maxOtherParticipants: {
|
|
63
69
|
type: 'number',
|
|
64
|
-
label: '
|
|
65
|
-
description: '
|
|
70
|
+
label: t('maxOthersLabel'),
|
|
71
|
+
description: t('maxOthersDescription'),
|
|
66
72
|
default: 3,
|
|
67
73
|
},
|
|
68
74
|
maxFactsForOthers: {
|
|
69
75
|
type: 'number',
|
|
70
|
-
label: '
|
|
71
|
-
description: '
|
|
76
|
+
label: t('maxFactsForOthersLabel'),
|
|
77
|
+
description: t('maxFactsForOthersDescription'),
|
|
72
78
|
default: 5,
|
|
73
79
|
},
|
|
74
80
|
temporaryFactMaxAgeDays: {
|
|
75
81
|
type: 'number',
|
|
76
|
-
label: '
|
|
77
|
-
description: '
|
|
82
|
+
label: t('temporaryMaxAgeLabel'),
|
|
83
|
+
description: t('temporaryMaxAgeDescription'),
|
|
78
84
|
default: 90,
|
|
79
85
|
},
|
|
80
86
|
relationScoreDecayPerDay: {
|
|
81
87
|
type: 'number',
|
|
82
|
-
label: '
|
|
83
|
-
description: '
|
|
88
|
+
label: t('decayLabel'),
|
|
89
|
+
description: t('decayDescription'),
|
|
84
90
|
default: 0.5,
|
|
85
91
|
},
|
|
86
92
|
relationIncrementDirect: {
|
|
87
93
|
type: 'number',
|
|
88
|
-
label: '
|
|
89
|
-
description: '
|
|
94
|
+
label: t('directLabel'),
|
|
95
|
+
description: t('directDescription'),
|
|
90
96
|
default: 1,
|
|
91
97
|
},
|
|
92
98
|
relationIncrementImmediate: {
|
|
93
99
|
type: 'number',
|
|
94
|
-
label: '
|
|
95
|
-
description: '
|
|
100
|
+
label: t('immediateLabel'),
|
|
101
|
+
description: t('immediateDescription'),
|
|
96
102
|
default: 1.5,
|
|
97
103
|
},
|
|
98
104
|
relationIncrementInterval: {
|
|
99
105
|
type: 'number',
|
|
100
|
-
label: '
|
|
101
|
-
description: '
|
|
106
|
+
label: t('intervalLabel'),
|
|
107
|
+
description: t('intervalDescription'),
|
|
102
108
|
default: 0.5,
|
|
103
109
|
},
|
|
104
110
|
relationIncrementWitness: {
|
|
105
111
|
type: 'number',
|
|
106
|
-
label: '
|
|
107
|
-
description: '
|
|
112
|
+
label: t('witnessLabel'),
|
|
113
|
+
description: t('witnessDescription'),
|
|
108
114
|
default: 0.1,
|
|
109
115
|
},
|
|
110
|
-
extractLLM: {
|
|
111
|
-
type: 'llm-ref',
|
|
112
|
-
label: '提取用模型',
|
|
113
|
-
description: '留空则使用当前 LLM 服务的默认模型。事实提取是简单结构化任务,推荐选择廉价/快速模型(如 deepseek-chat)以降低成本',
|
|
114
|
-
},
|
|
115
116
|
allowGlobalBackfill: {
|
|
116
117
|
type: 'boolean',
|
|
117
118
|
onInvalid: 'error',
|
|
118
|
-
label: '
|
|
119
|
-
description: '
|
|
119
|
+
label: t('backfillLabel'),
|
|
120
|
+
description: t('backfillDescription'),
|
|
120
121
|
default: false,
|
|
121
122
|
},
|
|
122
123
|
});
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
'
|
|
129
|
-
'
|
|
130
|
-
'
|
|
131
|
-
'
|
|
132
|
-
'
|
|
133
|
-
|
|
124
|
+
/**
|
|
125
|
+
* 事实分类,用于 LLM 在同类下做覆写决策。分类值是给模型的、落盘的标识,界面显示这里的名字;
|
|
126
|
+
* 次序即提示词里列出与档案分组的次序
|
|
127
|
+
*/
|
|
128
|
+
const CATEGORY_NAMES = {
|
|
129
|
+
兴趣爱好: t('categoryInterests'),
|
|
130
|
+
职业身份: t('categoryOccupation'),
|
|
131
|
+
人际关系: t('categoryRelationships'),
|
|
132
|
+
近期处境: t('categorySituation'),
|
|
133
|
+
价值观: t('categoryValues'),
|
|
134
|
+
性格特征: t('categoryPersonality'),
|
|
135
|
+
偏好: t('categoryPreferences'),
|
|
136
|
+
忌讳: t('categoryTaboos'),
|
|
137
|
+
其他: t('categoryOther'),
|
|
138
|
+
};
|
|
139
|
+
const KNOWN_CATEGORIES = Object.keys(CATEGORY_NAMES);
|
|
134
140
|
/** 生成事实的稳定短 ID(前缀 f 加 5 位 base36,对 30 条以内规模碰撞概率极低) */
|
|
135
141
|
function genShortId(existing) {
|
|
136
142
|
for (let i = 0; i < 8; i++) {
|
|
@@ -171,31 +177,34 @@ function isTargetUserMessage(message, userId, platform) {
|
|
|
171
177
|
return !msgPlatform || !platform || msgPlatform === platform;
|
|
172
178
|
}
|
|
173
179
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
180
|
+
* 核对原话的规整,原话与语料用同一份:NFKC(全角与半角、兼容写法归成同一个字)、小写,去掉 Unicode 标点、空白与不显示的字符
|
|
181
|
+
* (表情里的变体选择符与连接符);符号照留,「C++」不会只剩一个字母
|
|
176
182
|
*/
|
|
177
183
|
function normalizeForQuoteMatch(s) {
|
|
178
184
|
return s
|
|
185
|
+
.normalize('NFKC')
|
|
179
186
|
.toLowerCase()
|
|
180
|
-
.replace(/[\s\
|
|
181
|
-
.replace(/[。,、;:?!“”‘’()《》【】.,;:?!"'()[\]<>\-_~`*]/g, '');
|
|
187
|
+
.replace(/[\p{P}\s\p{Default_Ignorable_Code_Point}]+/gu, '');
|
|
182
188
|
}
|
|
183
189
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
190
|
+
* 目标用户各条发言规整后的语料,原话的片段要落在其中某一条里。只收发言人自己写的字(存档另记的 SpokenContent,没记就是归档
|
|
191
|
+
* 正文本身):被引用的原文、识图、语音转写、视频描述、文件正文与合并转发都不在发言人的话里
|
|
186
192
|
*/
|
|
187
193
|
function buildTargetUserCorpus(history, userId, platform) {
|
|
188
194
|
return history
|
|
189
195
|
.filter(m => isTargetUserMessage(m, userId, platform))
|
|
190
|
-
.map(m =>
|
|
191
|
-
.
|
|
196
|
+
.map(m => {
|
|
197
|
+
const spoken = m.metadata?.[WellKnownMetadataKeys.SpokenContent];
|
|
198
|
+
return normalizeForQuoteMatch(typeof spoken === 'string' ? spoken : (m.content ?? ''));
|
|
199
|
+
});
|
|
192
200
|
}
|
|
193
201
|
/**
|
|
194
202
|
* 渲染完整会话历史供提取 LLM 作为消歧上下文:
|
|
195
|
-
*
|
|
196
|
-
* 不做 strip
|
|
203
|
+
* 每行带身份标签区分「目标用户」「其他用户」与她自己(botName),
|
|
204
|
+
* 不做 strip:给模型看的是归档正文(引用、附件描述与转发都在,消歧要用);
|
|
205
|
+
* 核对原话另取发言人自己写的话(buildTargetUserCorpus)。
|
|
197
206
|
*/
|
|
198
|
-
function renderHistoryForExtract(history, userId, platform) {
|
|
207
|
+
function renderHistoryForExtract(history, userId, platform, botName) {
|
|
199
208
|
return history
|
|
200
209
|
.filter(m => (m.role === 'user' || m.role === 'assistant' || m.role === 'notice') && m.content)
|
|
201
210
|
.map(m => {
|
|
@@ -204,8 +213,8 @@ function renderHistoryForExtract(history, userId, platform) {
|
|
|
204
213
|
if (!content)
|
|
205
214
|
return '';
|
|
206
215
|
if (m.role === 'assistant') {
|
|
207
|
-
//
|
|
208
|
-
return `[${time}] [
|
|
216
|
+
// 她的回复:加标签让 LLM 知道用户是在跟谁互动,用于判断用户对她的行为
|
|
217
|
+
return `[${time}] [${botName}]: ${content}`;
|
|
209
218
|
}
|
|
210
219
|
if (m.role === 'notice') {
|
|
211
220
|
// 平台事件(群禁言 / 撤回 / 成员进出 / poke 等),供提取判断「用户被谁怎么了」之类上下文
|
|
@@ -222,6 +231,13 @@ function renderHistoryForExtract(history, userId, platform) {
|
|
|
222
231
|
.filter(Boolean)
|
|
223
232
|
.join('\n');
|
|
224
233
|
}
|
|
234
|
+
/** 解析一次提取的回复:在输出上限处停下的不完整,看着是合法 JSON 也不收。不能用时交回原因,写进日志与重试提示 */
|
|
235
|
+
function readExtractReply(resp) {
|
|
236
|
+
if (resp.finishReason === 'length')
|
|
237
|
+
return { why: '在长度上限处被截断,不完整' };
|
|
238
|
+
const { parsed } = parseLLMJsonObject((resp.content ?? '').trim());
|
|
239
|
+
return parsed ? { parsed } : { why: '不是合法的 JSON 对象' };
|
|
240
|
+
}
|
|
225
241
|
const uses = {
|
|
226
242
|
memory,
|
|
227
243
|
llm,
|
|
@@ -233,18 +249,22 @@ const uses = {
|
|
|
233
249
|
tools: optional(tools),
|
|
234
250
|
commands: optional(commands),
|
|
235
251
|
userRelation: optional(userRelation),
|
|
252
|
+
/** 提取提示里机器人的称呼取人设名;缺席时用中性称呼 */
|
|
253
|
+
persona: optional(persona),
|
|
254
|
+
/** 事实的语言:取全局层的语言列表(读者是 owner),缺席时按中文 */
|
|
255
|
+
i18n: optional(i18n),
|
|
236
256
|
/** 档案页;不在时照常记档案 */
|
|
237
257
|
webui: optional(webuiServer),
|
|
238
258
|
};
|
|
239
259
|
export default definePlugin({
|
|
240
260
|
name: '@aalis/plugin-user-profile',
|
|
241
|
-
displayName: '
|
|
261
|
+
displayName: t('displayName'),
|
|
242
262
|
subsystem: 'memory',
|
|
243
263
|
configSchema,
|
|
244
264
|
uses,
|
|
245
265
|
apply: registerUserProfile,
|
|
246
266
|
});
|
|
247
|
-
function registerUserProfile({ memory, llm, events, hooks, contributions, config, logger, tools, commands, userRelation, webui, }) {
|
|
267
|
+
function registerUserProfile({ memory, llm, events, hooks, contributions, config, logger, tools, commands, userRelation, persona, i18n, webui, }) {
|
|
248
268
|
const parsed = parseConfig(configSchema, config.use, logger.use);
|
|
249
269
|
const cfg = {
|
|
250
270
|
...parsed,
|
|
@@ -259,17 +279,13 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
259
279
|
relationIncrementImmediate: Math.max(0, parsed.relationIncrementImmediate),
|
|
260
280
|
relationIncrementInterval: Math.max(0, parsed.relationIncrementInterval),
|
|
261
281
|
relationIncrementWitness: Math.max(0, parsed.relationIncrementWitness),
|
|
262
|
-
extractLLM: parsed.extractLLM &&
|
|
263
|
-
typeof parsed.extractLLM === 'object' &&
|
|
264
|
-
parsed.extractLLM.provider &&
|
|
265
|
-
parsed.extractLLM.model
|
|
266
|
-
? parsed.extractLLM
|
|
267
|
-
: undefined,
|
|
268
282
|
};
|
|
269
283
|
/** 每会话每用户累计入站消息数(用于 extractEveryNMessages 计数),不随提取重置 */
|
|
270
284
|
const userMessageCount = new Map();
|
|
271
285
|
/** 防止同一用户的提取并发触发 */
|
|
272
286
|
const inflightExtractions = new Set();
|
|
287
|
+
/** 提取模型:llm 服务给本插件挑的能对话的那一个(见 chatModelPicker) */
|
|
288
|
+
const pickChatModel = chatModelPicker(llm, () => logger.use.warn('没有可用的对话模型,事实提取跳过'));
|
|
273
289
|
const relationScorePrecision = Math.min(6, Math.max(decimalPlaces(cfg.relationScoreDecayPerDay), decimalPlaces(cfg.relationIncrementDirect), decimalPlaces(cfg.relationIncrementImmediate), decimalPlaces(cfg.relationIncrementInterval)));
|
|
274
290
|
function userKeyOf(platform, userId) {
|
|
275
291
|
return `${platform ?? ''}:${userId}`;
|
|
@@ -387,19 +403,19 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
387
403
|
ref = undefined;
|
|
388
404
|
}
|
|
389
405
|
if (!Array.isArray(ref) || ref.length !== 2 || !ref.every(part => typeof part === 'string')) {
|
|
390
|
-
throw new
|
|
406
|
+
throw new TextError(t('unknownFact', { rowKey }));
|
|
391
407
|
}
|
|
392
408
|
return ref;
|
|
393
409
|
}
|
|
394
410
|
const page = webui.use.definePage({
|
|
395
411
|
key: 'profiles',
|
|
396
|
-
title: '
|
|
412
|
+
title: t('profiles'),
|
|
397
413
|
icon: 'contact',
|
|
398
414
|
actions: {
|
|
399
415
|
forgetFact: {
|
|
400
|
-
label: '
|
|
416
|
+
label: t('forgetLabel'),
|
|
401
417
|
effect: 'destructive',
|
|
402
|
-
confirm: '
|
|
418
|
+
confirm: t('forgetConfirm'),
|
|
403
419
|
row: true,
|
|
404
420
|
async handler({ rowKey }) {
|
|
405
421
|
const [userKey, factId] = parseFactRef(rowKey);
|
|
@@ -407,28 +423,28 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
407
423
|
const profile = await loadProfile(mem, userKey);
|
|
408
424
|
const hit = profile?.facts.find(f => f.id === factId);
|
|
409
425
|
if (!profile || !hit)
|
|
410
|
-
throw new
|
|
426
|
+
throw new TextError(t('factGone'));
|
|
411
427
|
await saveProfile(mem, userKey, {
|
|
412
428
|
...profile,
|
|
413
429
|
facts: profile.facts.filter(f => f !== hit),
|
|
414
430
|
updatedAt: Date.now(),
|
|
415
431
|
});
|
|
416
432
|
page.invalidate();
|
|
417
|
-
return
|
|
433
|
+
return t('forgotten', { text: hit.text });
|
|
418
434
|
},
|
|
419
435
|
},
|
|
420
436
|
clearProfile: {
|
|
421
|
-
label: '
|
|
437
|
+
label: t('clearProfileLabel'),
|
|
422
438
|
effect: 'destructive',
|
|
423
|
-
confirm: '
|
|
439
|
+
confirm: t('clearProfileConfirm'),
|
|
424
440
|
row: true,
|
|
425
441
|
async handler({ rowKey }) {
|
|
426
442
|
const mem = pinnedMemory();
|
|
427
443
|
if (!(await mem.getMetadata(PROFILE_NS, rowKey)))
|
|
428
|
-
throw new
|
|
444
|
+
throw new TextError(t('profileGone', { key: rowKey }));
|
|
429
445
|
await mem.deleteMetadata(PROFILE_NS, rowKey);
|
|
430
446
|
page.invalidate();
|
|
431
|
-
return
|
|
447
|
+
return t('profileCleared', { key: rowKey });
|
|
432
448
|
},
|
|
433
449
|
},
|
|
434
450
|
},
|
|
@@ -437,12 +453,12 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
437
453
|
type: 'table',
|
|
438
454
|
id: 'profiles',
|
|
439
455
|
rowKey: 'key',
|
|
440
|
-
description: '
|
|
441
|
-
empty: '
|
|
456
|
+
description: t('pageDescription'),
|
|
457
|
+
empty: t('pageEmpty'),
|
|
442
458
|
columns: [
|
|
443
|
-
{ key: 'key', label: '
|
|
444
|
-
{ key: 'facts', label: '
|
|
445
|
-
{ key: 'updatedAt', label: '
|
|
459
|
+
{ key: 'key', label: t('columnUser'), primary: true },
|
|
460
|
+
{ key: 'facts', label: t('columnFacts'), primary: true },
|
|
461
|
+
{ key: 'updatedAt', label: t('columnLastNoted'), kind: 'time' },
|
|
446
462
|
],
|
|
447
463
|
async data() {
|
|
448
464
|
const entries = await memory.use.listMetadata(PROFILE_NS);
|
|
@@ -460,12 +476,12 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
460
476
|
type: 'table',
|
|
461
477
|
id: 'facts',
|
|
462
478
|
rowKey: 'ref',
|
|
463
|
-
empty: '
|
|
479
|
+
empty: t('factsEmpty'),
|
|
464
480
|
columns: [
|
|
465
|
-
{ key: 'text', label: '
|
|
466
|
-
{ key: 'category', label: '
|
|
467
|
-
{ key: 'temporality', label: '
|
|
468
|
-
{ key: 'updatedAt', label: '
|
|
481
|
+
{ key: 'text', label: t('columnFact'), primary: true },
|
|
482
|
+
{ key: 'category', label: t('columnCategory') },
|
|
483
|
+
{ key: 'temporality', label: t('columnTerm') },
|
|
484
|
+
{ key: 'updatedAt', label: t('columnNoted'), kind: 'time' },
|
|
469
485
|
],
|
|
470
486
|
rowActions: [{ action: 'forgetFact' }],
|
|
471
487
|
async data({ rowKey }) {
|
|
@@ -475,8 +491,8 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
475
491
|
.map(f => ({
|
|
476
492
|
ref: JSON.stringify([rowKey, f.id]),
|
|
477
493
|
text: f.text,
|
|
478
|
-
category: f.category
|
|
479
|
-
temporality: f.temporality === 'temporary' ? '
|
|
494
|
+
category: f.category ? CATEGORY_NAMES[f.category] : '',
|
|
495
|
+
temporality: f.temporality === 'temporary' ? t('termShort') : t('termLong'),
|
|
480
496
|
updatedAt: f.updatedAt,
|
|
481
497
|
}));
|
|
482
498
|
},
|
|
@@ -499,14 +515,14 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
499
515
|
if (!x || typeof x !== 'object')
|
|
500
516
|
return [];
|
|
501
517
|
const o = x;
|
|
502
|
-
const
|
|
503
|
-
if (!
|
|
518
|
+
const text = typeof o.text === 'string' ? o.text.trim() : '';
|
|
519
|
+
if (!text)
|
|
504
520
|
return [];
|
|
505
521
|
const category = normalizeCategory(o.category);
|
|
506
522
|
const sourceQuote = typeof o.sourceQuote === 'string' ? o.sourceQuote.trim() : '';
|
|
507
523
|
return [
|
|
508
524
|
{
|
|
509
|
-
text
|
|
525
|
+
text,
|
|
510
526
|
category,
|
|
511
527
|
temporality: normalizeTemporality(o.temporality, category),
|
|
512
528
|
timeHint: normalizeTextField(o.timeHint),
|
|
@@ -521,15 +537,15 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
521
537
|
return [];
|
|
522
538
|
const o = x;
|
|
523
539
|
const id = typeof o.id === 'string' ? o.id.trim() : '';
|
|
524
|
-
const
|
|
525
|
-
if (!id || !
|
|
540
|
+
const text = typeof o.text === 'string' ? o.text.trim() : '';
|
|
541
|
+
if (!id || !text)
|
|
526
542
|
return [];
|
|
527
543
|
const category = normalizeCategory(o.category);
|
|
528
544
|
const sourceQuote = typeof o.sourceQuote === 'string' ? o.sourceQuote.trim() : '';
|
|
529
545
|
return [
|
|
530
546
|
{
|
|
531
547
|
id,
|
|
532
|
-
text
|
|
548
|
+
text,
|
|
533
549
|
category,
|
|
534
550
|
temporality: normalizeTemporality(o.temporality, category),
|
|
535
551
|
timeHint: normalizeTextField(o.timeHint),
|
|
@@ -538,27 +554,38 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
538
554
|
];
|
|
539
555
|
})
|
|
540
556
|
: [];
|
|
557
|
+
// 只给 id 的字符串没有原话:当成只有 id 的一项,校验时按没给原话丢弃并 warn,不静默放过
|
|
541
558
|
const remove = Array.isArray(obj.remove)
|
|
542
|
-
? obj.remove
|
|
543
|
-
|
|
544
|
-
|
|
559
|
+
? obj.remove.flatMap(x => {
|
|
560
|
+
const item = typeof x === 'string' ? { id: x } : x;
|
|
561
|
+
if (!item || typeof item !== 'object')
|
|
562
|
+
return [];
|
|
563
|
+
const o = item;
|
|
564
|
+
const id = typeof o.id === 'string' ? o.id.trim() : '';
|
|
565
|
+
if (!id)
|
|
566
|
+
return [];
|
|
567
|
+
const sourceQuote = typeof o.sourceQuote === 'string' ? o.sourceQuote.trim() : '';
|
|
568
|
+
return [{ id, sourceQuote }];
|
|
569
|
+
})
|
|
545
570
|
: [];
|
|
546
571
|
return { add, update, remove };
|
|
547
572
|
}
|
|
548
573
|
/** 调用 LLM 从历史中提取/修订事实,返回 add / update / remove 三类操作。 */
|
|
549
|
-
async function llmExtractFacts(history, existingFacts, nickname, userId, platform) {
|
|
574
|
+
async function llmExtractFacts(history, existingFacts, nickname, userId, platform, sessionId) {
|
|
550
575
|
const empty = {
|
|
551
576
|
add: [],
|
|
552
577
|
update: [],
|
|
553
578
|
remove: [],
|
|
554
579
|
};
|
|
580
|
+
// 她在提示里的称呼取这个会话的人设名,标签、规则与例句同一个名字
|
|
581
|
+
const botName = (persona.present ? persona.use.getPersonaName({ sessionId, platform }) : '') || BOT_FALLBACK_NAME;
|
|
555
582
|
const sys = '你是用户档案管理员。输入是一段多用户会话历史,每行开头有身份标签:' +
|
|
556
|
-
|
|
557
|
-
|
|
583
|
+
`\`[目标用户 X(uid)]\`、\`[其他用户 Y(uid)]\` 或 \`[${botName}]\`。上下文仅供消歧,` +
|
|
584
|
+
`**只允许根据「目标用户」自己的发言写入事实**,不能根据其他用户、${botName} 的猜测或提问写入事实。` +
|
|
558
585
|
'\n\n你可以执行三种操作(在一次输出里组合):' +
|
|
559
586
|
'\n- add: 添加新事实,需指定 text、category、sourceQuote' +
|
|
560
587
|
'\n- update: 用 id 精确替换某条已知事实,同样需 sourceQuote(优先使用 update 而非 add 来避免重复)' +
|
|
561
|
-
'\n- remove: 用 id
|
|
588
|
+
'\n- remove: 用 id 删除已被目标用户推翻的事实,同样需 sourceQuote(目标用户推翻它的那句原话)' +
|
|
562
589
|
'\n\n规则:' +
|
|
563
590
|
'\n1. 事实的**主语必须是「目标用户」本人**,不能是 ta 转述、讨论、评价的第三方。' +
|
|
564
591
|
'即使 sourceQuote 出自目标用户原话,也要先在脑中追问:' +
|
|
@@ -566,34 +593,34 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
566
593
|
'\n - 是认真陈述,还是反讽/否定/假设/角色扮演?("我可不喜欢爵士" → 否定,不写;跑团扮演 NPC 的台词 → 不写)' +
|
|
567
594
|
'\n - 如果原始依据来自「其他用户」的发言,即使在说目标用户,也不允许写入(他人可能记错、说反话)' +
|
|
568
595
|
'\n **拿不准就不写。profile 漏几条没关系,写错才麻烦。**' +
|
|
569
|
-
|
|
596
|
+
`\n ⚠️ 例外——\`人际关系\` 类:除上述通用规则外,该类还允许记录目标用户**与 ${botName} 之间长期可观察的关系模式**,` +
|
|
570
597
|
'包含两种情况:' +
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
598
|
+
`\n (a) 行为身份类:用户围绕 ${botName} 扮演的角色或长期重复的行为` +
|
|
599
|
+
`(示例 text:「是 ${botName} 的开发维护者」「经常向 ${botName} 提交功能建议」「常帮助测试和排查 ${botName} 的故障」)` +
|
|
600
|
+
`\n (b) 长期情感模式类:用户对 ${botName} 表现出的**反复出现的态度倾向**(不是单次情绪)` +
|
|
601
|
+
`(示例 text:「常对 ${botName} 表达依赖与喜爱」「习惯把日常烦恼向 ${botName} 倾诉」「喜欢调侃 ${botName}」)` +
|
|
575
602
|
'\n ❌ 严禁仅凭单次发言推断情感事实:一次抱怨/一次感谢/一次撒娇都不算"模式",不写。' +
|
|
576
603
|
'必须是已有事实佐证 + 本轮再次出现的同类行为才允许 update 强化,或会话历史多处反复出现才允许 add。' +
|
|
577
604
|
'依据必须是「目标用户」发言中可直接推断的内容,sourceQuote 取目标用户发言原话。' +
|
|
578
|
-
'\n2. **sourceQuote
|
|
605
|
+
'\n2. **sourceQuote 必须是「目标用户」自己发言中的原话片段**,可以是某一句话的一部分,必须能一字不改在「目标用户」某行的原文中找到;不要采用「其他用户」的原话,不要自己总结改写;' +
|
|
606
|
+
'只算 ta 自己写的字:行首的发送者前缀、行里附带的「[引用 … 的消息: …]」、图片与语音视频的描述、文件内容、合并转发都不算' +
|
|
579
607
|
'\n3. 在同一 category 下,如果新信息与已有事实在含义上重叠(例如已知"喜欢猫",新信息"还喜欢狗"),应以 update 改写原 id 为更全面的版本,而不是 add 再加一条' +
|
|
580
608
|
'\n4. 如果新对话明确推翻或修正了某条已知事实(如已知"在北京工作",但用户说"我刚搬到上海"),用 update 替换或 remove 删除' +
|
|
581
|
-
`\n5. 每条 text
|
|
609
|
+
`\n5. 每条 text 用一句简洁的 ${outputLanguageFor(i18n, { host: true }, 'zh-CN')},不超过 ${cfg.maxFactCharsPerItem} 个字符,不带「用户」「他」等代词,直接陈述事实` +
|
|
582
610
|
'\n6. 如果没有任何更新,或所有候选都拿不准,三个数组都返回空——**保守优先**' +
|
|
583
611
|
`\n7. category 必须是以下之一:${KNOWN_CATEGORIES.join('、')}` +
|
|
584
612
|
'\n8. 每条 add/update 都必须给出 temporality:长期稳定偏好、性格、身份、人际关系用 permanent;近期状态、正在进行的事、短期计划用 temporary' +
|
|
585
613
|
'\n9. 如果对话中出现明确或隐含时间(如“最近”“上周”“今年4月”“昨天”),用 timeHint 记录简短时间线索;没有就省略或用空字符串' +
|
|
586
614
|
'\n\n输出严格的 JSON(不要其他文本):' +
|
|
587
|
-
'\n{"add": [{"text": "...", "category": "...", "temporality": "permanent|temporary", "timeHint": "...", "sourceQuote": "目标用户发言中的原话片段"}], "update": [{"id": "已知事实的id", "text": "新表述", "category": "...", "temporality": "permanent|temporary", "timeHint": "...", "sourceQuote": "目标用户发言中的原话片段"}], "remove": ["已知事实的id"]}';
|
|
615
|
+
'\n{"add": [{"text": "...", "category": "...", "temporality": "permanent|temporary", "timeHint": "...", "sourceQuote": "目标用户发言中的原话片段"}], "update": [{"id": "已知事实的id", "text": "新表述", "category": "...", "temporality": "permanent|temporary", "timeHint": "...", "sourceQuote": "目标用户发言中的原话片段"}], "remove": [{"id": "已知事实的id", "sourceQuote": "目标用户发言中的原话片段"}]}';
|
|
588
616
|
const factListText = existingFacts.length > 0
|
|
589
617
|
? existingFacts.map(f => `[${f.id}] (${f.category ?? '未分类'}) ${f.text}`).join('\n')
|
|
590
618
|
: '(暂无)';
|
|
591
619
|
const who = nickname ? `${nickname}(${userId})` : userId;
|
|
592
|
-
const renderedHistory = renderHistoryForExtract(history, userId, platform);
|
|
620
|
+
const renderedHistory = renderHistoryForExtract(history, userId, platform, botName);
|
|
593
621
|
const user = `# 提取目标\n${who}\n\n# 已知事实(带 id,请在 update/remove 中精确引用 id)\n${factListText}` +
|
|
594
622
|
`\n\n# 会话历史(含多用户,仅供消歧;只能从「目标用户」发言中提取事实)\n${renderedHistory || '(暂无会话历史)'}`;
|
|
595
|
-
|
|
596
|
-
const entry = resolveLLMModel(llm, cfg.extractLLM, ['chat']);
|
|
623
|
+
const entry = pickChatModel();
|
|
597
624
|
if (!entry)
|
|
598
625
|
return empty;
|
|
599
626
|
const extractLlm = entry.use;
|
|
@@ -608,21 +635,19 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
608
635
|
maxTokens: 800,
|
|
609
636
|
think: lightestThinking(extractLlm),
|
|
610
637
|
});
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
// 写成了纯文本/markdown。给一次显式反馈再来一次,避免一次失败丢失整批事实。
|
|
618
|
-
logger.use.warn(`[user-profile] LLM 输出无法解析为 JSON,尝试重试一次。原文前 200 字:${text.slice(0, 200)}`);
|
|
638
|
+
const text = (resp.content ?? '').trim();
|
|
639
|
+
let reply = readExtractReply(resp);
|
|
640
|
+
if ('why' in reply) {
|
|
641
|
+
// 截断,或 util-json-repair 剥过围栏、修过裸引号仍不是合法 JSON(空回复也算:提示词要求没有事实也回三个空数组):给一次
|
|
642
|
+
// 显式反馈再来一次,避免一次失败丢失整批事实
|
|
643
|
+
logger.use.warn(`[user-profile] LLM 输出${reply.why},尝试重试一次。原文前 200 字:${text.slice(0, 200)}`);
|
|
619
644
|
const retryResp = await extractLlm.chat({
|
|
620
645
|
messages: [
|
|
621
646
|
...baseMessages,
|
|
622
647
|
{ role: 'assistant', content: text },
|
|
623
648
|
{
|
|
624
649
|
role: 'user',
|
|
625
|
-
content:
|
|
650
|
+
content: `你上一条输出${reply.why}。` +
|
|
626
651
|
'请只输出**一个**合法的 JSON 对象,第一个字符必须是 `{`、最后一个字符必须是 `}`,' +
|
|
627
652
|
'禁止 ```json 围栏、禁止任何解释、禁止 markdown。如果实在没有可写入的事实,' +
|
|
628
653
|
'就输出 `{"add":[],"update":[],"remove":[]}`。',
|
|
@@ -632,44 +657,40 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
632
657
|
maxTokens: 800,
|
|
633
658
|
think: lightestThinking(extractLlm),
|
|
634
659
|
});
|
|
635
|
-
|
|
636
|
-
(
|
|
637
|
-
|
|
638
|
-
logger.use.warn(`[user-profile] LLM 重试后仍无法解析 JSON,放弃本批次。重试原文前 200 字:${text.slice(0, 200)}`);
|
|
660
|
+
reply = readExtractReply(retryResp);
|
|
661
|
+
if ('why' in reply) {
|
|
662
|
+
logger.use.warn(`[user-profile] LLM 重试后输出仍${reply.why},放弃本批次。重试原文前 200 字:${(retryResp.content ?? '').trim().slice(0, 200)}`);
|
|
639
663
|
return empty;
|
|
640
664
|
}
|
|
641
665
|
logger.use.debug('[user-profile] LLM 重试后解析成功');
|
|
642
666
|
}
|
|
643
|
-
const { add, update, remove } = parseFactOps(parsed);
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
667
|
+
const { add, update, remove } = parseFactOps(reply.parsed);
|
|
668
|
+
// 增改删都要附原话:按行拆开(quote 可能跨多条消息,LLM 用 \n 拼接),每行规整后(normalizeForQuoteMatch)要落在目标用户
|
|
669
|
+
// 自己写的某一条话里(见 buildTargetUserCorpus),任一片段找不到整条丢弃并 warn;规整后不含字母或数字的行不算片段,一个
|
|
670
|
+
// 片段都没有的按没给原话。模型会凭空编事实,也会把别人的话、图上的字记到他头上,核对原话挡的是这两种。不设字数下限:接在
|
|
671
|
+
// 问句后面的「是的」是合法的原话;也不核对事实能不能从原话推出来:每次提取要多一次调用
|
|
672
|
+
const targetCorpus = buildTargetUserCorpus(history, userId, platform);
|
|
673
|
+
const validateSourceQuote = (quote, kind, what) => {
|
|
674
|
+
const fragments = quote
|
|
675
|
+
.split(/\r?\n/)
|
|
676
|
+
.map(line => ({ line, normalized: normalizeForQuoteMatch(line) }))
|
|
677
|
+
.filter(({ normalized }) => /[\p{L}\p{N}]/u.test(normalized));
|
|
678
|
+
if (fragments.length === 0) {
|
|
679
|
+
logger.use.warn(`[user-profile] 丢弃 ${kind} fact(未提供 sourceQuote): ${what}`);
|
|
652
680
|
return false;
|
|
653
681
|
}
|
|
654
|
-
const
|
|
655
|
-
.
|
|
656
|
-
|
|
657
|
-
.filter(l => l.length > 0);
|
|
658
|
-
const fragments = lines.length > 0 ? lines : [q];
|
|
659
|
-
for (const frag of fragments) {
|
|
660
|
-
const normalized = normalizeForQuoteMatch(frag);
|
|
661
|
-
if (!normalized)
|
|
662
|
-
continue;
|
|
663
|
-
if (!targetCorpus.includes(normalized)) {
|
|
664
|
-
logger.use.warn(`[user-profile] 丢弃 ${kind} fact(sourceQuote 片段不在目标用户 ${userId} 发言中): text="${item.text}" fragment="${frag}"`);
|
|
682
|
+
for (const { line, normalized } of fragments) {
|
|
683
|
+
if (!targetCorpus.some(spoken => spoken.includes(normalized))) {
|
|
684
|
+
logger.use.warn(`[user-profile] 丢弃 ${kind} fact(sourceQuote 片段不在目标用户 ${userId} 发言中): ${what} fragment="${line.trim()}"`);
|
|
665
685
|
return false;
|
|
666
686
|
}
|
|
667
687
|
}
|
|
668
688
|
return true;
|
|
669
689
|
};
|
|
670
|
-
const validatedAdd = add.filter(item => validateSourceQuote(item, 'add'));
|
|
671
|
-
const validatedUpdate = update.filter(item => validateSourceQuote(item, 'update'));
|
|
672
|
-
|
|
690
|
+
const validatedAdd = add.filter(item => validateSourceQuote(item.sourceQuote, 'add', item.text));
|
|
691
|
+
const validatedUpdate = update.filter(item => validateSourceQuote(item.sourceQuote, 'update', item.text));
|
|
692
|
+
const validatedRemove = remove.filter(item => validateSourceQuote(item.sourceQuote, 'remove', `id=${item.id}`));
|
|
693
|
+
return { add: validatedAdd, update: validatedUpdate, remove: validatedRemove };
|
|
673
694
|
}
|
|
674
695
|
catch (err) {
|
|
675
696
|
logger.use.debug(`事实提取 LLM 调用失败:${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -688,8 +709,6 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
688
709
|
return cfg.relationIncrementImmediate;
|
|
689
710
|
if (triggerType === 'interval')
|
|
690
711
|
return cfg.relationIncrementInterval;
|
|
691
|
-
if (triggerType === 'idle')
|
|
692
|
-
return 0;
|
|
693
712
|
if (triggerType === 'witness')
|
|
694
713
|
return cfg.relationIncrementWitness;
|
|
695
714
|
return cfg.relationIncrementDirect;
|
|
@@ -705,12 +724,11 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
705
724
|
const daysSinceLast = last ? Math.max(0, (now - last) / 86_400_000) : 0;
|
|
706
725
|
const decayed = clampRelationScore((profile.relationScore ?? 0) - daysSinceLast * cfg.relationScoreDecayPerDay);
|
|
707
726
|
const nextScore = clampRelationScore(decayed + relationIncrementFor(triggerType));
|
|
708
|
-
const shouldCount = countInteraction && triggerType !== 'idle';
|
|
709
727
|
return {
|
|
710
728
|
...profile,
|
|
711
729
|
relationScore: nextScore,
|
|
712
|
-
interactionCount: (profile.interactionCount ?? 0) + (
|
|
713
|
-
lastInteractionAt:
|
|
730
|
+
interactionCount: (profile.interactionCount ?? 0) + (countInteraction ? 1 : 0),
|
|
731
|
+
lastInteractionAt: countInteraction ? now : profile.lastInteractionAt,
|
|
714
732
|
updatedAt: now,
|
|
715
733
|
};
|
|
716
734
|
}
|
|
@@ -813,14 +831,14 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
813
831
|
if (!history.some(m => isTargetUserMessage(m, userId, platform)))
|
|
814
832
|
return;
|
|
815
833
|
const snapshotFacts = (await loadProfile(mem, userKey))?.facts ?? [];
|
|
816
|
-
const ops = await llmExtractFacts(history, snapshotFacts, nickname, userId, platform);
|
|
834
|
+
const ops = await llmExtractFacts(history, snapshotFacts, nickname, userId, platform, sessionId);
|
|
817
835
|
const hasFactOps = ops.add.length > 0 || ops.update.length > 0 || ops.remove.length > 0;
|
|
818
836
|
if (!hasFactOps)
|
|
819
837
|
return;
|
|
820
838
|
// 以 LLM 返回后重读的档案为合并基底:调用期间档案可能被清空或删了事实,以调用前的快照
|
|
821
839
|
// 为基底会把删掉的内容整份写回;重读也保住期间写入的 relationScore 等字段
|
|
822
840
|
const fresh = (await loadProfile(mem, userKey)) ?? { facts: [], updatedAt: 0 };
|
|
823
|
-
const newFacts = mergeFactList(fresh.facts, ops.add, dropUpdatesOfDeletedFacts(ops.update, snapshotFacts, fresh.facts), ops.remove);
|
|
841
|
+
const newFacts = mergeFactList(fresh.facts, ops.add, dropUpdatesOfDeletedFacts(ops.update, snapshotFacts, fresh.facts), ops.remove.map(r => r.id));
|
|
824
842
|
await saveProfile(mem, userKey, {
|
|
825
843
|
...fresh,
|
|
826
844
|
facts: newFacts,
|
|
@@ -850,9 +868,10 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
850
868
|
// 注:钩子无优先级机制,同相位 handler 按插件激活序执行;本 handler 只做副作用
|
|
851
869
|
// (更新关系分数)不改消息,与其它 handler 顺序无关。
|
|
852
870
|
// 关系强度与"是否触发回复"绑定,因此仍走 agent:input:before 中间件。
|
|
871
|
+
// 只算真人的话,与旁观那一路同一个判据:带来源的注入(子任务的任务消息、好友申请通知等)不是谁在跟她说话,不加
|
|
853
872
|
hooks.use.middleware('agent:input:before', async (data, next) => {
|
|
854
|
-
const { userId, platform, triggerType } = data.message;
|
|
855
|
-
if (userId) {
|
|
873
|
+
const { userId, platform, triggerType, source } = data.message;
|
|
874
|
+
if (userId && !source) {
|
|
856
875
|
const userKey = userKeyOf(platform, userId);
|
|
857
876
|
try {
|
|
858
877
|
// 仅叠加 score,互动计数与时戳由 witness 路径统一负责,
|
|
@@ -871,7 +890,8 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
871
890
|
events.use.on('inbound:message:archived', data => {
|
|
872
891
|
const { sessionId, incoming } = data;
|
|
873
892
|
const { userId, platform, nickname } = incoming;
|
|
874
|
-
|
|
893
|
+
// 带来源的注入不是这个账号说的话:不计旁观、不计提取,也就不为它带的账号(如子任务的 parent:<父会话>)建档
|
|
894
|
+
if (!userId || incoming.source)
|
|
875
895
|
return;
|
|
876
896
|
// 旁观档位:每条入站消息都加 witness 增量(默认 0.1),与 agent 是否回复无关。
|
|
877
897
|
// agent:input:before 中间件在触发回复时会再叠加 direct/immediate/interval 增量。
|
|
@@ -905,36 +925,50 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
905
925
|
meta.push('临时状态');
|
|
906
926
|
return `- ${fact.text}(${meta.join(';')})`;
|
|
907
927
|
}
|
|
908
|
-
/**
|
|
928
|
+
/** 生效的事实按分类分组,次序同 KNOWN_CATEGORIES;没有分类的归「其他」。给模型的档案块与 /profile 共用 */
|
|
929
|
+
function groupFacts(facts) {
|
|
930
|
+
const active = facts.filter(isFactActive);
|
|
931
|
+
return KNOWN_CATEGORIES.flatMap(cat => {
|
|
932
|
+
const items = active.filter(f => (f.category ?? '其他') === cat);
|
|
933
|
+
return items.length > 0 ? [[cat, items]] : [];
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
/** 将一个用户的 Fact[] 渲染为分组文本块(给模型的) */
|
|
909
937
|
function renderProfileBlock(facts, label, compact) {
|
|
910
|
-
const groups = new Map();
|
|
911
|
-
const activeFacts = facts.filter(isFactActive);
|
|
912
|
-
// compact 模式(群聊背景参与者):只取最近更新的 N 条,不分组
|
|
913
|
-
const subset = compact
|
|
914
|
-
? [...activeFacts].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, cfg.maxFactsForOthers)
|
|
915
|
-
: activeFacts;
|
|
916
|
-
for (const f of subset) {
|
|
917
|
-
const key = f.category ?? '其他';
|
|
918
|
-
if (!groups.has(key))
|
|
919
|
-
groups.set(key, []);
|
|
920
|
-
groups.get(key).push(renderFactLine(f, !compact));
|
|
921
|
-
}
|
|
922
938
|
if (compact) {
|
|
923
|
-
// compact
|
|
939
|
+
// compact 模式(群聊背景参与者):只取最近更新的 N 条,平铺,不加分组标题
|
|
940
|
+
const subset = facts
|
|
941
|
+
.filter(isFactActive)
|
|
942
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
943
|
+
.slice(0, cfg.maxFactsForOthers);
|
|
924
944
|
return `### ${label}\n${subset.map(f => renderFactLine(f, false)).join('\n')}`;
|
|
925
945
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
946
|
+
return groupFacts(facts)
|
|
947
|
+
.map(([cat, items]) => `## ${cat}\n${items.map(f => renderFactLine(f, true)).join('\n')}`)
|
|
948
|
+
.join('\n\n');
|
|
949
|
+
}
|
|
950
|
+
/** /profile 的回复:与给模型的档案块同一分组与版式,分类名与说明按读者语言 */
|
|
951
|
+
function profileText(profile, userKey) {
|
|
952
|
+
const factText = (fact) => t('factLine', {
|
|
953
|
+
text: fact.text,
|
|
954
|
+
meta: textClauses([
|
|
955
|
+
...(fact.timeHint ? [t('factTimeHint', { hint: fact.timeHint })] : []),
|
|
956
|
+
t('factObserved', { at: fact.observedAt }),
|
|
957
|
+
...(fact.temporality === 'temporary' ? [t('factTemporary')] : []),
|
|
958
|
+
]),
|
|
959
|
+
});
|
|
960
|
+
return textLines([
|
|
961
|
+
t('profileHead', { key: userKey }),
|
|
962
|
+
t('profileMeta', {
|
|
963
|
+
score: (profile.relationScore ?? 0).toFixed(relationScorePrecision),
|
|
964
|
+
count: profile.interactionCount ?? 0,
|
|
965
|
+
}),
|
|
966
|
+
...groupFacts(profile.facts).flatMap(([cat, items]) => [
|
|
967
|
+
'',
|
|
968
|
+
t('section', { name: CATEGORY_NAMES[cat] }),
|
|
969
|
+
...items.map(factText),
|
|
970
|
+
]),
|
|
971
|
+
]);
|
|
938
972
|
}
|
|
939
973
|
function renderRelationLine(profile) {
|
|
940
974
|
const score = profile.relationScore ?? 0;
|
|
@@ -943,28 +977,33 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
943
977
|
return '';
|
|
944
978
|
return `关系强度:${score.toFixed(relationScorePrecision)}/100;累计互动:${count} 次。`;
|
|
945
979
|
}
|
|
946
|
-
// ─── LLM 调用前注入(agent:prompt 贡献 / turn-context
|
|
947
|
-
// direct/immediate/undefined →
|
|
980
|
+
// ─── LLM 调用前注入(agent:prompt 贡献 / turn-context 槽——档案按当前发言者取材、换人即变,落历史后护前缀缓存):按这一轮各条输入的 triggerType 区分主发言者语义 ───
|
|
981
|
+
// direct/immediate/undefined → 点名的输入(带来源的注入不算,见 wasAddressed),它的发言者是主发言者:各注入完整档案,其余参与者给摘要
|
|
948
982
|
// interval → 无主发言者(只是恰好撞上频率),所有参与者一律 compact 摘要
|
|
949
983
|
// idle → 无 userId,只注入历史 messages 中出现的参与者 compact 摘要
|
|
950
|
-
//
|
|
984
|
+
// 没有一条点名的输入时没有主发言者。多块返回保序共键:各主发言者档案(按输入先后)→ 其他参与者。
|
|
951
985
|
contributions.use.contribute('agent:prompt', {
|
|
952
986
|
id: 'user-profile',
|
|
953
987
|
anchor: 'turn-context',
|
|
954
988
|
async build(data) {
|
|
955
|
-
// 干跑(token 快照)
|
|
989
|
+
// 干跑(token 快照)跳过档案加载——该路径没有输入,加载既昂贵又无意义
|
|
956
990
|
if (data.dryRun)
|
|
957
991
|
return null;
|
|
958
992
|
const mem = memory.present ? memory.use : undefined;
|
|
959
993
|
const blocksToInsert = [];
|
|
960
|
-
const
|
|
961
|
-
const hasPrimarySpeaker =
|
|
994
|
+
const addressed = data.inputs.map(({ message }) => message).filter(wasAddressed);
|
|
995
|
+
const hasPrimarySpeaker = addressed.length > 0;
|
|
996
|
+
/** 主发言者:点名的输入里有账号的发言者,按 userKey 去重、按输入先后 */
|
|
997
|
+
const primaries = new Map();
|
|
998
|
+
for (const { platform, userId } of addressed) {
|
|
999
|
+
if (userId)
|
|
1000
|
+
primaries.set(userKeyOf(platform, userId), userId);
|
|
1001
|
+
}
|
|
962
1002
|
// 1. 主发言者完整档案:仅在确实有人在「和 Aalis 对话」时注入
|
|
963
|
-
|
|
964
|
-
const userKey = userKeyOf(data.platform, data.userId);
|
|
1003
|
+
for (const [userKey, userId] of primaries) {
|
|
965
1004
|
const profile = await loadProfileLenient(mem, userKey);
|
|
966
1005
|
if (profile?.facts.some(isFactActive)) {
|
|
967
|
-
const body = renderProfileBlock(profile.facts,
|
|
1006
|
+
const body = renderProfileBlock(profile.facts, userId, false);
|
|
968
1007
|
const relationLine = renderRelationLine(profile);
|
|
969
1008
|
// 可选:从 user-relation 服务取「同社群活跃成员」(按配置的社群发现算法所得标签 + PageRank 排序)。
|
|
970
1009
|
// 服务不存在 / 节点没社群标签 / 报错都静默跳过;该字段是锦上添花,不破坏档案主体。
|
|
@@ -982,7 +1021,7 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
982
1021
|
catch (err) {
|
|
983
1022
|
logger.use.debug(`读取 community peers 失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
984
1023
|
}
|
|
985
|
-
const block = `# 关于当前对话者(${
|
|
1024
|
+
const block = `# 关于当前对话者(${userId})的已知事实\n` +
|
|
986
1025
|
'以下是你跨会话长期积累的关于该用户的事实,用于让回应更自然贴合其个性。' +
|
|
987
1026
|
'不要主动罗列这些事实,也不要让用户觉得你在「读档案」,而是让它自然影响你的语气和话题选择。' +
|
|
988
1027
|
'这些事实来自零散对话的推断,未必完全准确;也不要把它们与对话历史片段拼接成新的强陈述,' +
|
|
@@ -997,10 +1036,8 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
997
1036
|
// 2. 群聊其他参与者:按最近互动优先加载档案
|
|
998
1037
|
// - 先从当前 LLM 上下文的 messages 中倒序收集最近发言者
|
|
999
1038
|
// - 再从跨会话 user:profile metadata 中按 lastInteractionAt 倒序补齐
|
|
1000
|
-
// -
|
|
1001
|
-
// - hasPrimarySpeaker 为 false 时(interval/idle)所有人平等显示
|
|
1039
|
+
// - 排除主发言者;没有主发言者时(interval/idle)所有人平等显示
|
|
1002
1040
|
if (cfg.maxOtherParticipants > 0) {
|
|
1003
|
-
const primaryKey = hasPrimarySpeaker && data.userId ? userKeyOf(data.platform, data.userId) : undefined;
|
|
1004
1041
|
const others = new Map();
|
|
1005
1042
|
const candidateLimit = Math.max(cfg.maxOtherParticipants * 5, cfg.maxOtherParticipants + 10);
|
|
1006
1043
|
function addOther(userId, platform, nickname) {
|
|
@@ -1008,7 +1045,7 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1008
1045
|
if (!uid)
|
|
1009
1046
|
return false;
|
|
1010
1047
|
const userKey = userKeyOf(platform, uid);
|
|
1011
|
-
if (
|
|
1048
|
+
if (primaries.has(userKey))
|
|
1012
1049
|
return false;
|
|
1013
1050
|
if (others.has(userKey))
|
|
1014
1051
|
return false;
|
|
@@ -1082,13 +1119,17 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1082
1119
|
},
|
|
1083
1120
|
});
|
|
1084
1121
|
// ─── 参与统一的 memory:clear ───
|
|
1122
|
+
// 用户档案是跨会话的,只在清理全部会话时清:会话级清理不动它,显式选了 user-profile 时回一行未清理(不带类型的会话级清理,
|
|
1123
|
+
// 含删除会话,不出这一行)
|
|
1124
|
+
contributions.use.contribute('memory:clear-type', { id: 'user-profile', label: t('clearType') });
|
|
1085
1125
|
hooks.use.middleware('memory:clear', async (data, next) => {
|
|
1086
1126
|
if (data.types && !data.types.includes('user-profile')) {
|
|
1087
1127
|
await next();
|
|
1088
1128
|
return;
|
|
1089
1129
|
}
|
|
1090
1130
|
if (data.scope !== 'all') {
|
|
1091
|
-
|
|
1131
|
+
if (data.types)
|
|
1132
|
+
data.results.push({ source: 'user-profile', type: 'user-profile', success: false, message: t('clearSkipped') });
|
|
1092
1133
|
await next();
|
|
1093
1134
|
return;
|
|
1094
1135
|
}
|
|
@@ -1107,16 +1148,15 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1107
1148
|
source: 'user-profile',
|
|
1108
1149
|
type: 'user-profile',
|
|
1109
1150
|
success: true,
|
|
1110
|
-
message:
|
|
1151
|
+
message: t('clearDone', { count: keys.length }),
|
|
1111
1152
|
});
|
|
1112
1153
|
}
|
|
1113
1154
|
catch (err) {
|
|
1114
|
-
const m = err instanceof Error ? err.message : String(err);
|
|
1115
1155
|
data.results.push({
|
|
1116
1156
|
source: 'user-profile',
|
|
1117
1157
|
type: 'user-profile',
|
|
1118
1158
|
success: false,
|
|
1119
|
-
message:
|
|
1159
|
+
message: t('clearFailed', { error: textOf(err) }),
|
|
1120
1160
|
});
|
|
1121
1161
|
}
|
|
1122
1162
|
page.invalidate();
|
|
@@ -1129,15 +1169,16 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1129
1169
|
// - 两种调用方式:① user_key 直传;② platform+user_id 组合
|
|
1130
1170
|
tools.use.registerGroup({
|
|
1131
1171
|
name: 'user-profile',
|
|
1132
|
-
label: '
|
|
1133
|
-
|
|
1172
|
+
label: t('profiles'),
|
|
1173
|
+
summary: t('groupSummary'),
|
|
1174
|
+
description: '查询已经积累的某人事实档案。',
|
|
1134
1175
|
});
|
|
1135
1176
|
tools.use.register({
|
|
1136
1177
|
definition: {
|
|
1137
1178
|
type: 'function',
|
|
1138
1179
|
function: {
|
|
1139
1180
|
name: 'user_profile_lookup',
|
|
1140
|
-
description: '
|
|
1181
|
+
description: '查询你已积累的某个用户的事实档案。当对话中提到某人、或你想回忆已知信息时使用。' +
|
|
1141
1182
|
'可通过 user_key("<platform>:<userId>")直查,或用 platform + user_id 组合。',
|
|
1142
1183
|
parameters: {
|
|
1143
1184
|
type: 'object',
|
|
@@ -1199,36 +1240,33 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1199
1240
|
// /profile 查看自己的档案
|
|
1200
1241
|
// /profile clear 清除自己的档案(authority=2,自己删自己的)
|
|
1201
1242
|
// /profile clear nuke 清空所有用户档案(authority=3,dangerous)
|
|
1202
|
-
commands.use.command('profile', '
|
|
1243
|
+
commands.use.command('profile', t('profileDescription')).action(async (argv) => {
|
|
1203
1244
|
const userId = argv.session.userId;
|
|
1204
1245
|
if (!userId)
|
|
1205
|
-
return '
|
|
1246
|
+
return t('noIdentityView');
|
|
1206
1247
|
const userKey = userKeyOf(argv.session.platform, userId);
|
|
1207
1248
|
const profile = await loadProfileLenient(memory.present ? memory.use : undefined, userKey);
|
|
1208
1249
|
if (!profile || profile.facts.length === 0) {
|
|
1209
|
-
return
|
|
1250
|
+
return t('noProfileYet', { key: userKey });
|
|
1210
1251
|
}
|
|
1211
|
-
|
|
1212
|
-
const meta = `关系强度:${(profile.relationScore ?? 0).toFixed(relationScorePrecision)}/100,互动次数:${profile.interactionCount ?? 0}`;
|
|
1213
|
-
return `📇 你的档案 (${userKey})\n${meta}\n\n${block}`;
|
|
1252
|
+
return profileText(profile, userKey);
|
|
1214
1253
|
});
|
|
1215
|
-
commands.use.command('profile.clear', '
|
|
1254
|
+
commands.use.command('profile.clear', t('clearDescription'), { visibility: 'restricted' }).action(async (argv) => {
|
|
1216
1255
|
const userId = argv.session.userId;
|
|
1217
1256
|
if (!userId)
|
|
1218
|
-
return '
|
|
1257
|
+
return t('noIdentityClear');
|
|
1219
1258
|
const userKey = userKeyOf(argv.session.platform, userId);
|
|
1220
1259
|
const mem = pinnedMemory();
|
|
1221
1260
|
try {
|
|
1222
1261
|
const existed = await mem.getMetadata(PROFILE_NS, userKey);
|
|
1223
1262
|
if (!existed)
|
|
1224
|
-
return
|
|
1263
|
+
return t('noOwnProfile', { key: userKey });
|
|
1225
1264
|
await mem.deleteMetadata(PROFILE_NS, userKey);
|
|
1226
1265
|
page.invalidate();
|
|
1227
|
-
return
|
|
1266
|
+
return t('ownCleared', { key: userKey });
|
|
1228
1267
|
}
|
|
1229
1268
|
catch (err) {
|
|
1230
|
-
|
|
1231
|
-
return `❌ 清除失败:${m}`;
|
|
1269
|
+
return t('ownClearFailed', { error: textOf(err) });
|
|
1232
1270
|
}
|
|
1233
1271
|
});
|
|
1234
1272
|
// 定点除毒入口(2026-08 群主档案投毒事件的运维刚需):此前删单条事实只能
|
|
@@ -1236,7 +1274,7 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1236
1274
|
//(它改的是**他人**档案,且 target 含冒号时直传 key、可触及任意一份档案)。
|
|
1237
1275
|
// id 未命中时列出前若干条供定位。
|
|
1238
1276
|
commands.use
|
|
1239
|
-
.command('profile.forget <target:string> <factId:string>', '
|
|
1277
|
+
.command('profile.forget <target:string> <factId:string>', t('forgetDescription'), {
|
|
1240
1278
|
risk: 'dangerous',
|
|
1241
1279
|
})
|
|
1242
1280
|
.action(async (argv, targetArg, factIdArg) => {
|
|
@@ -1247,14 +1285,14 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1247
1285
|
try {
|
|
1248
1286
|
const profile = await loadProfile(mem, userKey);
|
|
1249
1287
|
if (!profile || profile.facts.length === 0)
|
|
1250
|
-
return
|
|
1288
|
+
return t('noProfile', { key: userKey });
|
|
1251
1289
|
const hit = profile.facts.find(f => f.id === factId);
|
|
1252
1290
|
if (!hit) {
|
|
1253
1291
|
const listing = profile.facts
|
|
1254
1292
|
.slice(0, 20)
|
|
1255
1293
|
.map(f => ` [${f.id}] ${f.text.slice(0, 50)}`)
|
|
1256
1294
|
.join('\n');
|
|
1257
|
-
return
|
|
1295
|
+
return t('factNotFound', { factId, key: userKey, count: profile.facts.length, listing });
|
|
1258
1296
|
}
|
|
1259
1297
|
await saveProfile(mem, userKey, {
|
|
1260
1298
|
...profile,
|
|
@@ -1262,22 +1300,21 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1262
1300
|
updatedAt: Date.now(),
|
|
1263
1301
|
});
|
|
1264
1302
|
page.invalidate();
|
|
1265
|
-
return
|
|
1303
|
+
return t('factForgotten', { key: userKey, factId, text: hit.text.slice(0, 60) });
|
|
1266
1304
|
}
|
|
1267
1305
|
catch (err) {
|
|
1268
|
-
return
|
|
1306
|
+
return t('forgetFailed', { error: textOf(err) });
|
|
1269
1307
|
}
|
|
1270
1308
|
});
|
|
1271
|
-
commands.use.command('profile.clear.nuke', '
|
|
1309
|
+
commands.use.command('profile.clear.nuke', t('nukeDescription'), { risk: 'dangerous' }).action(async () => {
|
|
1272
1310
|
const mem = pinnedMemory();
|
|
1273
1311
|
try {
|
|
1274
1312
|
const [keys] = await clearMetadataNamespaces(mem, [PROFILE_NS], logger.use);
|
|
1275
1313
|
page.invalidate();
|
|
1276
|
-
return
|
|
1314
|
+
return t('nukeDone', { count: keys.length });
|
|
1277
1315
|
}
|
|
1278
1316
|
catch (err) {
|
|
1279
|
-
|
|
1280
|
-
return `❌ 清空失败:${m}`;
|
|
1317
|
+
return t('nukeFailed', { error: textOf(err) });
|
|
1281
1318
|
}
|
|
1282
1319
|
});
|
|
1283
1320
|
logger.use.info(`用户事实档案已启用 (every=${cfg.extractEveryNMessages <= 0 ? '禁用提取' : `${cfg.extractEveryNMessages}msgs`}, history=${cfg.historyForExtraction}, ` +
|