@aalis/plugin-user-profile 0.14.0 → 0.15.0

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