@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.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 { lightestThinking, llm, resolveLLMModel } from '@aalis/api-llm';
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 { DIRECTIVE_KINDS, WellKnownMetadataKeys } from '@aalis/schema-message';
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: '每 N 条消息提取一次',
41
- description: '同一用户每发 N 条消息触发一次事实提取。无论 Aalis 是否回复都会计数,群聊中每人独立计数。设为 1 表示每条消息都尝试提取(不推荐);设为 0 或负数则禁用提取(仍会注入已有档案)',
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: '触发提取时,喂给 LLM 的最近消息条数',
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: '超出后按 updatedAt 升序淘汰最久未更新的,旧事实自动淘汰。最小有效值为 5(写入时自动下限),不支持设为 0 来禁用提取;若要禁用提取请将 extractEveryNMessages 设为 0',
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: '超出会被裁断,避免 LLM 输出长段落代替事实',
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: '群聊中除当前发言者外,最多加载多少人的档案摘要注入 LLM。0 表示禁用群聊多用户注入',
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: '群聊背景参与者每人只显示最近更新的 N 条事实,避免 prompt 过长',
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: 'temporality=temporary 的事实超过该天数未更新后不再主动注入 prompt(仍保留在档案中,等待后续 update/remove)。0 表示不淡出',
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: '用户长期未互动时,relationScore 每天衰减的分数。0 表示不衰减',
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: 'direct 触发时每条消息增加的关系强度',
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: '每条入站消息无论 agent 是否回复都加上的最低档增量。若 agent 触发回复,会再叠加 direct/immediate/interval 之一。0 表示旁观不加分;互动次数与最近互动时戳仍照记(衰减基准与最近互动排序的唯一来源)',
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
- const KNOWN_CATEGORIES = [
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
- * 将文本标准化为可子串匹配的形式:去空白、去常见标点、toLowerCase。
175
- * 用于 sourceQuote 校验,容忍 LLM 轻微原词改写。
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\u3000]+/g, '')
181
- .replace(/[。,、;:?!“”‘’()《》【】.,;:?!"'()[\]<>\-_~`*]/g, '');
187
+ .replace(/[\p{P}\s\p{Default_Ignorable_Code_Point}]+/gu, '');
182
188
  }
183
189
  /**
184
- * 拼接目标用户自己发言作为 sourceQuote 校验语料。
185
- * 只包含目标用户 role==='user' 且 metadata.userId 匹配的消息。
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 => m.content ?? '')
191
- .join('\n');
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(引用原话不会在目标用户语料中出现,sourceQuote 校验能拦住)。
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
- // Aalis 的回复:加标签让 LLM 知道用户是在跟谁互动,用于判断「对 Aalis 的行为」
208
- return `[${time}] [Aalis]: ${content}`;
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 Error(`不认识的事实:${rowKey}`);
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 Error('这条事实已经不在档案里了');
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 `已删除:${hit.text}`;
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 Error(`${rowKey} 已经没有档案了`);
445
+ throw new TextError(t('profileGone', { key: rowKey }));
429
446
  await mem.deleteMetadata(PROFILE_NS, rowKey);
430
447
  page.invalidate();
431
- return `已清空 ${rowKey} 的档案`;
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: '用户', primary: true },
444
- { key: 'facts', label: '事实', primary: true },
445
- { key: 'updatedAt', label: '最近记下', kind: 'time' },
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: '事实', primary: true },
466
- { key: 'category', label: '分类' },
467
- { key: 'temporality', label: '时效' },
468
- { key: 'updatedAt', label: '记下', kind: 'time' },
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 t = typeof o.text === 'string' ? o.text.trim() : '';
503
- if (!t)
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: t,
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 t = typeof o.text === 'string' ? o.text.trim() : '';
525
- if (!id || !t)
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: t,
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
- .filter((x) => typeof x === 'string' && x.trim().length > 0)
544
- .map(s => s.trim())
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
- '`[目标用户 X(uid)]`、`[其他用户 Y(uid)]` 或 `[Aalis]`。上下文仅供消歧,' +
557
- '**只允许根据「目标用户」自己的发言写入事实**,不能根据其他用户、Aalis 的猜测或提问写入事实。' +
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 删除已被推翻、过时的事实(不需 sourceQuote)' +
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
- '\n ⚠️ 例外——`人际关系` 类:除上述通用规则外,该类还允许记录目标用户**与 Aalis 之间长期可观察的关系模式**,' +
597
+ `\n ⚠️ 例外——\`人际关系\` 类:除上述通用规则外,该类还允许记录目标用户**与 ${botName} 之间长期可观察的关系模式**,` +
570
598
  '包含两种情况:' +
571
- '\n (a) 行为身份类:用户在 Aalis 项目中扮演的角色或长期重复的行为' +
572
- '(示例 text:「是 Aalis 的开发维护者」「经常向 Aalis 提交功能建议」「常帮助测试和排查 Aalis 的故障」)' +
573
- '\n (b) 长期情感模式类:用户对 Aalis 表现出的**反复出现的态度倾向**(不是单次情绪)' +
574
- '(示例 text:「常对 Aalis 表达依赖与喜爱」「习惯把日常烦恼向 Aalis 倾诉」「喜欢调侃 Aalis」)' +
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 用一句简洁中文,不超过 ${cfg.maxFactCharsPerItem} 字,不带「用户」「他」等代词,直接陈述事实` +
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
- // 优先用 cfg.extractLLM 指定的模型;否则取默认 chat-capable LLM。
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
- let text = (resp.content ?? '').trim();
612
- if (!text)
613
- return empty;
614
- let { parsed } = parseLLMJsonObject(text);
615
- if (!parsed) {
616
- // util-json-repair 已尝试剥 fence + 修裸引号 + 补括号;仍失败 → 模型多半
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: '你上一条输出无法被 JSON.parse(很可能是包了 markdown 代码块、夹杂解释文字、或被截断)。' +
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
- text = (retryResp.content ?? '').trim();
636
- ({ parsed } = parseLLMJsonObject(text));
637
- if (!parsed) {
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
- // sourceQuote 校验:quote 可能跨多条消息(LLM 用 \n 拼接),
645
- // 因此按行拆分,每行(非空)都必须能在「目标用户」语料中找到 normalize 后的子串。
646
- // 这样即使行与行之间夹着其他用户的消息也不会被错误拒收。
647
- const targetCorpus = normalizeForQuoteMatch(buildTargetUserCorpus(history, userId, platform));
648
- const validateSourceQuote = (item, kind) => {
649
- const q = item.sourceQuote?.trim() ?? '';
650
- if (!q) {
651
- logger.use.warn(`[user-profile] 丢弃 ${kind} fact(未提供 sourceQuote): ${item.text}`);
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 lines = q
655
- .split(/\r?\n/)
656
- .map(l => l.trim())
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
- return { add: validatedAdd, update: validatedUpdate, remove };
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) + (shouldCount ? 1 : 0),
713
- lastInteractionAt: shouldCount ? now : profile.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
- if (!userId)
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
- void updateRelationForUser(userKey, 'witness').catch((err) => logger.use.debug(`witness 关系更新异常 (${userKey}): ${err instanceof Error ? err.message : String(err)}`));
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
- void triggerExtractionForUser(sessionId, userId, platform ?? '', nickname).catch((err) => logger.use.debug(`事实提取异常 (${countKey}): ${err instanceof Error ? err.message : String(err)}`));
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
- /** 将一个用户的 Fact[] 渲染为分组文本块 */
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
- const sections = [];
927
- for (const cat of KNOWN_CATEGORIES) {
928
- const items = groups.get(cat);
929
- if (items && items.length > 0)
930
- sections.push(`## ${cat}\n${items.join('\n')}`);
931
- }
932
- for (const [cat, items] of groups) {
933
- if (!KNOWN_CATEGORIES.includes(cat) && items.length > 0) {
934
- sections.push(`## ${cat}\n${items.join('\n')}`);
935
- }
936
- }
937
- return sections.join('\n\n');
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 槽——档案按当前发言者取材、换人即变,落历史后护前缀缓存):根据 triggerType 区分主发言者语义 ───
947
- // direct/immediate/undefined → view.userId 是主发言者,注入完整档案 + 其他参与者摘要
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 快照)跳过档案加载——该路径 userId 为空串,加载既昂贵又无意义
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 trigger = data.triggerType ?? 'direct';
961
- const hasPrimarySpeaker = trigger === 'direct' || trigger === 'immediate';
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
- if (hasPrimarySpeaker && data.userId) {
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, data.userId, false);
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 = `# 关于当前对话者(${data.userId})的已知事实\n` +
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
- // - hasPrimarySpeaker 为 true 时排除主发言者
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 (primaryKey && userKey === primaryKey)
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: `用户档案已清空 (${keys.length} 条)`,
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: `用户档案清空失败: ${m}`,
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
- // - 全局可查(用户决定 C):跨平台/跨群均可
1183
+ // - 全局可查:跨平台/跨群均可
1129
1184
  // - 两种调用方式:① user_key 直传;② platform+user_id 组合
1130
1185
  tools.use.registerGroup({
1131
1186
  name: 'user-profile',
1132
- label: '用户档案',
1133
- description: '查询 Aalis 已经积累的某人事实档案。',
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: '查询 Aalis 已积累的某个用户的事实档案。当对话中提到某人、或你想回忆已知信息时使用。' +
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', '查看你在 Aalis 中的事实档案').action(async (argv) => {
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 `📭 暂无档案数据 (${userKey})`;
1265
+ return t('noProfileYet', { key: userKey });
1210
1266
  }
1211
- const block = renderProfileBlock(profile.facts, userKey, false);
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', '清除你自己的事实档案', { visibility: 'restricted' }).action(async (argv) => {
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 `📭 你当前没有档案数据 (${userKey})`;
1278
+ return t('noOwnProfile', { key: userKey });
1225
1279
  await mem.deleteMetadata(PROFILE_NS, userKey);
1226
1280
  page.invalidate();
1227
- return `✅ 已清除你的档案 (${userKey})`;
1281
+ return t('ownCleared', { key: userKey });
1228
1282
  }
1229
1283
  catch (err) {
1230
- const m = err instanceof Error ? err.message : String(err);
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 `📭 无档案数据 (${userKey})`;
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 `未找到事实 [${factId}]。${userKey} 现有 ${profile.facts.length} 条:\n${listing}`;
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 `✅ 已删除 ${userKey} 的事实 [${factId}]:${hit.text.slice(0, 60)}`;
1318
+ return t('factForgotten', { key: userKey, factId, text: hit.text.slice(0, 60) });
1266
1319
  }
1267
1320
  catch (err) {
1268
- return `❌ 删除失败:${err instanceof Error ? err.message : String(err)}`;
1321
+ return t('forgetFailed', { error: textOf(err) });
1269
1322
  }
1270
1323
  });
1271
- commands.use.command('profile.clear.nuke', '【危险】清空所有用户档案', { risk: 'dangerous' }).action(async () => {
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 `✅ 已清空全部用户档案(${keys.length} 条)`;
1329
+ return t('nukeDone', { count: keys.length });
1277
1330
  }
1278
1331
  catch (err) {
1279
- const m = err instanceof Error ? err.message : String(err);
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}, ` +