@aalis/plugin-user-profile 0.14.0 → 0.15.1
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 +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +282 -230
- 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 +19 -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
|
-
import { config, definePlugin, events, logger, optional } from '@aalis/core';
|
|
11
|
+
import { config, definePlugin, events, lifecycle, 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,
|
|
@@ -230,21 +246,26 @@ const uses = {
|
|
|
230
246
|
contributions,
|
|
231
247
|
config,
|
|
232
248
|
logger,
|
|
249
|
+
lifecycle,
|
|
233
250
|
tools: optional(tools),
|
|
234
251
|
commands: optional(commands),
|
|
235
252
|
userRelation: optional(userRelation),
|
|
253
|
+
/** 提取提示里机器人的称呼取人设名;缺席时用中性称呼 */
|
|
254
|
+
persona: optional(persona),
|
|
255
|
+
/** 事实的语言:取全局层的语言列表(读者是 owner),缺席时按中文 */
|
|
256
|
+
i18n: optional(i18n),
|
|
236
257
|
/** 档案页;不在时照常记档案 */
|
|
237
258
|
webui: optional(webuiServer),
|
|
238
259
|
};
|
|
239
260
|
export default definePlugin({
|
|
240
261
|
name: '@aalis/plugin-user-profile',
|
|
241
|
-
displayName: '
|
|
262
|
+
displayName: t('displayName'),
|
|
242
263
|
subsystem: 'memory',
|
|
243
264
|
configSchema,
|
|
244
265
|
uses,
|
|
245
266
|
apply: registerUserProfile,
|
|
246
267
|
});
|
|
247
|
-
function registerUserProfile({ memory, llm, events, hooks, contributions, config, logger, tools, commands, userRelation, webui, }) {
|
|
268
|
+
function registerUserProfile({ memory, llm, events, hooks, contributions, config, logger, lifecycle, tools, commands, userRelation, persona, i18n, webui, }) {
|
|
248
269
|
const parsed = parseConfig(configSchema, config.use, logger.use);
|
|
249
270
|
const cfg = {
|
|
250
271
|
...parsed,
|
|
@@ -259,17 +280,13 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
259
280
|
relationIncrementImmediate: Math.max(0, parsed.relationIncrementImmediate),
|
|
260
281
|
relationIncrementInterval: Math.max(0, parsed.relationIncrementInterval),
|
|
261
282
|
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
283
|
};
|
|
269
284
|
/** 每会话每用户累计入站消息数(用于 extractEveryNMessages 计数),不随提取重置 */
|
|
270
285
|
const userMessageCount = new Map();
|
|
271
286
|
/** 防止同一用户的提取并发触发 */
|
|
272
287
|
const inflightExtractions = new Set();
|
|
288
|
+
/** 提取模型:llm 服务给本插件挑的能对话的那一个(见 chatModelPicker) */
|
|
289
|
+
const pickChatModel = chatModelPicker(llm, () => logger.use.warn('没有可用的对话模型,事实提取跳过'));
|
|
273
290
|
const relationScorePrecision = Math.min(6, Math.max(decimalPlaces(cfg.relationScoreDecayPerDay), decimalPlaces(cfg.relationIncrementDirect), decimalPlaces(cfg.relationIncrementImmediate), decimalPlaces(cfg.relationIncrementInterval)));
|
|
274
291
|
function userKeyOf(platform, userId) {
|
|
275
292
|
return `${platform ?? ''}:${userId}`;
|
|
@@ -387,19 +404,19 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
387
404
|
ref = undefined;
|
|
388
405
|
}
|
|
389
406
|
if (!Array.isArray(ref) || ref.length !== 2 || !ref.every(part => typeof part === 'string')) {
|
|
390
|
-
throw new
|
|
407
|
+
throw new TextError(t('unknownFact', { rowKey }));
|
|
391
408
|
}
|
|
392
409
|
return ref;
|
|
393
410
|
}
|
|
394
411
|
const page = webui.use.definePage({
|
|
395
412
|
key: 'profiles',
|
|
396
|
-
title: '
|
|
413
|
+
title: t('profiles'),
|
|
397
414
|
icon: 'contact',
|
|
398
415
|
actions: {
|
|
399
416
|
forgetFact: {
|
|
400
|
-
label: '
|
|
417
|
+
label: t('forgetLabel'),
|
|
401
418
|
effect: 'destructive',
|
|
402
|
-
confirm: '
|
|
419
|
+
confirm: t('forgetConfirm'),
|
|
403
420
|
row: true,
|
|
404
421
|
async handler({ rowKey }) {
|
|
405
422
|
const [userKey, factId] = parseFactRef(rowKey);
|
|
@@ -407,28 +424,28 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
407
424
|
const profile = await loadProfile(mem, userKey);
|
|
408
425
|
const hit = profile?.facts.find(f => f.id === factId);
|
|
409
426
|
if (!profile || !hit)
|
|
410
|
-
throw new
|
|
427
|
+
throw new TextError(t('factGone'));
|
|
411
428
|
await saveProfile(mem, userKey, {
|
|
412
429
|
...profile,
|
|
413
430
|
facts: profile.facts.filter(f => f !== hit),
|
|
414
431
|
updatedAt: Date.now(),
|
|
415
432
|
});
|
|
416
433
|
page.invalidate();
|
|
417
|
-
return
|
|
434
|
+
return t('forgotten', { text: hit.text });
|
|
418
435
|
},
|
|
419
436
|
},
|
|
420
437
|
clearProfile: {
|
|
421
|
-
label: '
|
|
438
|
+
label: t('clearProfileLabel'),
|
|
422
439
|
effect: 'destructive',
|
|
423
|
-
confirm: '
|
|
440
|
+
confirm: t('clearProfileConfirm'),
|
|
424
441
|
row: true,
|
|
425
442
|
async handler({ rowKey }) {
|
|
426
443
|
const mem = pinnedMemory();
|
|
427
444
|
if (!(await mem.getMetadata(PROFILE_NS, rowKey)))
|
|
428
|
-
throw new
|
|
445
|
+
throw new TextError(t('profileGone', { key: rowKey }));
|
|
429
446
|
await mem.deleteMetadata(PROFILE_NS, rowKey);
|
|
430
447
|
page.invalidate();
|
|
431
|
-
return
|
|
448
|
+
return t('profileCleared', { key: rowKey });
|
|
432
449
|
},
|
|
433
450
|
},
|
|
434
451
|
},
|
|
@@ -437,12 +454,12 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
437
454
|
type: 'table',
|
|
438
455
|
id: 'profiles',
|
|
439
456
|
rowKey: 'key',
|
|
440
|
-
description: '
|
|
441
|
-
empty: '
|
|
457
|
+
description: t('pageDescription'),
|
|
458
|
+
empty: t('pageEmpty'),
|
|
442
459
|
columns: [
|
|
443
|
-
{ key: 'key', label: '
|
|
444
|
-
{ key: 'facts', label: '
|
|
445
|
-
{ key: 'updatedAt', label: '
|
|
460
|
+
{ key: 'key', label: t('columnUser'), primary: true },
|
|
461
|
+
{ key: 'facts', label: t('columnFacts'), primary: true },
|
|
462
|
+
{ key: 'updatedAt', label: t('columnLastNoted'), kind: 'time' },
|
|
446
463
|
],
|
|
447
464
|
async data() {
|
|
448
465
|
const entries = await memory.use.listMetadata(PROFILE_NS);
|
|
@@ -460,12 +477,12 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
460
477
|
type: 'table',
|
|
461
478
|
id: 'facts',
|
|
462
479
|
rowKey: 'ref',
|
|
463
|
-
empty: '
|
|
480
|
+
empty: t('factsEmpty'),
|
|
464
481
|
columns: [
|
|
465
|
-
{ key: 'text', label: '
|
|
466
|
-
{ key: 'category', label: '
|
|
467
|
-
{ key: 'temporality', label: '
|
|
468
|
-
{ key: 'updatedAt', label: '
|
|
482
|
+
{ key: 'text', label: t('columnFact'), primary: true },
|
|
483
|
+
{ key: 'category', label: t('columnCategory') },
|
|
484
|
+
{ key: 'temporality', label: t('columnTerm') },
|
|
485
|
+
{ key: 'updatedAt', label: t('columnNoted'), kind: 'time' },
|
|
469
486
|
],
|
|
470
487
|
rowActions: [{ action: 'forgetFact' }],
|
|
471
488
|
async data({ rowKey }) {
|
|
@@ -475,8 +492,8 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
475
492
|
.map(f => ({
|
|
476
493
|
ref: JSON.stringify([rowKey, f.id]),
|
|
477
494
|
text: f.text,
|
|
478
|
-
category: f.category
|
|
479
|
-
temporality: f.temporality === 'temporary' ? '
|
|
495
|
+
category: f.category ? CATEGORY_NAMES[f.category] : '',
|
|
496
|
+
temporality: f.temporality === 'temporary' ? t('termShort') : t('termLong'),
|
|
480
497
|
updatedAt: f.updatedAt,
|
|
481
498
|
}));
|
|
482
499
|
},
|
|
@@ -499,14 +516,14 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
499
516
|
if (!x || typeof x !== 'object')
|
|
500
517
|
return [];
|
|
501
518
|
const o = x;
|
|
502
|
-
const
|
|
503
|
-
if (!
|
|
519
|
+
const text = typeof o.text === 'string' ? o.text.trim() : '';
|
|
520
|
+
if (!text)
|
|
504
521
|
return [];
|
|
505
522
|
const category = normalizeCategory(o.category);
|
|
506
523
|
const sourceQuote = typeof o.sourceQuote === 'string' ? o.sourceQuote.trim() : '';
|
|
507
524
|
return [
|
|
508
525
|
{
|
|
509
|
-
text
|
|
526
|
+
text,
|
|
510
527
|
category,
|
|
511
528
|
temporality: normalizeTemporality(o.temporality, category),
|
|
512
529
|
timeHint: normalizeTextField(o.timeHint),
|
|
@@ -521,15 +538,15 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
521
538
|
return [];
|
|
522
539
|
const o = x;
|
|
523
540
|
const id = typeof o.id === 'string' ? o.id.trim() : '';
|
|
524
|
-
const
|
|
525
|
-
if (!id || !
|
|
541
|
+
const text = typeof o.text === 'string' ? o.text.trim() : '';
|
|
542
|
+
if (!id || !text)
|
|
526
543
|
return [];
|
|
527
544
|
const category = normalizeCategory(o.category);
|
|
528
545
|
const sourceQuote = typeof o.sourceQuote === 'string' ? o.sourceQuote.trim() : '';
|
|
529
546
|
return [
|
|
530
547
|
{
|
|
531
548
|
id,
|
|
532
|
-
text
|
|
549
|
+
text,
|
|
533
550
|
category,
|
|
534
551
|
temporality: normalizeTemporality(o.temporality, category),
|
|
535
552
|
timeHint: normalizeTextField(o.timeHint),
|
|
@@ -538,27 +555,38 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
538
555
|
];
|
|
539
556
|
})
|
|
540
557
|
: [];
|
|
558
|
+
// 只给 id 的字符串没有原话:当成只有 id 的一项,校验时按没给原话丢弃并 warn,不静默放过
|
|
541
559
|
const remove = Array.isArray(obj.remove)
|
|
542
|
-
? obj.remove
|
|
543
|
-
|
|
544
|
-
|
|
560
|
+
? obj.remove.flatMap(x => {
|
|
561
|
+
const item = typeof x === 'string' ? { id: x } : x;
|
|
562
|
+
if (!item || typeof item !== 'object')
|
|
563
|
+
return [];
|
|
564
|
+
const o = item;
|
|
565
|
+
const id = typeof o.id === 'string' ? o.id.trim() : '';
|
|
566
|
+
if (!id)
|
|
567
|
+
return [];
|
|
568
|
+
const sourceQuote = typeof o.sourceQuote === 'string' ? o.sourceQuote.trim() : '';
|
|
569
|
+
return [{ id, sourceQuote }];
|
|
570
|
+
})
|
|
545
571
|
: [];
|
|
546
572
|
return { add, update, remove };
|
|
547
573
|
}
|
|
548
574
|
/** 调用 LLM 从历史中提取/修订事实,返回 add / update / remove 三类操作。 */
|
|
549
|
-
async function llmExtractFacts(history, existingFacts, nickname, userId, platform) {
|
|
575
|
+
async function llmExtractFacts(history, existingFacts, nickname, userId, platform, sessionId) {
|
|
550
576
|
const empty = {
|
|
551
577
|
add: [],
|
|
552
578
|
update: [],
|
|
553
579
|
remove: [],
|
|
554
580
|
};
|
|
581
|
+
// 她在提示里的称呼取这个会话的人设名,标签、规则与例句同一个名字
|
|
582
|
+
const botName = (persona.present ? persona.use.getPersonaName({ sessionId, platform }) : '') || BOT_FALLBACK_NAME;
|
|
555
583
|
const sys = '你是用户档案管理员。输入是一段多用户会话历史,每行开头有身份标签:' +
|
|
556
|
-
|
|
557
|
-
|
|
584
|
+
`\`[目标用户 X(uid)]\`、\`[其他用户 Y(uid)]\` 或 \`[${botName}]\`。上下文仅供消歧,` +
|
|
585
|
+
`**只允许根据「目标用户」自己的发言写入事实**,不能根据其他用户、${botName} 的猜测或提问写入事实。` +
|
|
558
586
|
'\n\n你可以执行三种操作(在一次输出里组合):' +
|
|
559
587
|
'\n- add: 添加新事实,需指定 text、category、sourceQuote' +
|
|
560
588
|
'\n- update: 用 id 精确替换某条已知事实,同样需 sourceQuote(优先使用 update 而非 add 来避免重复)' +
|
|
561
|
-
'\n- remove: 用 id
|
|
589
|
+
'\n- remove: 用 id 删除已被目标用户推翻的事实,同样需 sourceQuote(目标用户推翻它的那句原话)' +
|
|
562
590
|
'\n\n规则:' +
|
|
563
591
|
'\n1. 事实的**主语必须是「目标用户」本人**,不能是 ta 转述、讨论、评价的第三方。' +
|
|
564
592
|
'即使 sourceQuote 出自目标用户原话,也要先在脑中追问:' +
|
|
@@ -566,34 +594,34 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
566
594
|
'\n - 是认真陈述,还是反讽/否定/假设/角色扮演?("我可不喜欢爵士" → 否定,不写;跑团扮演 NPC 的台词 → 不写)' +
|
|
567
595
|
'\n - 如果原始依据来自「其他用户」的发言,即使在说目标用户,也不允许写入(他人可能记错、说反话)' +
|
|
568
596
|
'\n **拿不准就不写。profile 漏几条没关系,写错才麻烦。**' +
|
|
569
|
-
|
|
597
|
+
`\n ⚠️ 例外——\`人际关系\` 类:除上述通用规则外,该类还允许记录目标用户**与 ${botName} 之间长期可观察的关系模式**,` +
|
|
570
598
|
'包含两种情况:' +
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
599
|
+
`\n (a) 行为身份类:用户围绕 ${botName} 扮演的角色或长期重复的行为` +
|
|
600
|
+
`(示例 text:「是 ${botName} 的开发维护者」「经常向 ${botName} 提交功能建议」「常帮助测试和排查 ${botName} 的故障」)` +
|
|
601
|
+
`\n (b) 长期情感模式类:用户对 ${botName} 表现出的**反复出现的态度倾向**(不是单次情绪)` +
|
|
602
|
+
`(示例 text:「常对 ${botName} 表达依赖与喜爱」「习惯把日常烦恼向 ${botName} 倾诉」「喜欢调侃 ${botName}」)` +
|
|
575
603
|
'\n ❌ 严禁仅凭单次发言推断情感事实:一次抱怨/一次感谢/一次撒娇都不算"模式",不写。' +
|
|
576
604
|
'必须是已有事实佐证 + 本轮再次出现的同类行为才允许 update 强化,或会话历史多处反复出现才允许 add。' +
|
|
577
605
|
'依据必须是「目标用户」发言中可直接推断的内容,sourceQuote 取目标用户发言原话。' +
|
|
578
|
-
'\n2. **sourceQuote
|
|
606
|
+
'\n2. **sourceQuote 必须是「目标用户」自己发言中的原话片段**,可以是某一句话的一部分,必须能一字不改在「目标用户」某行的原文中找到;不要采用「其他用户」的原话,不要自己总结改写;' +
|
|
607
|
+
'只算 ta 自己写的字:行首的发送者前缀、行里附带的「[引用 … 的消息: …]」、图片与语音视频的描述、文件内容、合并转发都不算' +
|
|
579
608
|
'\n3. 在同一 category 下,如果新信息与已有事实在含义上重叠(例如已知"喜欢猫",新信息"还喜欢狗"),应以 update 改写原 id 为更全面的版本,而不是 add 再加一条' +
|
|
580
609
|
'\n4. 如果新对话明确推翻或修正了某条已知事实(如已知"在北京工作",但用户说"我刚搬到上海"),用 update 替换或 remove 删除' +
|
|
581
|
-
`\n5. 每条 text
|
|
610
|
+
`\n5. 每条 text 用一句简洁的 ${outputLanguageFor(i18n, { host: true }, 'zh-CN')},不超过 ${cfg.maxFactCharsPerItem} 个字符,不带「用户」「他」等代词,直接陈述事实` +
|
|
582
611
|
'\n6. 如果没有任何更新,或所有候选都拿不准,三个数组都返回空——**保守优先**' +
|
|
583
612
|
`\n7. category 必须是以下之一:${KNOWN_CATEGORIES.join('、')}` +
|
|
584
613
|
'\n8. 每条 add/update 都必须给出 temporality:长期稳定偏好、性格、身份、人际关系用 permanent;近期状态、正在进行的事、短期计划用 temporary' +
|
|
585
614
|
'\n9. 如果对话中出现明确或隐含时间(如“最近”“上周”“今年4月”“昨天”),用 timeHint 记录简短时间线索;没有就省略或用空字符串' +
|
|
586
615
|
'\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"]}';
|
|
616
|
+
'\n{"add": [{"text": "...", "category": "...", "temporality": "permanent|temporary", "timeHint": "...", "sourceQuote": "目标用户发言中的原话片段"}], "update": [{"id": "已知事实的id", "text": "新表述", "category": "...", "temporality": "permanent|temporary", "timeHint": "...", "sourceQuote": "目标用户发言中的原话片段"}], "remove": [{"id": "已知事实的id", "sourceQuote": "目标用户发言中的原话片段"}]}';
|
|
588
617
|
const factListText = existingFacts.length > 0
|
|
589
618
|
? existingFacts.map(f => `[${f.id}] (${f.category ?? '未分类'}) ${f.text}`).join('\n')
|
|
590
619
|
: '(暂无)';
|
|
591
620
|
const who = nickname ? `${nickname}(${userId})` : userId;
|
|
592
|
-
const renderedHistory = renderHistoryForExtract(history, userId, platform);
|
|
621
|
+
const renderedHistory = renderHistoryForExtract(history, userId, platform, botName);
|
|
593
622
|
const user = `# 提取目标\n${who}\n\n# 已知事实(带 id,请在 update/remove 中精确引用 id)\n${factListText}` +
|
|
594
623
|
`\n\n# 会话历史(含多用户,仅供消歧;只能从「目标用户」发言中提取事实)\n${renderedHistory || '(暂无会话历史)'}`;
|
|
595
|
-
|
|
596
|
-
const entry = resolveLLMModel(llm, cfg.extractLLM, ['chat']);
|
|
624
|
+
const entry = pickChatModel();
|
|
597
625
|
if (!entry)
|
|
598
626
|
return empty;
|
|
599
627
|
const extractLlm = entry.use;
|
|
@@ -602,27 +630,27 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
602
630
|
{ role: 'system', content: sys },
|
|
603
631
|
{ role: 'user', content: user },
|
|
604
632
|
];
|
|
633
|
+
// 提取是本插件自己发起的:停用、停机时随之停下(出错照旧只记 debug、这一批不写)
|
|
605
634
|
const resp = await extractLlm.chat({
|
|
606
635
|
messages: baseMessages,
|
|
607
636
|
temperature: 0.2,
|
|
608
637
|
maxTokens: 800,
|
|
609
638
|
think: lightestThinking(extractLlm),
|
|
639
|
+
signal: lifecycle.use.signal,
|
|
610
640
|
});
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
// 写成了纯文本/markdown。给一次显式反馈再来一次,避免一次失败丢失整批事实。
|
|
618
|
-
logger.use.warn(`[user-profile] LLM 输出无法解析为 JSON,尝试重试一次。原文前 200 字:${text.slice(0, 200)}`);
|
|
641
|
+
const text = (resp.content ?? '').trim();
|
|
642
|
+
let reply = readExtractReply(resp);
|
|
643
|
+
if ('why' in reply) {
|
|
644
|
+
// 截断,或 util-json-repair 剥过围栏、修过裸引号仍不是合法 JSON(空回复也算:提示词要求没有事实也回三个空数组):给一次
|
|
645
|
+
// 显式反馈再来一次,避免一次失败丢失整批事实
|
|
646
|
+
logger.use.warn(`[user-profile] LLM 输出${reply.why},尝试重试一次。原文前 200 字:${text.slice(0, 200)}`);
|
|
619
647
|
const retryResp = await extractLlm.chat({
|
|
620
648
|
messages: [
|
|
621
649
|
...baseMessages,
|
|
622
650
|
{ role: 'assistant', content: text },
|
|
623
651
|
{
|
|
624
652
|
role: 'user',
|
|
625
|
-
content:
|
|
653
|
+
content: `你上一条输出${reply.why}。` +
|
|
626
654
|
'请只输出**一个**合法的 JSON 对象,第一个字符必须是 `{`、最后一个字符必须是 `}`,' +
|
|
627
655
|
'禁止 ```json 围栏、禁止任何解释、禁止 markdown。如果实在没有可写入的事实,' +
|
|
628
656
|
'就输出 `{"add":[],"update":[],"remove":[]}`。',
|
|
@@ -631,45 +659,42 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
631
659
|
temperature: 0.2,
|
|
632
660
|
maxTokens: 800,
|
|
633
661
|
think: lightestThinking(extractLlm),
|
|
662
|
+
signal: lifecycle.use.signal,
|
|
634
663
|
});
|
|
635
|
-
|
|
636
|
-
(
|
|
637
|
-
|
|
638
|
-
logger.use.warn(`[user-profile] LLM 重试后仍无法解析 JSON,放弃本批次。重试原文前 200 字:${text.slice(0, 200)}`);
|
|
664
|
+
reply = readExtractReply(retryResp);
|
|
665
|
+
if ('why' in reply) {
|
|
666
|
+
logger.use.warn(`[user-profile] LLM 重试后输出仍${reply.why},放弃本批次。重试原文前 200 字:${(retryResp.content ?? '').trim().slice(0, 200)}`);
|
|
639
667
|
return empty;
|
|
640
668
|
}
|
|
641
669
|
logger.use.debug('[user-profile] LLM 重试后解析成功');
|
|
642
670
|
}
|
|
643
|
-
const { add, update, remove } = parseFactOps(parsed);
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
671
|
+
const { add, update, remove } = parseFactOps(reply.parsed);
|
|
672
|
+
// 增改删都要附原话:按行拆开(quote 可能跨多条消息,LLM 用 \n 拼接),每行规整后(normalizeForQuoteMatch)要落在目标用户
|
|
673
|
+
// 自己写的某一条话里(见 buildTargetUserCorpus),任一片段找不到整条丢弃并 warn;规整后不含字母或数字的行不算片段,一个
|
|
674
|
+
// 片段都没有的按没给原话。模型会凭空编事实,也会把别人的话、图上的字记到他头上,核对原话挡的是这两种。不设字数下限:接在
|
|
675
|
+
// 问句后面的「是的」是合法的原话;也不核对事实能不能从原话推出来:每次提取要多一次调用
|
|
676
|
+
const targetCorpus = buildTargetUserCorpus(history, userId, platform);
|
|
677
|
+
const validateSourceQuote = (quote, kind, what) => {
|
|
678
|
+
const fragments = quote
|
|
679
|
+
.split(/\r?\n/)
|
|
680
|
+
.map(line => ({ line, normalized: normalizeForQuoteMatch(line) }))
|
|
681
|
+
.filter(({ normalized }) => /[\p{L}\p{N}]/u.test(normalized));
|
|
682
|
+
if (fragments.length === 0) {
|
|
683
|
+
logger.use.warn(`[user-profile] 丢弃 ${kind} fact(未提供 sourceQuote): ${what}`);
|
|
652
684
|
return false;
|
|
653
685
|
}
|
|
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}"`);
|
|
686
|
+
for (const { line, normalized } of fragments) {
|
|
687
|
+
if (!targetCorpus.some(spoken => spoken.includes(normalized))) {
|
|
688
|
+
logger.use.warn(`[user-profile] 丢弃 ${kind} fact(sourceQuote 片段不在目标用户 ${userId} 发言中): ${what} fragment="${line.trim()}"`);
|
|
665
689
|
return false;
|
|
666
690
|
}
|
|
667
691
|
}
|
|
668
692
|
return true;
|
|
669
693
|
};
|
|
670
|
-
const validatedAdd = add.filter(item => validateSourceQuote(item, 'add'));
|
|
671
|
-
const validatedUpdate = update.filter(item => validateSourceQuote(item, 'update'));
|
|
672
|
-
|
|
694
|
+
const validatedAdd = add.filter(item => validateSourceQuote(item.sourceQuote, 'add', item.text));
|
|
695
|
+
const validatedUpdate = update.filter(item => validateSourceQuote(item.sourceQuote, 'update', item.text));
|
|
696
|
+
const validatedRemove = remove.filter(item => validateSourceQuote(item.sourceQuote, 'remove', `id=${item.id}`));
|
|
697
|
+
return { add: validatedAdd, update: validatedUpdate, remove: validatedRemove };
|
|
673
698
|
}
|
|
674
699
|
catch (err) {
|
|
675
700
|
logger.use.debug(`事实提取 LLM 调用失败:${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -688,8 +713,6 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
688
713
|
return cfg.relationIncrementImmediate;
|
|
689
714
|
if (triggerType === 'interval')
|
|
690
715
|
return cfg.relationIncrementInterval;
|
|
691
|
-
if (triggerType === 'idle')
|
|
692
|
-
return 0;
|
|
693
716
|
if (triggerType === 'witness')
|
|
694
717
|
return cfg.relationIncrementWitness;
|
|
695
718
|
return cfg.relationIncrementDirect;
|
|
@@ -705,12 +728,11 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
705
728
|
const daysSinceLast = last ? Math.max(0, (now - last) / 86_400_000) : 0;
|
|
706
729
|
const decayed = clampRelationScore((profile.relationScore ?? 0) - daysSinceLast * cfg.relationScoreDecayPerDay);
|
|
707
730
|
const nextScore = clampRelationScore(decayed + relationIncrementFor(triggerType));
|
|
708
|
-
const shouldCount = countInteraction && triggerType !== 'idle';
|
|
709
731
|
return {
|
|
710
732
|
...profile,
|
|
711
733
|
relationScore: nextScore,
|
|
712
|
-
interactionCount: (profile.interactionCount ?? 0) + (
|
|
713
|
-
lastInteractionAt:
|
|
734
|
+
interactionCount: (profile.interactionCount ?? 0) + (countInteraction ? 1 : 0),
|
|
735
|
+
lastInteractionAt: countInteraction ? now : profile.lastInteractionAt,
|
|
714
736
|
updatedAt: now,
|
|
715
737
|
};
|
|
716
738
|
}
|
|
@@ -813,14 +835,14 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
813
835
|
if (!history.some(m => isTargetUserMessage(m, userId, platform)))
|
|
814
836
|
return;
|
|
815
837
|
const snapshotFacts = (await loadProfile(mem, userKey))?.facts ?? [];
|
|
816
|
-
const ops = await llmExtractFacts(history, snapshotFacts, nickname, userId, platform);
|
|
838
|
+
const ops = await llmExtractFacts(history, snapshotFacts, nickname, userId, platform, sessionId);
|
|
817
839
|
const hasFactOps = ops.add.length > 0 || ops.update.length > 0 || ops.remove.length > 0;
|
|
818
840
|
if (!hasFactOps)
|
|
819
841
|
return;
|
|
820
842
|
// 以 LLM 返回后重读的档案为合并基底:调用期间档案可能被清空或删了事实,以调用前的快照
|
|
821
843
|
// 为基底会把删掉的内容整份写回;重读也保住期间写入的 relationScore 等字段
|
|
822
844
|
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);
|
|
845
|
+
const newFacts = mergeFactList(fresh.facts, ops.add, dropUpdatesOfDeletedFacts(ops.update, snapshotFacts, fresh.facts), ops.remove.map(r => r.id));
|
|
824
846
|
await saveProfile(mem, userKey, {
|
|
825
847
|
...fresh,
|
|
826
848
|
facts: newFacts,
|
|
@@ -850,9 +872,10 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
850
872
|
// 注:钩子无优先级机制,同相位 handler 按插件激活序执行;本 handler 只做副作用
|
|
851
873
|
// (更新关系分数)不改消息,与其它 handler 顺序无关。
|
|
852
874
|
// 关系强度与"是否触发回复"绑定,因此仍走 agent:input:before 中间件。
|
|
875
|
+
// 只算真人的话,与旁观那一路同一个判据:带来源的注入(子任务的任务消息、好友申请通知等)不是谁在跟她说话,不加
|
|
853
876
|
hooks.use.middleware('agent:input:before', async (data, next) => {
|
|
854
|
-
const { userId, platform, triggerType } = data.message;
|
|
855
|
-
if (userId) {
|
|
877
|
+
const { userId, platform, triggerType, source } = data.message;
|
|
878
|
+
if (userId && !source) {
|
|
856
879
|
const userKey = userKeyOf(platform, userId);
|
|
857
880
|
try {
|
|
858
881
|
// 仅叠加 score,互动计数与时戳由 witness 路径统一负责,
|
|
@@ -865,13 +888,25 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
865
888
|
}
|
|
866
889
|
await next();
|
|
867
890
|
});
|
|
891
|
+
// 本插件自己发起的后台写(旁观关系更新、事实提取)记在这里:收尾时等它们落定再撤回。信号在收尾开始时已断,提取停在当前
|
|
892
|
+
// 这一步,已经开始的写入写完
|
|
893
|
+
const running = new Set();
|
|
894
|
+
const track = (work) => {
|
|
895
|
+
running.add(work);
|
|
896
|
+
void work.then(() => running.delete(work));
|
|
897
|
+
};
|
|
898
|
+
lifecycle.use.onDrain(async () => {
|
|
899
|
+
while (running.size > 0)
|
|
900
|
+
await Promise.all(running);
|
|
901
|
+
}, '等在途的档案写入');
|
|
868
902
|
// ─── 事实提取触发:每条入站消息落库后立即计数,与 agent 是否回复无关 ───
|
|
869
903
|
// 监听 message-archive 在 archiveIncoming 落库成功后发出的 inbound:message:archived 事件,
|
|
870
904
|
// 确保缓冲消息(onebot saveBufferedMessage 等不触发 agent 回复的路径)也能纳入计数。
|
|
871
905
|
events.use.on('inbound:message:archived', data => {
|
|
872
906
|
const { sessionId, incoming } = data;
|
|
873
907
|
const { userId, platform, nickname } = incoming;
|
|
874
|
-
|
|
908
|
+
// 带来源的注入不是这个账号说的话:不计旁观、不计提取,也就不为它带的账号(如子任务的 parent:<父会话>)建档
|
|
909
|
+
if (!userId || incoming.source)
|
|
875
910
|
return;
|
|
876
911
|
// 旁观档位:每条入站消息都加 witness 增量(默认 0.1),与 agent 是否回复无关。
|
|
877
912
|
// agent:input:before 中间件在触发回复时会再叠加 direct/immediate/interval 增量。
|
|
@@ -879,12 +914,12 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
879
914
|
// lastInteractionAt 的唯一写点,设门会连带关掉计数与时戳(衰减基准、最近互动
|
|
880
915
|
// 排序全失效)。增量为 0 时 relationIncrementFor 自然返回 0,不加分即可。
|
|
881
916
|
const userKey = userKeyOf(platform, userId);
|
|
882
|
-
|
|
917
|
+
track(updateRelationForUser(userKey, 'witness').catch((err) => logger.use.debug(`witness 关系更新异常 (${userKey}): ${err instanceof Error ? err.message : String(err)}`)));
|
|
883
918
|
const countKey = extractionCountKeyOf(sessionId, platform, userId);
|
|
884
919
|
const count = (userMessageCount.get(countKey) ?? 0) + 1;
|
|
885
920
|
userMessageCount.set(countKey, count);
|
|
886
921
|
if (cfg.extractEveryNMessages > 0 && count % cfg.extractEveryNMessages === 0) {
|
|
887
|
-
|
|
922
|
+
track(triggerExtractionForUser(sessionId, userId, platform ?? '', nickname).catch((err) => logger.use.debug(`事实提取异常 (${countKey}): ${err instanceof Error ? err.message : String(err)}`)));
|
|
888
923
|
}
|
|
889
924
|
});
|
|
890
925
|
function isFactActive(fact) {
|
|
@@ -905,36 +940,50 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
905
940
|
meta.push('临时状态');
|
|
906
941
|
return `- ${fact.text}(${meta.join(';')})`;
|
|
907
942
|
}
|
|
908
|
-
/**
|
|
943
|
+
/** 生效的事实按分类分组,次序同 KNOWN_CATEGORIES;没有分类的归「其他」。给模型的档案块与 /profile 共用 */
|
|
944
|
+
function groupFacts(facts) {
|
|
945
|
+
const active = facts.filter(isFactActive);
|
|
946
|
+
return KNOWN_CATEGORIES.flatMap(cat => {
|
|
947
|
+
const items = active.filter(f => (f.category ?? '其他') === cat);
|
|
948
|
+
return items.length > 0 ? [[cat, items]] : [];
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
/** 将一个用户的 Fact[] 渲染为分组文本块(给模型的) */
|
|
909
952
|
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
953
|
if (compact) {
|
|
923
|
-
// compact
|
|
954
|
+
// compact 模式(群聊背景参与者):只取最近更新的 N 条,平铺,不加分组标题
|
|
955
|
+
const subset = facts
|
|
956
|
+
.filter(isFactActive)
|
|
957
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
958
|
+
.slice(0, cfg.maxFactsForOthers);
|
|
924
959
|
return `### ${label}\n${subset.map(f => renderFactLine(f, false)).join('\n')}`;
|
|
925
960
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
961
|
+
return groupFacts(facts)
|
|
962
|
+
.map(([cat, items]) => `## ${cat}\n${items.map(f => renderFactLine(f, true)).join('\n')}`)
|
|
963
|
+
.join('\n\n');
|
|
964
|
+
}
|
|
965
|
+
/** /profile 的回复:与给模型的档案块同一分组与版式,分类名与说明按读者语言 */
|
|
966
|
+
function profileText(profile, userKey) {
|
|
967
|
+
const factText = (fact) => t('factLine', {
|
|
968
|
+
text: fact.text,
|
|
969
|
+
meta: textClauses([
|
|
970
|
+
...(fact.timeHint ? [t('factTimeHint', { hint: fact.timeHint })] : []),
|
|
971
|
+
t('factObserved', { at: fact.observedAt }),
|
|
972
|
+
...(fact.temporality === 'temporary' ? [t('factTemporary')] : []),
|
|
973
|
+
]),
|
|
974
|
+
});
|
|
975
|
+
return textLines([
|
|
976
|
+
t('profileHead', { key: userKey }),
|
|
977
|
+
t('profileMeta', {
|
|
978
|
+
score: (profile.relationScore ?? 0).toFixed(relationScorePrecision),
|
|
979
|
+
count: profile.interactionCount ?? 0,
|
|
980
|
+
}),
|
|
981
|
+
...groupFacts(profile.facts).flatMap(([cat, items]) => [
|
|
982
|
+
'',
|
|
983
|
+
t('section', { name: CATEGORY_NAMES[cat] }),
|
|
984
|
+
...items.map(factText),
|
|
985
|
+
]),
|
|
986
|
+
]);
|
|
938
987
|
}
|
|
939
988
|
function renderRelationLine(profile) {
|
|
940
989
|
const score = profile.relationScore ?? 0;
|
|
@@ -943,28 +992,33 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
943
992
|
return '';
|
|
944
993
|
return `关系强度:${score.toFixed(relationScorePrecision)}/100;累计互动:${count} 次。`;
|
|
945
994
|
}
|
|
946
|
-
// ─── LLM 调用前注入(agent:prompt 贡献 / turn-context
|
|
947
|
-
// direct/immediate/undefined →
|
|
995
|
+
// ─── LLM 调用前注入(agent:prompt 贡献 / turn-context 槽——档案按当前发言者取材、换人即变,落历史后护前缀缓存):按这一轮各条输入的 triggerType 区分主发言者语义 ───
|
|
996
|
+
// direct/immediate/undefined → 点名的输入(带来源的注入不算,见 wasAddressed),它的发言者是主发言者:各注入完整档案,其余参与者给摘要
|
|
948
997
|
// interval → 无主发言者(只是恰好撞上频率),所有参与者一律 compact 摘要
|
|
949
998
|
// idle → 无 userId,只注入历史 messages 中出现的参与者 compact 摘要
|
|
950
|
-
//
|
|
999
|
+
// 没有一条点名的输入时没有主发言者。多块返回保序共键:各主发言者档案(按输入先后)→ 其他参与者。
|
|
951
1000
|
contributions.use.contribute('agent:prompt', {
|
|
952
1001
|
id: 'user-profile',
|
|
953
1002
|
anchor: 'turn-context',
|
|
954
1003
|
async build(data) {
|
|
955
|
-
// 干跑(token 快照)
|
|
1004
|
+
// 干跑(token 快照)跳过档案加载——该路径没有输入,加载既昂贵又无意义
|
|
956
1005
|
if (data.dryRun)
|
|
957
1006
|
return null;
|
|
958
1007
|
const mem = memory.present ? memory.use : undefined;
|
|
959
1008
|
const blocksToInsert = [];
|
|
960
|
-
const
|
|
961
|
-
const hasPrimarySpeaker =
|
|
1009
|
+
const addressed = data.inputs.map(({ message }) => message).filter(wasAddressed);
|
|
1010
|
+
const hasPrimarySpeaker = addressed.length > 0;
|
|
1011
|
+
/** 主发言者:点名的输入里有账号的发言者,按 userKey 去重、按输入先后 */
|
|
1012
|
+
const primaries = new Map();
|
|
1013
|
+
for (const { platform, userId } of addressed) {
|
|
1014
|
+
if (userId)
|
|
1015
|
+
primaries.set(userKeyOf(platform, userId), userId);
|
|
1016
|
+
}
|
|
962
1017
|
// 1. 主发言者完整档案:仅在确实有人在「和 Aalis 对话」时注入
|
|
963
|
-
|
|
964
|
-
const userKey = userKeyOf(data.platform, data.userId);
|
|
1018
|
+
for (const [userKey, userId] of primaries) {
|
|
965
1019
|
const profile = await loadProfileLenient(mem, userKey);
|
|
966
1020
|
if (profile?.facts.some(isFactActive)) {
|
|
967
|
-
const body = renderProfileBlock(profile.facts,
|
|
1021
|
+
const body = renderProfileBlock(profile.facts, userId, false);
|
|
968
1022
|
const relationLine = renderRelationLine(profile);
|
|
969
1023
|
// 可选:从 user-relation 服务取「同社群活跃成员」(按配置的社群发现算法所得标签 + PageRank 排序)。
|
|
970
1024
|
// 服务不存在 / 节点没社群标签 / 报错都静默跳过;该字段是锦上添花,不破坏档案主体。
|
|
@@ -982,7 +1036,7 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
982
1036
|
catch (err) {
|
|
983
1037
|
logger.use.debug(`读取 community peers 失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
984
1038
|
}
|
|
985
|
-
const block = `# 关于当前对话者(${
|
|
1039
|
+
const block = `# 关于当前对话者(${userId})的已知事实\n` +
|
|
986
1040
|
'以下是你跨会话长期积累的关于该用户的事实,用于让回应更自然贴合其个性。' +
|
|
987
1041
|
'不要主动罗列这些事实,也不要让用户觉得你在「读档案」,而是让它自然影响你的语气和话题选择。' +
|
|
988
1042
|
'这些事实来自零散对话的推断,未必完全准确;也不要把它们与对话历史片段拼接成新的强陈述,' +
|
|
@@ -997,10 +1051,8 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
997
1051
|
// 2. 群聊其他参与者:按最近互动优先加载档案
|
|
998
1052
|
// - 先从当前 LLM 上下文的 messages 中倒序收集最近发言者
|
|
999
1053
|
// - 再从跨会话 user:profile metadata 中按 lastInteractionAt 倒序补齐
|
|
1000
|
-
// -
|
|
1001
|
-
// - hasPrimarySpeaker 为 false 时(interval/idle)所有人平等显示
|
|
1054
|
+
// - 排除主发言者;没有主发言者时(interval/idle)所有人平等显示
|
|
1002
1055
|
if (cfg.maxOtherParticipants > 0) {
|
|
1003
|
-
const primaryKey = hasPrimarySpeaker && data.userId ? userKeyOf(data.platform, data.userId) : undefined;
|
|
1004
1056
|
const others = new Map();
|
|
1005
1057
|
const candidateLimit = Math.max(cfg.maxOtherParticipants * 5, cfg.maxOtherParticipants + 10);
|
|
1006
1058
|
function addOther(userId, platform, nickname) {
|
|
@@ -1008,7 +1060,7 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1008
1060
|
if (!uid)
|
|
1009
1061
|
return false;
|
|
1010
1062
|
const userKey = userKeyOf(platform, uid);
|
|
1011
|
-
if (
|
|
1063
|
+
if (primaries.has(userKey))
|
|
1012
1064
|
return false;
|
|
1013
1065
|
if (others.has(userKey))
|
|
1014
1066
|
return false;
|
|
@@ -1082,13 +1134,17 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1082
1134
|
},
|
|
1083
1135
|
});
|
|
1084
1136
|
// ─── 参与统一的 memory:clear ───
|
|
1137
|
+
// 用户档案是跨会话的,只在清理全部会话时清:会话级清理不动它,显式选了 user-profile 时回一行未清理(不带类型的会话级清理,
|
|
1138
|
+
// 含删除会话,不出这一行)
|
|
1139
|
+
contributions.use.contribute('memory:clear-type', { id: 'user-profile', label: t('clearType') });
|
|
1085
1140
|
hooks.use.middleware('memory:clear', async (data, next) => {
|
|
1086
1141
|
if (data.types && !data.types.includes('user-profile')) {
|
|
1087
1142
|
await next();
|
|
1088
1143
|
return;
|
|
1089
1144
|
}
|
|
1090
1145
|
if (data.scope !== 'all') {
|
|
1091
|
-
|
|
1146
|
+
if (data.types)
|
|
1147
|
+
data.results.push({ source: 'user-profile', type: 'user-profile', success: false, message: t('clearSkipped') });
|
|
1092
1148
|
await next();
|
|
1093
1149
|
return;
|
|
1094
1150
|
}
|
|
@@ -1107,16 +1163,15 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1107
1163
|
source: 'user-profile',
|
|
1108
1164
|
type: 'user-profile',
|
|
1109
1165
|
success: true,
|
|
1110
|
-
message:
|
|
1166
|
+
message: t('clearDone', { count: keys.length }),
|
|
1111
1167
|
});
|
|
1112
1168
|
}
|
|
1113
1169
|
catch (err) {
|
|
1114
|
-
const m = err instanceof Error ? err.message : String(err);
|
|
1115
1170
|
data.results.push({
|
|
1116
1171
|
source: 'user-profile',
|
|
1117
1172
|
type: 'user-profile',
|
|
1118
1173
|
success: false,
|
|
1119
|
-
message:
|
|
1174
|
+
message: t('clearFailed', { error: textOf(err) }),
|
|
1120
1175
|
});
|
|
1121
1176
|
}
|
|
1122
1177
|
page.invalidate();
|
|
@@ -1125,19 +1180,20 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1125
1180
|
// ─── 工具:user_profile_lookup ───
|
|
1126
1181
|
// 让 Agent 在对话中主动查询任意用户的事实档案。
|
|
1127
1182
|
// 设计要点:
|
|
1128
|
-
// -
|
|
1183
|
+
// - 全局可查:跨平台/跨群均可
|
|
1129
1184
|
// - 两种调用方式:① user_key 直传;② platform+user_id 组合
|
|
1130
1185
|
tools.use.registerGroup({
|
|
1131
1186
|
name: 'user-profile',
|
|
1132
|
-
label: '
|
|
1133
|
-
|
|
1187
|
+
label: t('profiles'),
|
|
1188
|
+
summary: t('groupSummary'),
|
|
1189
|
+
description: '查询已经积累的某人事实档案。',
|
|
1134
1190
|
});
|
|
1135
1191
|
tools.use.register({
|
|
1136
1192
|
definition: {
|
|
1137
1193
|
type: 'function',
|
|
1138
1194
|
function: {
|
|
1139
1195
|
name: 'user_profile_lookup',
|
|
1140
|
-
description: '
|
|
1196
|
+
description: '查询你已积累的某个用户的事实档案。当对话中提到某人、或你想回忆已知信息时使用。' +
|
|
1141
1197
|
'可通过 user_key("<platform>:<userId>")直查,或用 platform + user_id 组合。',
|
|
1142
1198
|
parameters: {
|
|
1143
1199
|
type: 'object',
|
|
@@ -1199,36 +1255,33 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1199
1255
|
// /profile 查看自己的档案
|
|
1200
1256
|
// /profile clear 清除自己的档案(authority=2,自己删自己的)
|
|
1201
1257
|
// /profile clear nuke 清空所有用户档案(authority=3,dangerous)
|
|
1202
|
-
commands.use.command('profile', '
|
|
1258
|
+
commands.use.command('profile', t('profileDescription')).action(async (argv) => {
|
|
1203
1259
|
const userId = argv.session.userId;
|
|
1204
1260
|
if (!userId)
|
|
1205
|
-
return '
|
|
1261
|
+
return t('noIdentityView');
|
|
1206
1262
|
const userKey = userKeyOf(argv.session.platform, userId);
|
|
1207
1263
|
const profile = await loadProfileLenient(memory.present ? memory.use : undefined, userKey);
|
|
1208
1264
|
if (!profile || profile.facts.length === 0) {
|
|
1209
|
-
return
|
|
1265
|
+
return t('noProfileYet', { key: userKey });
|
|
1210
1266
|
}
|
|
1211
|
-
|
|
1212
|
-
const meta = `关系强度:${(profile.relationScore ?? 0).toFixed(relationScorePrecision)}/100,互动次数:${profile.interactionCount ?? 0}`;
|
|
1213
|
-
return `📇 你的档案 (${userKey})\n${meta}\n\n${block}`;
|
|
1267
|
+
return profileText(profile, userKey);
|
|
1214
1268
|
});
|
|
1215
|
-
commands.use.command('profile.clear', '
|
|
1269
|
+
commands.use.command('profile.clear', t('clearDescription'), { visibility: 'restricted' }).action(async (argv) => {
|
|
1216
1270
|
const userId = argv.session.userId;
|
|
1217
1271
|
if (!userId)
|
|
1218
|
-
return '
|
|
1272
|
+
return t('noIdentityClear');
|
|
1219
1273
|
const userKey = userKeyOf(argv.session.platform, userId);
|
|
1220
1274
|
const mem = pinnedMemory();
|
|
1221
1275
|
try {
|
|
1222
1276
|
const existed = await mem.getMetadata(PROFILE_NS, userKey);
|
|
1223
1277
|
if (!existed)
|
|
1224
|
-
return
|
|
1278
|
+
return t('noOwnProfile', { key: userKey });
|
|
1225
1279
|
await mem.deleteMetadata(PROFILE_NS, userKey);
|
|
1226
1280
|
page.invalidate();
|
|
1227
|
-
return
|
|
1281
|
+
return t('ownCleared', { key: userKey });
|
|
1228
1282
|
}
|
|
1229
1283
|
catch (err) {
|
|
1230
|
-
|
|
1231
|
-
return `❌ 清除失败:${m}`;
|
|
1284
|
+
return t('ownClearFailed', { error: textOf(err) });
|
|
1232
1285
|
}
|
|
1233
1286
|
});
|
|
1234
1287
|
// 定点除毒入口(2026-08 群主档案投毒事件的运维刚需):此前删单条事实只能
|
|
@@ -1236,7 +1289,7 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1236
1289
|
//(它改的是**他人**档案,且 target 含冒号时直传 key、可触及任意一份档案)。
|
|
1237
1290
|
// id 未命中时列出前若干条供定位。
|
|
1238
1291
|
commands.use
|
|
1239
|
-
.command('profile.forget <target:string> <factId:string>', '
|
|
1292
|
+
.command('profile.forget <target:string> <factId:string>', t('forgetDescription'), {
|
|
1240
1293
|
risk: 'dangerous',
|
|
1241
1294
|
})
|
|
1242
1295
|
.action(async (argv, targetArg, factIdArg) => {
|
|
@@ -1247,14 +1300,14 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1247
1300
|
try {
|
|
1248
1301
|
const profile = await loadProfile(mem, userKey);
|
|
1249
1302
|
if (!profile || profile.facts.length === 0)
|
|
1250
|
-
return
|
|
1303
|
+
return t('noProfile', { key: userKey });
|
|
1251
1304
|
const hit = profile.facts.find(f => f.id === factId);
|
|
1252
1305
|
if (!hit) {
|
|
1253
1306
|
const listing = profile.facts
|
|
1254
1307
|
.slice(0, 20)
|
|
1255
1308
|
.map(f => ` [${f.id}] ${f.text.slice(0, 50)}`)
|
|
1256
1309
|
.join('\n');
|
|
1257
|
-
return
|
|
1310
|
+
return t('factNotFound', { factId, key: userKey, count: profile.facts.length, listing });
|
|
1258
1311
|
}
|
|
1259
1312
|
await saveProfile(mem, userKey, {
|
|
1260
1313
|
...profile,
|
|
@@ -1262,22 +1315,21 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1262
1315
|
updatedAt: Date.now(),
|
|
1263
1316
|
});
|
|
1264
1317
|
page.invalidate();
|
|
1265
|
-
return
|
|
1318
|
+
return t('factForgotten', { key: userKey, factId, text: hit.text.slice(0, 60) });
|
|
1266
1319
|
}
|
|
1267
1320
|
catch (err) {
|
|
1268
|
-
return
|
|
1321
|
+
return t('forgetFailed', { error: textOf(err) });
|
|
1269
1322
|
}
|
|
1270
1323
|
});
|
|
1271
|
-
commands.use.command('profile.clear.nuke', '
|
|
1324
|
+
commands.use.command('profile.clear.nuke', t('nukeDescription'), { risk: 'dangerous' }).action(async () => {
|
|
1272
1325
|
const mem = pinnedMemory();
|
|
1273
1326
|
try {
|
|
1274
1327
|
const [keys] = await clearMetadataNamespaces(mem, [PROFILE_NS], logger.use);
|
|
1275
1328
|
page.invalidate();
|
|
1276
|
-
return
|
|
1329
|
+
return t('nukeDone', { count: keys.length });
|
|
1277
1330
|
}
|
|
1278
1331
|
catch (err) {
|
|
1279
|
-
|
|
1280
|
-
return `❌ 清空失败:${m}`;
|
|
1332
|
+
return t('nukeFailed', { error: textOf(err) });
|
|
1281
1333
|
}
|
|
1282
1334
|
});
|
|
1283
1335
|
logger.use.info(`用户事实档案已启用 (every=${cfg.extractEveryNMessages <= 0 ? '禁用提取' : `${cfg.extractEveryNMessages}msgs`}, history=${cfg.historyForExtraction}, ` +
|