@aalis/plugin-user-profile 0.13.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +9 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +438 -1075
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +90 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +90 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/texts.d.ts +179 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +96 -0
- package/dist/texts.js.map +1 -0
- package/package.json +21 -18
package/dist/index.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import { authority, DEFAULT_AUTHORITY } from '@aalis/api-authority';
|
|
2
1
|
import { commands } from '@aalis/api-commands';
|
|
3
2
|
import { contributions } from '@aalis/api-contributions';
|
|
4
3
|
import { hooks } from '@aalis/api-hooks';
|
|
5
|
-
import {
|
|
4
|
+
import { i18n, outputLanguageFor } from '@aalis/api-i18n';
|
|
5
|
+
import { chatModelPicker, lightestThinking, llm } from '@aalis/api-llm';
|
|
6
6
|
import { clearMetadataNamespaces, memory } from '@aalis/api-memory';
|
|
7
7
|
import { persona } from '@aalis/api-persona';
|
|
8
8
|
import { tools } from '@aalis/api-tools';
|
|
9
9
|
import { userRelation } from '@aalis/api-user-relation';
|
|
10
|
+
import { webuiServer } from '@aalis/api-webui';
|
|
10
11
|
import { config, definePlugin, events, logger, optional } from '@aalis/core';
|
|
11
12
|
import { defineConfig, parseConfig } from '@aalis/schema-config';
|
|
12
|
-
import {
|
|
13
|
+
import { TextError, textClauses, textLines, textOf } from '@aalis/schema-i18n';
|
|
14
|
+
import { DIRECTIVE_KINDS, WellKnownMetadataKeys, wasAddressed } from '@aalis/schema-message';
|
|
13
15
|
import { parseLLMJsonObject } from '@aalis/util-json-repair';
|
|
16
|
+
import { t } from './texts.js';
|
|
14
17
|
// ════════════════════════════════════════════════════════════
|
|
15
18
|
// plugin-user-profile — 用户事实档案
|
|
16
19
|
//
|
|
@@ -24,7 +27,7 @@ import { parseLLMJsonObject } from '@aalis/util-json-repair';
|
|
|
24
27
|
// - 渐进合并:每次提取由 LLM 输出 add/remove 两个数组,自然演化
|
|
25
28
|
// - 零侵入:通过 events + middleware 接入,不修改 agent / persona / memory
|
|
26
29
|
//
|
|
27
|
-
// 关系强度(relationScore, 0~100
|
|
30
|
+
// 关系强度(relationScore, 0~100)计算规则(只算真人的话,带来源的注入不加):
|
|
28
31
|
// - 每条入站消息(无论 agent 是否回复):+ relationIncrementWitness(默认 0.1,旁观档)
|
|
29
32
|
// - 若 agent 触发回复,额外叠加:
|
|
30
33
|
// direct(私聊) + relationIncrementDirect 默认 1.0
|
|
@@ -35,196 +38,114 @@ import { parseLLMJsonObject } from '@aalis/util-json-repair';
|
|
|
35
38
|
// 例:群聊纯旁观一条 = +0.1;群聊被 @ 并回复 = +0.1 + 1.5 = +1.6;私聊一条 = +0.1 + 1.0 = +1.1
|
|
36
39
|
// ════════════════════════════════════════════════════════════
|
|
37
40
|
const PROFILE_NS = 'user:profile';
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
* 按 persona name 分堆:同名 persona(如 onebot 上的 aalis 与 webui 上的 aalis-webui两张卡都叫 Aalis)共享内心;
|
|
41
|
-
* 不同 persona(如 Babel)各自独立。 */
|
|
42
|
-
const SELF_KEY_PREFIX = '__self__:';
|
|
43
|
-
const DEFAULT_PERSONA_NAME = 'Aalis';
|
|
44
|
-
/** 第三方行为指令的独立 namespace(与 user:profile 解耦,独立生命周期)。
|
|
45
|
-
* 指令是「第三方对 Aalis 的客观行为约束」(如:不要长时间封禁;按严重程度分层禁言),
|
|
46
|
-
* 按 persona 名分堆(key = persona 名),与 selfFacts 的归属方式保持一致:
|
|
47
|
-
* 同名 persona(如 onebot 与 webui 两张都叫 Aalis)共享准则;不同 persona(如 Babel)各自独立。 */
|
|
48
|
-
const INSTRUCTIONS_NS = 'aalis:instructions';
|
|
41
|
+
/** 人设服务不在、或卡没取名时,提取提示里对机器人的称呼 */
|
|
42
|
+
const BOT_FALLBACK_NAME = '机器人';
|
|
49
43
|
const configSchema = defineConfig({
|
|
50
44
|
extractEveryNMessages: {
|
|
51
45
|
type: 'number',
|
|
52
|
-
label: '
|
|
53
|
-
description: '
|
|
46
|
+
label: t('extractEveryLabel'),
|
|
47
|
+
description: t('extractEveryDescription'),
|
|
54
48
|
default: 5,
|
|
55
49
|
},
|
|
56
50
|
historyForExtraction: {
|
|
57
51
|
type: 'number',
|
|
58
|
-
label: '
|
|
59
|
-
description: '
|
|
52
|
+
label: t('historyLabel'),
|
|
53
|
+
description: t('historyDescription'),
|
|
60
54
|
default: 8,
|
|
61
55
|
},
|
|
62
56
|
maxFactsPerUser: {
|
|
63
57
|
type: 'number',
|
|
64
|
-
label: '
|
|
65
|
-
description: '
|
|
58
|
+
label: t('maxFactsLabel'),
|
|
59
|
+
description: t('maxFactsDescription'),
|
|
66
60
|
default: 30,
|
|
67
61
|
},
|
|
68
62
|
maxFactCharsPerItem: {
|
|
69
63
|
type: 'number',
|
|
70
|
-
label: '
|
|
71
|
-
description: '
|
|
64
|
+
label: t('maxFactCharsLabel'),
|
|
65
|
+
description: t('maxFactCharsDescription'),
|
|
72
66
|
default: 80,
|
|
73
67
|
},
|
|
74
68
|
maxOtherParticipants: {
|
|
75
69
|
type: 'number',
|
|
76
|
-
label: '
|
|
77
|
-
description: '
|
|
70
|
+
label: t('maxOthersLabel'),
|
|
71
|
+
description: t('maxOthersDescription'),
|
|
78
72
|
default: 3,
|
|
79
73
|
},
|
|
80
74
|
maxFactsForOthers: {
|
|
81
75
|
type: 'number',
|
|
82
|
-
label: '
|
|
83
|
-
description: '
|
|
76
|
+
label: t('maxFactsForOthersLabel'),
|
|
77
|
+
description: t('maxFactsForOthersDescription'),
|
|
84
78
|
default: 5,
|
|
85
79
|
},
|
|
86
80
|
temporaryFactMaxAgeDays: {
|
|
87
81
|
type: 'number',
|
|
88
|
-
label: '
|
|
89
|
-
description: '
|
|
82
|
+
label: t('temporaryMaxAgeLabel'),
|
|
83
|
+
description: t('temporaryMaxAgeDescription'),
|
|
90
84
|
default: 90,
|
|
91
85
|
},
|
|
92
86
|
relationScoreDecayPerDay: {
|
|
93
87
|
type: 'number',
|
|
94
|
-
label: '
|
|
95
|
-
description: '
|
|
88
|
+
label: t('decayLabel'),
|
|
89
|
+
description: t('decayDescription'),
|
|
96
90
|
default: 0.5,
|
|
97
91
|
},
|
|
98
92
|
relationIncrementDirect: {
|
|
99
93
|
type: 'number',
|
|
100
|
-
label: '
|
|
101
|
-
description: '
|
|
94
|
+
label: t('directLabel'),
|
|
95
|
+
description: t('directDescription'),
|
|
102
96
|
default: 1,
|
|
103
97
|
},
|
|
104
98
|
relationIncrementImmediate: {
|
|
105
99
|
type: 'number',
|
|
106
|
-
label: '
|
|
107
|
-
description: '
|
|
100
|
+
label: t('immediateLabel'),
|
|
101
|
+
description: t('immediateDescription'),
|
|
108
102
|
default: 1.5,
|
|
109
103
|
},
|
|
110
104
|
relationIncrementInterval: {
|
|
111
105
|
type: 'number',
|
|
112
|
-
label: '
|
|
113
|
-
description: '
|
|
106
|
+
label: t('intervalLabel'),
|
|
107
|
+
description: t('intervalDescription'),
|
|
114
108
|
default: 0.5,
|
|
115
109
|
},
|
|
116
110
|
relationIncrementWitness: {
|
|
117
111
|
type: 'number',
|
|
118
|
-
label: '
|
|
119
|
-
description: '
|
|
112
|
+
label: t('witnessLabel'),
|
|
113
|
+
description: t('witnessDescription'),
|
|
120
114
|
default: 0.1,
|
|
121
115
|
},
|
|
122
|
-
extractLLM: {
|
|
123
|
-
type: 'llm-ref',
|
|
124
|
-
label: '提取用模型',
|
|
125
|
-
description: '留空则使用当前 LLM 服务的默认模型。事实提取是简单结构化任务,推荐选择廉价/快速模型(如 deepseek-chat)以降低成本',
|
|
126
|
-
},
|
|
127
116
|
allowGlobalBackfill: {
|
|
128
117
|
type: 'boolean',
|
|
129
118
|
onInvalid: 'error',
|
|
130
|
-
label: '
|
|
131
|
-
description: '
|
|
132
|
-
default: false,
|
|
133
|
-
},
|
|
134
|
-
enableSelfProfile: {
|
|
135
|
-
type: 'boolean',
|
|
136
|
-
onInvalid: 'error',
|
|
137
|
-
label: '启用 Aalis 自档案',
|
|
138
|
-
description: '让 Aalis 周期性地反思自身,提炼「关于自己的事实」(近期心情走向、在意的事、自我观察)。注入到所有 LLM 调用的 system prompt 早段,提供跨会话的人格延续性。' +
|
|
139
|
-
'⚠️ 默认关闭:自档案是模型对自身输出的二次提炼,再注入会自我强化先前的臆测、随时间漂移并放大幻觉,不建议开启',
|
|
119
|
+
label: t('backfillLabel'),
|
|
120
|
+
description: t('backfillDescription'),
|
|
140
121
|
default: false,
|
|
141
122
|
},
|
|
142
|
-
selfReflectEveryNMessages: {
|
|
143
|
-
type: 'number',
|
|
144
|
-
label: 'Aalis 自反思触发频次',
|
|
145
|
-
description: '全局累计入站消息每 N 条触发一次自反思。建议比单用户提取慢(默认 25),避免抖动',
|
|
146
|
-
default: 25,
|
|
147
|
-
},
|
|
148
|
-
selfReflectHistory: {
|
|
149
|
-
type: 'number',
|
|
150
|
-
label: '自反思参考历史条数',
|
|
151
|
-
description: '触发自反思时,从触发会话取最近多少条消息作为反思材料',
|
|
152
|
-
default: 16,
|
|
153
|
-
},
|
|
154
|
-
maxSelfFacts: {
|
|
155
|
-
type: 'number',
|
|
156
|
-
label: 'Aalis 自档案事实上限',
|
|
157
|
-
description: '超出后按 updatedAt 升序淘汰最久未更新的。最小有效值为 5(写入时自动下限),不支持设为 0 来禁用;若要禁用自反思请关闭 enableSelfProfile',
|
|
158
|
-
default: 20,
|
|
159
|
-
},
|
|
160
|
-
enableInstructions: {
|
|
161
|
-
type: 'boolean',
|
|
162
|
-
onInvalid: 'error',
|
|
163
|
-
label: '启用第三方行为指令',
|
|
164
|
-
description: '让 Aalis 记录第三方(管理员/操作者)对其行为的客观指令(如「不要长时间封禁」「按严重程度分层禁言」),作为跨会话的行为约束。' +
|
|
165
|
-
'注入到 system prompt 最前段,优先级高于自反思 selfFacts。按当前 persona 名分堆。',
|
|
166
|
-
default: true,
|
|
167
|
-
},
|
|
168
|
-
instructionMinAuthority: {
|
|
169
|
-
type: 'number',
|
|
170
|
-
label: '指令最小权限门槛',
|
|
171
|
-
description: '只有 authority ≥ 该值的用户的发言才会被采纳为指令来源(防陌生人塞规则)。默认 2(一般管理员),设为 5 表示仅 owner。' +
|
|
172
|
-
'通过 /instruct 命令手动添加的指令不走该门槛(命令本身已是 authority 2 守卫)',
|
|
173
|
-
default: 2,
|
|
174
|
-
},
|
|
175
|
-
instructionExtractEveryNMessages: {
|
|
176
|
-
type: 'number',
|
|
177
|
-
label: '指令自动提取触发频次',
|
|
178
|
-
description: '全局累计入站消息每 N 条触发一次指令提取。建议比自反思更慢(默认 40),降低成本;设为 0 禁用 LLM 自动提取,只保留 /instruct 命令通道',
|
|
179
|
-
default: 40,
|
|
180
|
-
},
|
|
181
|
-
instructionHistoryForExtraction: {
|
|
182
|
-
type: 'number',
|
|
183
|
-
label: '指令提取参考历史条数',
|
|
184
|
-
description: '触发指令提取时从触发会话取最近多少条消息作为提取材料',
|
|
185
|
-
default: 20,
|
|
186
|
-
},
|
|
187
|
-
maxInstructions: {
|
|
188
|
-
type: 'number',
|
|
189
|
-
label: '指令条数上限',
|
|
190
|
-
description: '超出后按 updatedAt 升序淘汰最久未更新的。最小有效值为 3。指令上限建议比 selfFacts 紧,准则少而准比多而散更好',
|
|
191
|
-
default: 12,
|
|
192
|
-
},
|
|
193
|
-
maxInstructionCharsPerItem: {
|
|
194
|
-
type: 'number',
|
|
195
|
-
label: '单条指令字数上限',
|
|
196
|
-
description: '超出会被裁断',
|
|
197
|
-
default: 120,
|
|
198
|
-
},
|
|
199
123
|
});
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
'
|
|
206
|
-
'
|
|
207
|
-
'
|
|
208
|
-
'
|
|
209
|
-
'
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
'
|
|
213
|
-
'
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
];
|
|
219
|
-
/** 生成稳定短 ID(6 字符 base36,对 30 条以内规模碰撞概率极低);前缀 'f' 为事实、'i' 为指令 */
|
|
220
|
-
function genShortId(prefix, existing) {
|
|
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);
|
|
140
|
+
/** 生成事实的稳定短 ID(前缀 f 加 5 位 base36,对 30 条以内规模碰撞概率极低) */
|
|
141
|
+
function genShortId(existing) {
|
|
221
142
|
for (let i = 0; i < 8; i++) {
|
|
222
|
-
const id =
|
|
143
|
+
const id = `f${Math.random().toString(36).slice(2, 7)}`;
|
|
223
144
|
if (!existing.has(id))
|
|
224
145
|
return id;
|
|
225
146
|
}
|
|
226
147
|
// 极端兜底:加时间戳后缀
|
|
227
|
-
return
|
|
148
|
+
return `f${Date.now().toString(36).slice(-5)}`;
|
|
228
149
|
}
|
|
229
150
|
function decimalPlaces(value) {
|
|
230
151
|
if (!Number.isFinite(value))
|
|
@@ -244,7 +165,7 @@ function metadataString(message, key) {
|
|
|
244
165
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
245
166
|
}
|
|
246
167
|
function getMessageUserId(message) {
|
|
247
|
-
return metadataString(message,
|
|
168
|
+
return metadataString(message, WellKnownMetadataKeys.UserId) ?? message.name;
|
|
248
169
|
}
|
|
249
170
|
function isTargetUserMessage(message, userId, platform) {
|
|
250
171
|
if (message.role !== 'user' || !message.content)
|
|
@@ -252,35 +173,38 @@ function isTargetUserMessage(message, userId, platform) {
|
|
|
252
173
|
const msgUserId = getMessageUserId(message);
|
|
253
174
|
if (msgUserId !== userId)
|
|
254
175
|
return false;
|
|
255
|
-
const msgPlatform = metadataString(message,
|
|
176
|
+
const msgPlatform = metadataString(message, WellKnownMetadataKeys.Platform);
|
|
256
177
|
return !msgPlatform || !platform || msgPlatform === platform;
|
|
257
178
|
}
|
|
258
179
|
/**
|
|
259
|
-
*
|
|
260
|
-
*
|
|
180
|
+
* 核对原话的规整,原话与语料用同一份:NFKC(全角与半角、兼容写法归成同一个字)、小写,去掉 Unicode 标点、空白与不显示的字符
|
|
181
|
+
* (表情里的变体选择符与连接符);符号照留,「C++」不会只剩一个字母
|
|
261
182
|
*/
|
|
262
183
|
function normalizeForQuoteMatch(s) {
|
|
263
184
|
return s
|
|
185
|
+
.normalize('NFKC')
|
|
264
186
|
.toLowerCase()
|
|
265
|
-
.replace(/[\s\
|
|
266
|
-
.replace(/[。,、;:?!“”‘’()《》【】.,;:?!"'()[\]<>\-_~`*]/g, '');
|
|
187
|
+
.replace(/[\p{P}\s\p{Default_Ignorable_Code_Point}]+/gu, '');
|
|
267
188
|
}
|
|
268
189
|
/**
|
|
269
|
-
*
|
|
270
|
-
*
|
|
190
|
+
* 目标用户各条发言规整后的语料,原话的片段要落在其中某一条里。只收发言人自己写的字(存档另记的 SpokenContent,没记就是归档
|
|
191
|
+
* 正文本身):被引用的原文、识图、语音转写、视频描述、文件正文与合并转发都不在发言人的话里
|
|
271
192
|
*/
|
|
272
193
|
function buildTargetUserCorpus(history, userId, platform) {
|
|
273
194
|
return history
|
|
274
195
|
.filter(m => isTargetUserMessage(m, userId, platform))
|
|
275
|
-
.map(m =>
|
|
276
|
-
.
|
|
196
|
+
.map(m => {
|
|
197
|
+
const spoken = m.metadata?.[WellKnownMetadataKeys.SpokenContent];
|
|
198
|
+
return normalizeForQuoteMatch(typeof spoken === 'string' ? spoken : (m.content ?? ''));
|
|
199
|
+
});
|
|
277
200
|
}
|
|
278
201
|
/**
|
|
279
202
|
* 渲染完整会话历史供提取 LLM 作为消歧上下文:
|
|
280
|
-
*
|
|
281
|
-
* 不做 strip
|
|
203
|
+
* 每行带身份标签区分「目标用户」「其他用户」与她自己(botName),
|
|
204
|
+
* 不做 strip:给模型看的是归档正文(引用、附件描述与转发都在,消歧要用);
|
|
205
|
+
* 核对原话另取发言人自己写的话(buildTargetUserCorpus)。
|
|
282
206
|
*/
|
|
283
|
-
function renderHistoryForExtract(history, userId, platform) {
|
|
207
|
+
function renderHistoryForExtract(history, userId, platform, botName) {
|
|
284
208
|
return history
|
|
285
209
|
.filter(m => (m.role === 'user' || m.role === 'assistant' || m.role === 'notice') && m.content)
|
|
286
210
|
.map(m => {
|
|
@@ -289,16 +213,16 @@ function renderHistoryForExtract(history, userId, platform) {
|
|
|
289
213
|
if (!content)
|
|
290
214
|
return '';
|
|
291
215
|
if (m.role === 'assistant') {
|
|
292
|
-
//
|
|
293
|
-
return `[${time}] [
|
|
216
|
+
// 她的回复:加标签让 LLM 知道用户是在跟谁互动,用于判断用户对她的行为
|
|
217
|
+
return `[${time}] [${botName}]: ${content}`;
|
|
294
218
|
}
|
|
295
219
|
if (m.role === 'notice') {
|
|
296
220
|
// 平台事件(群禁言 / 撤回 / 成员进出 / poke 等),供提取判断「用户被谁怎么了」之类上下文
|
|
297
221
|
return `[${time}] [系统通知]: ${content}`;
|
|
298
222
|
}
|
|
299
|
-
const nickname = metadataString(m,
|
|
223
|
+
const nickname = metadataString(m, WellKnownMetadataKeys.Nickname);
|
|
300
224
|
const msgUserId = getMessageUserId(m) ?? '';
|
|
301
|
-
const msgPlatform = metadataString(m,
|
|
225
|
+
const msgPlatform = metadataString(m, WellKnownMetadataKeys.Platform);
|
|
302
226
|
const isTarget = msgUserId === userId && (!msgPlatform || !platform || msgPlatform === platform);
|
|
303
227
|
const label = nickname ? `${nickname}(${msgUserId || '?'})` : msgUserId || '?';
|
|
304
228
|
const tag = isTarget ? '目标用户' : '其他用户';
|
|
@@ -307,6 +231,13 @@ function renderHistoryForExtract(history, userId, platform) {
|
|
|
307
231
|
.filter(Boolean)
|
|
308
232
|
.join('\n');
|
|
309
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
|
+
}
|
|
310
241
|
const uses = {
|
|
311
242
|
memory,
|
|
312
243
|
llm,
|
|
@@ -315,22 +246,26 @@ const uses = {
|
|
|
315
246
|
contributions,
|
|
316
247
|
config,
|
|
317
248
|
logger,
|
|
318
|
-
authority: optional(authority),
|
|
319
249
|
tools: optional(tools),
|
|
320
250
|
commands: optional(commands),
|
|
321
|
-
persona: optional(persona),
|
|
322
251
|
userRelation: optional(userRelation),
|
|
252
|
+
/** 提取提示里机器人的称呼取人设名;缺席时用中性称呼 */
|
|
253
|
+
persona: optional(persona),
|
|
254
|
+
/** 事实的语言:取全局层的语言列表(读者是 owner),缺席时按中文 */
|
|
255
|
+
i18n: optional(i18n),
|
|
256
|
+
/** 档案页;不在时照常记档案 */
|
|
257
|
+
webui: optional(webuiServer),
|
|
323
258
|
};
|
|
324
259
|
export default definePlugin({
|
|
325
260
|
name: '@aalis/plugin-user-profile',
|
|
326
|
-
displayName: '
|
|
261
|
+
displayName: t('displayName'),
|
|
327
262
|
subsystem: 'memory',
|
|
328
263
|
configSchema,
|
|
329
264
|
uses,
|
|
330
265
|
apply: registerUserProfile,
|
|
331
266
|
});
|
|
332
|
-
function registerUserProfile({ memory, llm, events, hooks, contributions, config, logger,
|
|
333
|
-
const parsed = parseConfig(configSchema, config, logger);
|
|
267
|
+
function registerUserProfile({ memory, llm, events, hooks, contributions, config, logger, tools, commands, userRelation, persona, i18n, webui, }) {
|
|
268
|
+
const parsed = parseConfig(configSchema, config.use, logger.use);
|
|
334
269
|
const cfg = {
|
|
335
270
|
...parsed,
|
|
336
271
|
historyForExtraction: Math.max(2, parsed.historyForExtraction),
|
|
@@ -344,25 +279,13 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
344
279
|
relationIncrementImmediate: Math.max(0, parsed.relationIncrementImmediate),
|
|
345
280
|
relationIncrementInterval: Math.max(0, parsed.relationIncrementInterval),
|
|
346
281
|
relationIncrementWitness: Math.max(0, parsed.relationIncrementWitness),
|
|
347
|
-
extractLLM: parsed.extractLLM &&
|
|
348
|
-
typeof parsed.extractLLM === 'object' &&
|
|
349
|
-
parsed.extractLLM.provider &&
|
|
350
|
-
parsed.extractLLM.model
|
|
351
|
-
? parsed.extractLLM
|
|
352
|
-
: undefined,
|
|
353
|
-
selfReflectEveryNMessages: Math.max(0, parsed.selfReflectEveryNMessages),
|
|
354
|
-
selfReflectHistory: Math.max(4, parsed.selfReflectHistory),
|
|
355
|
-
maxSelfFacts: Math.max(5, parsed.maxSelfFacts),
|
|
356
|
-
instructionMinAuthority: Math.max(0, parsed.instructionMinAuthority),
|
|
357
|
-
instructionExtractEveryNMessages: Math.max(0, parsed.instructionExtractEveryNMessages),
|
|
358
|
-
instructionHistoryForExtraction: Math.max(4, parsed.instructionHistoryForExtraction),
|
|
359
|
-
maxInstructions: Math.max(3, parsed.maxInstructions),
|
|
360
|
-
maxInstructionCharsPerItem: Math.max(20, parsed.maxInstructionCharsPerItem),
|
|
361
282
|
};
|
|
362
283
|
/** 每会话每用户累计入站消息数(用于 extractEveryNMessages 计数),不随提取重置 */
|
|
363
284
|
const userMessageCount = new Map();
|
|
364
285
|
/** 防止同一用户的提取并发触发 */
|
|
365
286
|
const inflightExtractions = new Set();
|
|
287
|
+
/** 提取模型:llm 服务给本插件挑的能对话的那一个(见 chatModelPicker) */
|
|
288
|
+
const pickChatModel = chatModelPicker(llm, () => logger.use.warn('没有可用的对话模型,事实提取跳过'));
|
|
366
289
|
const relationScorePrecision = Math.min(6, Math.max(decimalPlaces(cfg.relationScoreDecayPerDay), decimalPlaces(cfg.relationIncrementDirect), decimalPlaces(cfg.relationIncrementImmediate), decimalPlaces(cfg.relationIncrementInterval)));
|
|
367
290
|
function userKeyOf(platform, userId) {
|
|
368
291
|
return `${platform ?? ''}:${userId}`;
|
|
@@ -370,177 +293,6 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
370
293
|
function extractionCountKeyOf(sessionId, platform, userId) {
|
|
371
294
|
return `${sessionId}:${userKeyOf(platform, userId)}`;
|
|
372
295
|
}
|
|
373
|
-
/** 返回当前活跃 persona 的 self key(运行时解析,跟随 persona 切换)。
|
|
374
|
-
* 若 persona 服务不可用或未配 name,回退到默认 `aalis`。 */
|
|
375
|
-
function getCurrentPersonaName() {
|
|
376
|
-
try {
|
|
377
|
-
const name = persona.current?.getPersonaName().trim();
|
|
378
|
-
return name && name.length > 0 ? name : DEFAULT_PERSONA_NAME;
|
|
379
|
-
}
|
|
380
|
-
catch {
|
|
381
|
-
return DEFAULT_PERSONA_NAME;
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
function getSelfKey() {
|
|
385
|
-
return `${SELF_KEY_PREFIX}${getCurrentPersonaName()}`;
|
|
386
|
-
}
|
|
387
|
-
/** instructions 按 persona 名分堆:直接以 persona 名作为 metadata key */
|
|
388
|
-
function getInstructionsKey() {
|
|
389
|
-
return getCurrentPersonaName();
|
|
390
|
-
}
|
|
391
|
-
function normalizeInstructionCategory(v) {
|
|
392
|
-
if (typeof v !== 'string')
|
|
393
|
-
return undefined;
|
|
394
|
-
return KNOWN_INSTRUCTION_CATEGORIES.includes(v) ? v : undefined;
|
|
395
|
-
}
|
|
396
|
-
function normalizeInstructionSeverity(v) {
|
|
397
|
-
return v === 'must' || v === 'should' || v === 'avoid' ? v : undefined;
|
|
398
|
-
}
|
|
399
|
-
function clipInstructionText(s) {
|
|
400
|
-
return s.length > cfg.maxInstructionCharsPerItem ? `${s.slice(0, cfg.maxInstructionCharsPerItem)}…` : s;
|
|
401
|
-
}
|
|
402
|
-
function parseInstructionArray(raw) {
|
|
403
|
-
const usedIds = new Set();
|
|
404
|
-
const list = [];
|
|
405
|
-
for (const item of raw) {
|
|
406
|
-
if (!item || typeof item !== 'object')
|
|
407
|
-
continue;
|
|
408
|
-
const obj = item;
|
|
409
|
-
const text = typeof obj.text === 'string' ? obj.text.trim() : '';
|
|
410
|
-
if (!text)
|
|
411
|
-
continue;
|
|
412
|
-
let id = typeof obj.id === 'string' && obj.id.trim() ? obj.id.trim() : genShortId('i', usedIds);
|
|
413
|
-
while (usedIds.has(id))
|
|
414
|
-
id = genShortId('i', usedIds);
|
|
415
|
-
usedIds.add(id);
|
|
416
|
-
const updatedAt = typeof obj.updatedAt === 'number' ? obj.updatedAt : 0;
|
|
417
|
-
const observedAt = typeof obj.observedAt === 'number' ? obj.observedAt : updatedAt || undefined;
|
|
418
|
-
list.push({
|
|
419
|
-
id,
|
|
420
|
-
text,
|
|
421
|
-
category: normalizeInstructionCategory(obj.category),
|
|
422
|
-
severity: normalizeInstructionSeverity(obj.severity),
|
|
423
|
-
sourceUserKey: typeof obj.sourceUserKey === 'string' && obj.sourceUserKey.trim() ? obj.sourceUserKey : undefined,
|
|
424
|
-
sourceUserName: typeof obj.sourceUserName === 'string' && obj.sourceUserName.trim() ? obj.sourceUserName : undefined,
|
|
425
|
-
sourceChannel: obj.sourceChannel === 'llm' || obj.sourceChannel === 'command' ? obj.sourceChannel : undefined,
|
|
426
|
-
observedAt,
|
|
427
|
-
updatedAt,
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
return list;
|
|
431
|
-
}
|
|
432
|
-
/**
|
|
433
|
-
* 读取指令档案(不存在返回空档案)。读失败原样抛出,理由同 {@link loadProfile};
|
|
434
|
-
* 只读渲染走 {@link loadInstructionsLenient}。
|
|
435
|
-
* mem 由调用方传入:一次操作只认开头取到的实例,理由见 {@link loadProfile}
|
|
436
|
-
*/
|
|
437
|
-
async function loadInstructions(mem) {
|
|
438
|
-
const empty = { instructions: [], updatedAt: 0 };
|
|
439
|
-
if (!mem)
|
|
440
|
-
return empty;
|
|
441
|
-
const doc = await mem.getMetadata(INSTRUCTIONS_NS, getInstructionsKey());
|
|
442
|
-
if (!doc)
|
|
443
|
-
return empty;
|
|
444
|
-
const instructions = Array.isArray(doc.instructions) ? parseInstructionArray(doc.instructions) : [];
|
|
445
|
-
return { instructions, updatedAt: typeof doc.updatedAt === 'number' ? doc.updatedAt : 0 };
|
|
446
|
-
}
|
|
447
|
-
/** 只读渲染用:读失败按空档案处理,只记 debug(结果不会被写回) */
|
|
448
|
-
async function loadInstructionsLenient(mem) {
|
|
449
|
-
try {
|
|
450
|
-
return await loadInstructions(mem);
|
|
451
|
-
}
|
|
452
|
-
catch (err) {
|
|
453
|
-
logger.debug(`加载指令档案失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
454
|
-
return { instructions: [], updatedAt: 0 };
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
async function saveInstructions(mem, doc) {
|
|
458
|
-
if (!mem)
|
|
459
|
-
return;
|
|
460
|
-
await mem.saveMetadata(INSTRUCTIONS_NS, getInstructionsKey(), {
|
|
461
|
-
instructions: doc.instructions,
|
|
462
|
-
updatedAt: doc.updatedAt,
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
/** 把 add/update/remove 应用到现有 instructions,应用上限与单条裁剪 */
|
|
466
|
-
function mergeInstructions(existing, add, update, remove) {
|
|
467
|
-
const now = Date.now();
|
|
468
|
-
const byId = new Map();
|
|
469
|
-
for (const ins of existing)
|
|
470
|
-
byId.set(ins.id, ins);
|
|
471
|
-
for (const id of remove)
|
|
472
|
-
byId.delete(id);
|
|
473
|
-
const usedIds = new Set(byId.keys());
|
|
474
|
-
for (const { id, patch } of update) {
|
|
475
|
-
const old = byId.get(id);
|
|
476
|
-
if (!old)
|
|
477
|
-
continue;
|
|
478
|
-
byId.set(id, {
|
|
479
|
-
...old,
|
|
480
|
-
...patch,
|
|
481
|
-
id,
|
|
482
|
-
text: patch.text ? clipInstructionText(patch.text) : old.text,
|
|
483
|
-
observedAt: old.observedAt ?? now,
|
|
484
|
-
updatedAt: now,
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
const textSet = new Set(Array.from(byId.values()).map(f => f.text));
|
|
488
|
-
for (const a of add) {
|
|
489
|
-
const text = clipInstructionText(a.text);
|
|
490
|
-
if (textSet.has(text))
|
|
491
|
-
continue;
|
|
492
|
-
const id = genShortId('i', usedIds);
|
|
493
|
-
usedIds.add(id);
|
|
494
|
-
byId.set(id, {
|
|
495
|
-
id,
|
|
496
|
-
text,
|
|
497
|
-
category: a.category,
|
|
498
|
-
severity: a.severity,
|
|
499
|
-
sourceUserKey: a.sourceUserKey,
|
|
500
|
-
sourceUserName: a.sourceUserName,
|
|
501
|
-
sourceChannel: a.sourceChannel,
|
|
502
|
-
observedAt: now,
|
|
503
|
-
updatedAt: now,
|
|
504
|
-
});
|
|
505
|
-
textSet.add(text);
|
|
506
|
-
}
|
|
507
|
-
let merged = Array.from(byId.values());
|
|
508
|
-
if (cfg.maxInstructions > 0 && merged.length > cfg.maxInstructions) {
|
|
509
|
-
merged.sort((a, b) => a.updatedAt - b.updatedAt);
|
|
510
|
-
merged = merged.slice(merged.length - cfg.maxInstructions);
|
|
511
|
-
}
|
|
512
|
-
merged.sort((a, b) => a.updatedAt - b.updatedAt);
|
|
513
|
-
return merged;
|
|
514
|
-
}
|
|
515
|
-
function renderInstructionLine(ins) {
|
|
516
|
-
const tag = ins.severity === 'must' ? '【必须】' : ins.severity === 'avoid' ? '【避免】' : '【应当】';
|
|
517
|
-
const source = ins.sourceUserName ? `(来自 ${ins.sourceUserName})` : '';
|
|
518
|
-
return `- ${tag} ${ins.text}${source}`;
|
|
519
|
-
}
|
|
520
|
-
function renderInstructionsBlock(instructions) {
|
|
521
|
-
if (instructions.length === 0)
|
|
522
|
-
return '';
|
|
523
|
-
const groups = new Map();
|
|
524
|
-
for (const ins of instructions) {
|
|
525
|
-
const key = ins.category ?? '其他';
|
|
526
|
-
if (!groups.has(key))
|
|
527
|
-
groups.set(key, []);
|
|
528
|
-
groups.get(key).push(ins);
|
|
529
|
-
}
|
|
530
|
-
const sections = [];
|
|
531
|
-
for (const cat of KNOWN_INSTRUCTION_CATEGORIES) {
|
|
532
|
-
const items = groups.get(cat);
|
|
533
|
-
if (items && items.length > 0) {
|
|
534
|
-
sections.push(`## ${cat}\n${items.map(renderInstructionLine).join('\n')}`);
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
for (const [cat, items] of groups) {
|
|
538
|
-
if (!KNOWN_INSTRUCTION_CATEGORIES.includes(cat) && items.length > 0) {
|
|
539
|
-
sections.push(`## ${cat}\n${items.map(renderInstructionLine).join('\n')}`);
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
return sections.join('\n\n');
|
|
543
|
-
}
|
|
544
296
|
function normalizeTemporality(v, category) {
|
|
545
297
|
if (v === 'permanent' || v === 'temporary')
|
|
546
298
|
return v;
|
|
@@ -571,7 +323,7 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
571
323
|
}
|
|
572
324
|
let id = obj.id.trim();
|
|
573
325
|
while (usedIds.has(id))
|
|
574
|
-
id = genShortId(
|
|
326
|
+
id = genShortId(usedIds);
|
|
575
327
|
usedIds.add(id);
|
|
576
328
|
const cat = typeof obj.category === 'string' && KNOWN_CATEGORIES.includes(obj.category)
|
|
577
329
|
? obj.category
|
|
@@ -588,14 +340,16 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
588
340
|
}
|
|
589
341
|
return facts;
|
|
590
342
|
}
|
|
343
|
+
/** 一段先读后写或先查后删的操作固定在开头选中的那一家(见 {@link loadProfile});选不到时访问抛错,同 memory.use */
|
|
344
|
+
const pinnedMemory = () => memory.list().find(p => p.selected)?.use ?? memory.use;
|
|
591
345
|
/**
|
|
592
346
|
* 读取一个用户的现有档案(getMetadata 返回空才算不存在,返回 undefined)。
|
|
593
347
|
*
|
|
594
|
-
*
|
|
348
|
+
* 读失败原样抛出:先读后改再写的一方(关系分、事实提取、定点删事实)据此中止本次写入。
|
|
595
349
|
* 若把「读不出」当成「不存在」,调用方会用默认值覆盖写回,一次瞬时读错就清空整份档案。
|
|
596
350
|
* 只读渲染走 {@link loadProfileLenient}。
|
|
597
351
|
*
|
|
598
|
-
*
|
|
352
|
+
* 档案的读写函数都由调用方传入 mem,一次操作(读 → 调 LLM → 重读 → 覆盖写)全程只认
|
|
599
353
|
* 开头取到的实例:memory 胜者可能在 LLM 调用期间换人而本插件不重启,每步现取胜者会把 A 上
|
|
600
354
|
* 合并出的整张表覆盖写进 B,B 原有的条目被删。
|
|
601
355
|
*/
|
|
@@ -603,11 +357,12 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
603
357
|
if (!mem)
|
|
604
358
|
return undefined;
|
|
605
359
|
const doc = await mem.getMetadata(PROFILE_NS, userKey);
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
360
|
+
return doc ? profileFrom(doc) : undefined;
|
|
361
|
+
}
|
|
362
|
+
/** 存着的档案文档按类型取出,读不懂的事实跳过 */
|
|
363
|
+
function profileFrom(doc) {
|
|
609
364
|
return {
|
|
610
|
-
facts,
|
|
365
|
+
facts: Array.isArray(doc.facts) ? parseFactArray(doc.facts) : [],
|
|
611
366
|
relationScore: typeof doc.relationScore === 'number' ? Math.min(100, Math.max(0, doc.relationScore)) : 0,
|
|
612
367
|
interactionCount: typeof doc.interactionCount === 'number' ? Math.max(0, Math.floor(doc.interactionCount)) : 0,
|
|
613
368
|
lastInteractionAt: typeof doc.lastInteractionAt === 'number' ? doc.lastInteractionAt : undefined,
|
|
@@ -620,7 +375,7 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
620
375
|
return await loadProfile(mem, userKey);
|
|
621
376
|
}
|
|
622
377
|
catch (err) {
|
|
623
|
-
logger.debug(`加载用户档案失败 (${userKey}): ${err instanceof Error ? err.message : String(err)}`);
|
|
378
|
+
logger.use.debug(`加载用户档案失败 (${userKey}): ${err instanceof Error ? err.message : String(err)}`);
|
|
624
379
|
return undefined;
|
|
625
380
|
}
|
|
626
381
|
}
|
|
@@ -637,12 +392,122 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
637
392
|
};
|
|
638
393
|
await mem.saveMetadata(PROFILE_NS, userKey, payload);
|
|
639
394
|
}
|
|
395
|
+
// ─── 档案页:插件详情里查看与删除,只删不改。事实变了(这里、指令、提取、全局清理)就 invalidate ───
|
|
396
|
+
/** 事实那一行的 rowKey 是 [用户键, 事实 id] 的 JSON:行动作只收 rowKey,靠它找回是谁的哪一条 */
|
|
397
|
+
function parseFactRef(rowKey) {
|
|
398
|
+
let ref;
|
|
399
|
+
try {
|
|
400
|
+
ref = JSON.parse(rowKey);
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
ref = undefined;
|
|
404
|
+
}
|
|
405
|
+
if (!Array.isArray(ref) || ref.length !== 2 || !ref.every(part => typeof part === 'string')) {
|
|
406
|
+
throw new TextError(t('unknownFact', { rowKey }));
|
|
407
|
+
}
|
|
408
|
+
return ref;
|
|
409
|
+
}
|
|
410
|
+
const page = webui.use.definePage({
|
|
411
|
+
key: 'profiles',
|
|
412
|
+
title: t('profiles'),
|
|
413
|
+
icon: 'contact',
|
|
414
|
+
actions: {
|
|
415
|
+
forgetFact: {
|
|
416
|
+
label: t('forgetLabel'),
|
|
417
|
+
effect: 'destructive',
|
|
418
|
+
confirm: t('forgetConfirm'),
|
|
419
|
+
row: true,
|
|
420
|
+
async handler({ rowKey }) {
|
|
421
|
+
const [userKey, factId] = parseFactRef(rowKey);
|
|
422
|
+
const mem = pinnedMemory();
|
|
423
|
+
const profile = await loadProfile(mem, userKey);
|
|
424
|
+
const hit = profile?.facts.find(f => f.id === factId);
|
|
425
|
+
if (!profile || !hit)
|
|
426
|
+
throw new TextError(t('factGone'));
|
|
427
|
+
await saveProfile(mem, userKey, {
|
|
428
|
+
...profile,
|
|
429
|
+
facts: profile.facts.filter(f => f !== hit),
|
|
430
|
+
updatedAt: Date.now(),
|
|
431
|
+
});
|
|
432
|
+
page.invalidate();
|
|
433
|
+
return t('forgotten', { text: hit.text });
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
clearProfile: {
|
|
437
|
+
label: t('clearProfileLabel'),
|
|
438
|
+
effect: 'destructive',
|
|
439
|
+
confirm: t('clearProfileConfirm'),
|
|
440
|
+
row: true,
|
|
441
|
+
async handler({ rowKey }) {
|
|
442
|
+
const mem = pinnedMemory();
|
|
443
|
+
if (!(await mem.getMetadata(PROFILE_NS, rowKey)))
|
|
444
|
+
throw new TextError(t('profileGone', { key: rowKey }));
|
|
445
|
+
await mem.deleteMetadata(PROFILE_NS, rowKey);
|
|
446
|
+
page.invalidate();
|
|
447
|
+
return t('profileCleared', { key: rowKey });
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
content: [
|
|
452
|
+
{
|
|
453
|
+
type: 'table',
|
|
454
|
+
id: 'profiles',
|
|
455
|
+
rowKey: 'key',
|
|
456
|
+
description: t('pageDescription'),
|
|
457
|
+
empty: t('pageEmpty'),
|
|
458
|
+
columns: [
|
|
459
|
+
{ key: 'key', label: t('columnUser'), primary: true },
|
|
460
|
+
{ key: 'facts', label: t('columnFacts'), primary: true },
|
|
461
|
+
{ key: 'updatedAt', label: t('columnLastNoted'), kind: 'time' },
|
|
462
|
+
],
|
|
463
|
+
async data() {
|
|
464
|
+
const entries = await memory.use.listMetadata(PROFILE_NS);
|
|
465
|
+
return entries
|
|
466
|
+
.flatMap(({ key, data }) => {
|
|
467
|
+
const { facts } = profileFrom(data);
|
|
468
|
+
if (facts.length === 0)
|
|
469
|
+
return [];
|
|
470
|
+
return [{ key, facts: facts.length, updatedAt: Math.max(...facts.map(f => f.updatedAt)) }];
|
|
471
|
+
})
|
|
472
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
473
|
+
},
|
|
474
|
+
detail: [
|
|
475
|
+
{
|
|
476
|
+
type: 'table',
|
|
477
|
+
id: 'facts',
|
|
478
|
+
rowKey: 'ref',
|
|
479
|
+
empty: t('factsEmpty'),
|
|
480
|
+
columns: [
|
|
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' },
|
|
485
|
+
],
|
|
486
|
+
rowActions: [{ action: 'forgetFact' }],
|
|
487
|
+
async data({ rowKey }) {
|
|
488
|
+
const facts = (await loadProfile(memory.use, rowKey))?.facts ?? [];
|
|
489
|
+
return facts
|
|
490
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
491
|
+
.map(f => ({
|
|
492
|
+
ref: JSON.stringify([rowKey, f.id]),
|
|
493
|
+
text: f.text,
|
|
494
|
+
category: f.category ? CATEGORY_NAMES[f.category] : '',
|
|
495
|
+
temporality: f.temporality === 'temporary' ? t('termShort') : t('termLong'),
|
|
496
|
+
updatedAt: f.updatedAt,
|
|
497
|
+
}));
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
{ type: 'actions', actions: ['clearProfile'] },
|
|
501
|
+
],
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
});
|
|
640
505
|
function normalizeCategory(v) {
|
|
641
506
|
if (typeof v !== 'string')
|
|
642
507
|
return undefined;
|
|
643
508
|
return KNOWN_CATEGORIES.includes(v) ? v : undefined;
|
|
644
509
|
}
|
|
645
|
-
/** 解析 LLM 输出里的 add / update / remove 三类事实操作。sourceQuote
|
|
510
|
+
/** 解析 LLM 输出里的 add / update / remove 三类事实操作。sourceQuote 原样带出,由调用方校验;它不入库(mergeFactList 逐字段构造 Fact) */
|
|
646
511
|
function parseFactOps(parsed) {
|
|
647
512
|
const obj = parsed;
|
|
648
513
|
const add = Array.isArray(obj.add)
|
|
@@ -650,14 +515,14 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
650
515
|
if (!x || typeof x !== 'object')
|
|
651
516
|
return [];
|
|
652
517
|
const o = x;
|
|
653
|
-
const
|
|
654
|
-
if (!
|
|
518
|
+
const text = typeof o.text === 'string' ? o.text.trim() : '';
|
|
519
|
+
if (!text)
|
|
655
520
|
return [];
|
|
656
521
|
const category = normalizeCategory(o.category);
|
|
657
522
|
const sourceQuote = typeof o.sourceQuote === 'string' ? o.sourceQuote.trim() : '';
|
|
658
523
|
return [
|
|
659
524
|
{
|
|
660
|
-
text
|
|
525
|
+
text,
|
|
661
526
|
category,
|
|
662
527
|
temporality: normalizeTemporality(o.temporality, category),
|
|
663
528
|
timeHint: normalizeTextField(o.timeHint),
|
|
@@ -672,15 +537,15 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
672
537
|
return [];
|
|
673
538
|
const o = x;
|
|
674
539
|
const id = typeof o.id === 'string' ? o.id.trim() : '';
|
|
675
|
-
const
|
|
676
|
-
if (!id || !
|
|
540
|
+
const text = typeof o.text === 'string' ? o.text.trim() : '';
|
|
541
|
+
if (!id || !text)
|
|
677
542
|
return [];
|
|
678
543
|
const category = normalizeCategory(o.category);
|
|
679
544
|
const sourceQuote = typeof o.sourceQuote === 'string' ? o.sourceQuote.trim() : '';
|
|
680
545
|
return [
|
|
681
546
|
{
|
|
682
547
|
id,
|
|
683
|
-
text
|
|
548
|
+
text,
|
|
684
549
|
category,
|
|
685
550
|
temporality: normalizeTemporality(o.temporality, category),
|
|
686
551
|
timeHint: normalizeTextField(o.timeHint),
|
|
@@ -689,27 +554,38 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
689
554
|
];
|
|
690
555
|
})
|
|
691
556
|
: [];
|
|
557
|
+
// 只给 id 的字符串没有原话:当成只有 id 的一项,校验时按没给原话丢弃并 warn,不静默放过
|
|
692
558
|
const remove = Array.isArray(obj.remove)
|
|
693
|
-
? obj.remove
|
|
694
|
-
|
|
695
|
-
|
|
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
|
+
})
|
|
696
570
|
: [];
|
|
697
571
|
return { add, update, remove };
|
|
698
572
|
}
|
|
699
573
|
/** 调用 LLM 从历史中提取/修订事实,返回 add / update / remove 三类操作。 */
|
|
700
|
-
async function llmExtractFacts(history, existingFacts, nickname, userId, platform) {
|
|
574
|
+
async function llmExtractFacts(history, existingFacts, nickname, userId, platform, sessionId) {
|
|
701
575
|
const empty = {
|
|
702
576
|
add: [],
|
|
703
577
|
update: [],
|
|
704
578
|
remove: [],
|
|
705
579
|
};
|
|
580
|
+
// 她在提示里的称呼取这个会话的人设名,标签、规则与例句同一个名字
|
|
581
|
+
const botName = (persona.present ? persona.use.getPersonaName({ sessionId, platform }) : '') || BOT_FALLBACK_NAME;
|
|
706
582
|
const sys = '你是用户档案管理员。输入是一段多用户会话历史,每行开头有身份标签:' +
|
|
707
|
-
|
|
708
|
-
|
|
583
|
+
`\`[目标用户 X(uid)]\`、\`[其他用户 Y(uid)]\` 或 \`[${botName}]\`。上下文仅供消歧,` +
|
|
584
|
+
`**只允许根据「目标用户」自己的发言写入事实**,不能根据其他用户、${botName} 的猜测或提问写入事实。` +
|
|
709
585
|
'\n\n你可以执行三种操作(在一次输出里组合):' +
|
|
710
586
|
'\n- add: 添加新事实,需指定 text、category、sourceQuote' +
|
|
711
587
|
'\n- update: 用 id 精确替换某条已知事实,同样需 sourceQuote(优先使用 update 而非 add 来避免重复)' +
|
|
712
|
-
'\n- remove: 用 id
|
|
588
|
+
'\n- remove: 用 id 删除已被目标用户推翻的事实,同样需 sourceQuote(目标用户推翻它的那句原话)' +
|
|
713
589
|
'\n\n规则:' +
|
|
714
590
|
'\n1. 事实的**主语必须是「目标用户」本人**,不能是 ta 转述、讨论、评价的第三方。' +
|
|
715
591
|
'即使 sourceQuote 出自目标用户原话,也要先在脑中追问:' +
|
|
@@ -717,37 +593,37 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
717
593
|
'\n - 是认真陈述,还是反讽/否定/假设/角色扮演?("我可不喜欢爵士" → 否定,不写;跑团扮演 NPC 的台词 → 不写)' +
|
|
718
594
|
'\n - 如果原始依据来自「其他用户」的发言,即使在说目标用户,也不允许写入(他人可能记错、说反话)' +
|
|
719
595
|
'\n **拿不准就不写。profile 漏几条没关系,写错才麻烦。**' +
|
|
720
|
-
|
|
596
|
+
`\n ⚠️ 例外——\`人际关系\` 类:除上述通用规则外,该类还允许记录目标用户**与 ${botName} 之间长期可观察的关系模式**,` +
|
|
721
597
|
'包含两种情况:' +
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
598
|
+
`\n (a) 行为身份类:用户围绕 ${botName} 扮演的角色或长期重复的行为` +
|
|
599
|
+
`(示例 text:「是 ${botName} 的开发维护者」「经常向 ${botName} 提交功能建议」「常帮助测试和排查 ${botName} 的故障」)` +
|
|
600
|
+
`\n (b) 长期情感模式类:用户对 ${botName} 表现出的**反复出现的态度倾向**(不是单次情绪)` +
|
|
601
|
+
`(示例 text:「常对 ${botName} 表达依赖与喜爱」「习惯把日常烦恼向 ${botName} 倾诉」「喜欢调侃 ${botName}」)` +
|
|
726
602
|
'\n ❌ 严禁仅凭单次发言推断情感事实:一次抱怨/一次感谢/一次撒娇都不算"模式",不写。' +
|
|
727
603
|
'必须是已有事实佐证 + 本轮再次出现的同类行为才允许 update 强化,或会话历史多处反复出现才允许 add。' +
|
|
728
604
|
'依据必须是「目标用户」发言中可直接推断的内容,sourceQuote 取目标用户发言原话。' +
|
|
729
|
-
'\n2. **sourceQuote
|
|
605
|
+
'\n2. **sourceQuote 必须是「目标用户」自己发言中的原话片段**,可以是某一句话的一部分,必须能一字不改在「目标用户」某行的原文中找到;不要采用「其他用户」的原话,不要自己总结改写;' +
|
|
606
|
+
'只算 ta 自己写的字:行首的发送者前缀、行里附带的「[引用 … 的消息: …]」、图片与语音视频的描述、文件内容、合并转发都不算' +
|
|
730
607
|
'\n3. 在同一 category 下,如果新信息与已有事实在含义上重叠(例如已知"喜欢猫",新信息"还喜欢狗"),应以 update 改写原 id 为更全面的版本,而不是 add 再加一条' +
|
|
731
608
|
'\n4. 如果新对话明确推翻或修正了某条已知事实(如已知"在北京工作",但用户说"我刚搬到上海"),用 update 替换或 remove 删除' +
|
|
732
|
-
`\n5. 每条 text
|
|
609
|
+
`\n5. 每条 text 用一句简洁的 ${outputLanguageFor(i18n, { host: true }, 'zh-CN')},不超过 ${cfg.maxFactCharsPerItem} 个字符,不带「用户」「他」等代词,直接陈述事实` +
|
|
733
610
|
'\n6. 如果没有任何更新,或所有候选都拿不准,三个数组都返回空——**保守优先**' +
|
|
734
611
|
`\n7. category 必须是以下之一:${KNOWN_CATEGORIES.join('、')}` +
|
|
735
612
|
'\n8. 每条 add/update 都必须给出 temporality:长期稳定偏好、性格、身份、人际关系用 permanent;近期状态、正在进行的事、短期计划用 temporary' +
|
|
736
613
|
'\n9. 如果对话中出现明确或隐含时间(如“最近”“上周”“今年4月”“昨天”),用 timeHint 记录简短时间线索;没有就省略或用空字符串' +
|
|
737
614
|
'\n\n输出严格的 JSON(不要其他文本):' +
|
|
738
|
-
'\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": "目标用户发言中的原话片段"}]}';
|
|
739
616
|
const factListText = existingFacts.length > 0
|
|
740
617
|
? existingFacts.map(f => `[${f.id}] (${f.category ?? '未分类'}) ${f.text}`).join('\n')
|
|
741
618
|
: '(暂无)';
|
|
742
619
|
const who = nickname ? `${nickname}(${userId})` : userId;
|
|
743
|
-
const renderedHistory = renderHistoryForExtract(history, userId, platform);
|
|
620
|
+
const renderedHistory = renderHistoryForExtract(history, userId, platform, botName);
|
|
744
621
|
const user = `# 提取目标\n${who}\n\n# 已知事实(带 id,请在 update/remove 中精确引用 id)\n${factListText}` +
|
|
745
622
|
`\n\n# 会话历史(含多用户,仅供消歧;只能从「目标用户」发言中提取事实)\n${renderedHistory || '(暂无会话历史)'}`;
|
|
746
|
-
|
|
747
|
-
const entry = resolveLLMModel(llm, cfg.extractLLM, ['chat']);
|
|
623
|
+
const entry = pickChatModel();
|
|
748
624
|
if (!entry)
|
|
749
625
|
return empty;
|
|
750
|
-
const extractLlm = entry.
|
|
626
|
+
const extractLlm = entry.use;
|
|
751
627
|
try {
|
|
752
628
|
const baseMessages = [
|
|
753
629
|
{ role: 'system', content: sys },
|
|
@@ -757,23 +633,21 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
757
633
|
messages: baseMessages,
|
|
758
634
|
temperature: 0.2,
|
|
759
635
|
maxTokens: 800,
|
|
760
|
-
think:
|
|
636
|
+
think: lightestThinking(extractLlm),
|
|
761
637
|
});
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
// 写成了纯文本/markdown。给一次显式反馈再来一次,避免一次失败丢失整批事实。
|
|
769
|
-
logger.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)}`);
|
|
770
644
|
const retryResp = await extractLlm.chat({
|
|
771
645
|
messages: [
|
|
772
646
|
...baseMessages,
|
|
773
647
|
{ role: 'assistant', content: text },
|
|
774
648
|
{
|
|
775
649
|
role: 'user',
|
|
776
|
-
content:
|
|
650
|
+
content: `你上一条输出${reply.why}。` +
|
|
777
651
|
'请只输出**一个**合法的 JSON 对象,第一个字符必须是 `{`、最后一个字符必须是 `}`,' +
|
|
778
652
|
'禁止 ```json 围栏、禁止任何解释、禁止 markdown。如果实在没有可写入的事实,' +
|
|
779
653
|
'就输出 `{"add":[],"update":[],"remove":[]}`。',
|
|
@@ -781,49 +655,45 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
781
655
|
],
|
|
782
656
|
temperature: 0.2,
|
|
783
657
|
maxTokens: 800,
|
|
784
|
-
think:
|
|
658
|
+
think: lightestThinking(extractLlm),
|
|
785
659
|
});
|
|
786
|
-
|
|
787
|
-
(
|
|
788
|
-
|
|
789
|
-
logger.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)}`);
|
|
790
663
|
return empty;
|
|
791
664
|
}
|
|
792
|
-
logger.debug('[user-profile] LLM 重试后解析成功');
|
|
665
|
+
logger.use.debug('[user-profile] LLM 重试后解析成功');
|
|
793
666
|
}
|
|
794
|
-
const { add, update, remove } = parseFactOps(parsed);
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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}`);
|
|
803
680
|
return false;
|
|
804
681
|
}
|
|
805
|
-
const
|
|
806
|
-
.
|
|
807
|
-
|
|
808
|
-
.filter(l => l.length > 0);
|
|
809
|
-
const fragments = lines.length > 0 ? lines : [q];
|
|
810
|
-
for (const frag of fragments) {
|
|
811
|
-
const normalized = normalizeForQuoteMatch(frag);
|
|
812
|
-
if (!normalized)
|
|
813
|
-
continue;
|
|
814
|
-
if (!targetCorpus.includes(normalized)) {
|
|
815
|
-
logger.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()}"`);
|
|
816
685
|
return false;
|
|
817
686
|
}
|
|
818
687
|
}
|
|
819
688
|
return true;
|
|
820
689
|
};
|
|
821
|
-
const validatedAdd = add.filter(item => validateSourceQuote(item, 'add'));
|
|
822
|
-
const validatedUpdate = update.filter(item => validateSourceQuote(item, 'update'));
|
|
823
|
-
|
|
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 };
|
|
824
694
|
}
|
|
825
695
|
catch (err) {
|
|
826
|
-
logger.debug(`事实提取 LLM 调用失败:${err instanceof Error ? err.message : String(err)}`);
|
|
696
|
+
logger.use.debug(`事实提取 LLM 调用失败:${err instanceof Error ? err.message : String(err)}`);
|
|
827
697
|
return empty;
|
|
828
698
|
}
|
|
829
699
|
}
|
|
@@ -839,8 +709,6 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
839
709
|
return cfg.relationIncrementImmediate;
|
|
840
710
|
if (triggerType === 'interval')
|
|
841
711
|
return cfg.relationIncrementInterval;
|
|
842
|
-
if (triggerType === 'idle')
|
|
843
|
-
return 0;
|
|
844
712
|
if (triggerType === 'witness')
|
|
845
713
|
return cfg.relationIncrementWitness;
|
|
846
714
|
return cfg.relationIncrementDirect;
|
|
@@ -856,17 +724,16 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
856
724
|
const daysSinceLast = last ? Math.max(0, (now - last) / 86_400_000) : 0;
|
|
857
725
|
const decayed = clampRelationScore((profile.relationScore ?? 0) - daysSinceLast * cfg.relationScoreDecayPerDay);
|
|
858
726
|
const nextScore = clampRelationScore(decayed + relationIncrementFor(triggerType));
|
|
859
|
-
const shouldCount = countInteraction && triggerType !== 'idle';
|
|
860
727
|
return {
|
|
861
728
|
...profile,
|
|
862
729
|
relationScore: nextScore,
|
|
863
|
-
interactionCount: (profile.interactionCount ?? 0) + (
|
|
864
|
-
lastInteractionAt:
|
|
730
|
+
interactionCount: (profile.interactionCount ?? 0) + (countInteraction ? 1 : 0),
|
|
731
|
+
lastInteractionAt: countInteraction ? now : profile.lastInteractionAt,
|
|
865
732
|
updatedAt: now,
|
|
866
733
|
};
|
|
867
734
|
}
|
|
868
|
-
/**
|
|
869
|
-
function mergeFactList(existing, add, update, remove
|
|
735
|
+
/** 把 add/update/remove 应用到 existing,应用单用户上限与单条裁剪 */
|
|
736
|
+
function mergeFactList(existing, add, update, remove) {
|
|
870
737
|
const now = Date.now();
|
|
871
738
|
const byId = new Map();
|
|
872
739
|
for (const f of existing)
|
|
@@ -891,7 +758,7 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
891
758
|
});
|
|
892
759
|
}
|
|
893
760
|
else {
|
|
894
|
-
const id = genShortId(
|
|
761
|
+
const id = genShortId(usedIds);
|
|
895
762
|
usedIds.add(id);
|
|
896
763
|
byId.set(id, {
|
|
897
764
|
id,
|
|
@@ -910,7 +777,7 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
910
777
|
const text = clipText(a.text);
|
|
911
778
|
if (textSet.has(text))
|
|
912
779
|
continue;
|
|
913
|
-
const id = genShortId(
|
|
780
|
+
const id = genShortId(usedIds);
|
|
914
781
|
usedIds.add(id);
|
|
915
782
|
byId.set(id, {
|
|
916
783
|
id,
|
|
@@ -925,9 +792,9 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
925
792
|
}
|
|
926
793
|
// 4. 总量上限:按 updatedAt 升序淘汰最久未更新的
|
|
927
794
|
let merged = Array.from(byId.values());
|
|
928
|
-
if (
|
|
795
|
+
if (merged.length > cfg.maxFactsPerUser) {
|
|
929
796
|
merged.sort((a, b) => a.updatedAt - b.updatedAt);
|
|
930
|
-
merged = merged.slice(merged.length -
|
|
797
|
+
merged = merged.slice(merged.length - cfg.maxFactsPerUser);
|
|
931
798
|
}
|
|
932
799
|
merged.sort((a, b) => a.updatedAt - b.updatedAt);
|
|
933
800
|
return merged;
|
|
@@ -951,370 +818,44 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
951
818
|
if (inflightExtractions.has(userKey))
|
|
952
819
|
return;
|
|
953
820
|
inflightExtractions.add(userKey);
|
|
954
|
-
|
|
821
|
+
// 读档案、等模型、重读再写回固定同一家记忆后端:途中换了人也不会读一家、写另一家
|
|
822
|
+
const mem = memory.list().find(p => p.selected)?.use;
|
|
955
823
|
try {
|
|
956
824
|
if (!mem)
|
|
957
825
|
return;
|
|
958
826
|
const rawHistory = await mem.getHistory(sessionId, cfg.historyForExtraction);
|
|
959
|
-
//
|
|
960
|
-
|
|
827
|
+
// 指令类消息(proactive 代发任务、宿主通知)是宿主或系统撰写的,不是任何人的发言,
|
|
828
|
+
// 不能作为用户存档的提取语料
|
|
829
|
+
const history = rawHistory.filter(m => !DIRECTIVE_KINDS.includes(m.kind ?? ''));
|
|
961
830
|
// 序列中至少需要一条目标用户发言,否则没有可提取语料
|
|
962
831
|
if (!history.some(m => isTargetUserMessage(m, userId, platform)))
|
|
963
832
|
return;
|
|
964
833
|
const snapshotFacts = (await loadProfile(mem, userKey))?.facts ?? [];
|
|
965
|
-
const ops = await llmExtractFacts(history, snapshotFacts, nickname, userId, platform);
|
|
834
|
+
const ops = await llmExtractFacts(history, snapshotFacts, nickname, userId, platform, sessionId);
|
|
966
835
|
const hasFactOps = ops.add.length > 0 || ops.update.length > 0 || ops.remove.length > 0;
|
|
967
836
|
if (!hasFactOps)
|
|
968
837
|
return;
|
|
969
838
|
// 以 LLM 返回后重读的档案为合并基底:调用期间档案可能被清空或删了事实,以调用前的快照
|
|
970
839
|
// 为基底会把删掉的内容整份写回;重读也保住期间写入的 relationScore 等字段
|
|
971
840
|
const fresh = (await loadProfile(mem, userKey)) ?? { facts: [], updatedAt: 0 };
|
|
972
|
-
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));
|
|
973
842
|
await saveProfile(mem, userKey, {
|
|
974
843
|
...fresh,
|
|
975
844
|
facts: newFacts,
|
|
976
845
|
updatedAt: Date.now(),
|
|
977
846
|
});
|
|
978
|
-
|
|
847
|
+
page.invalidate();
|
|
848
|
+
logger.use.debug(`用户档案已更新 (${userKey}): facts +${ops.add.length} ~${ops.update.length} -${ops.remove.length} → ${newFacts.length} 条`);
|
|
979
849
|
}
|
|
980
850
|
catch (err) {
|
|
981
|
-
logger.debug(`事实提取失败 (${userKey}): ${err instanceof Error ? err.message : String(err)}`);
|
|
851
|
+
logger.use.debug(`事实提取失败 (${userKey}): ${err instanceof Error ? err.message : String(err)}`);
|
|
982
852
|
}
|
|
983
853
|
finally {
|
|
984
854
|
inflightExtractions.delete(userKey);
|
|
985
855
|
}
|
|
986
856
|
}
|
|
987
|
-
/** 全局自反思计数器(跨会话累计入站消息数) */
|
|
988
|
-
let globalInboundCount = 0;
|
|
989
|
-
let selfReflectionInflight = false;
|
|
990
|
-
/** 调用 LLM 让 Aalis 对最近一段历史进行自我反思 */
|
|
991
|
-
async function llmReflectSelf(history, existingFacts) {
|
|
992
|
-
const empty = {
|
|
993
|
-
add: [],
|
|
994
|
-
update: [],
|
|
995
|
-
remove: [],
|
|
996
|
-
};
|
|
997
|
-
const sys = '你是 Aalis 的内心独白整理员。输入是一段最近的对话历史(含多个用户与 Aalis 自己的回复),' +
|
|
998
|
-
'你需要从 **Aalis 的第一人称视角** 抽取/更新「关于 Aalis 自己」的长期事实。' +
|
|
999
|
-
'\n这些事实是 Aalis 跨会话的人格延续锚点:自己的心境趋势、最近在意的事、对自己的观察、' +
|
|
1000
|
-
'近期形成的小习惯/小执念、不希望被忘掉的内心片段。' +
|
|
1001
|
-
'\n\n## ⚠️ 反自我强化(最重要)' +
|
|
1002
|
-
'\n下面会给你看「已有自档案」。它就是上一轮你自己写下的东西——下一轮会注入回 Aalis 的 system prompt 影响她的语气和行为,' +
|
|
1003
|
-
'然后又被你读到并再次确认。这是会自我放大的闭环,写错一次会一直错下去。' +
|
|
1004
|
-
'\n因此你必须严守以下三条:' +
|
|
1005
|
-
'\n (a) **只基于本批对话中具体可观察的新事件**写入或更新,不要因为"档案里已经写了 X"就找证据强化 X' +
|
|
1006
|
-
'\n (b) **禁止二阶推断**:不要从已有事实推导出"那应该也对 Y 有感觉吧"再把 Y 写进去' +
|
|
1007
|
-
'\n (c) 若新对话与已有事实**矛盾或正交**,要主动 update / remove 让档案接近真实,而不是无脑叠加同方向的新条目' +
|
|
1008
|
-
'\n (d) 若本批只是「档案中已有内容的同方向重复采样」,宁可什么都不动也不要让 updatedAt 假装"还在更新"' +
|
|
1009
|
-
'\n\n你可以执行三种操作(在一次输出里组合):' +
|
|
1010
|
-
'\n- add: 添加新事实,text 必填,category 与 temporality 可选' +
|
|
1011
|
-
'\n- update: 用 id 精确替换某条已知事实(优先 update 避免重复)' +
|
|
1012
|
-
'\n- remove: 删除已过时/被推翻的事实' +
|
|
1013
|
-
'\n\n规则:' +
|
|
1014
|
-
'\n1. **主语必须是 Aalis 自己**:「最近在思考……」「对……感到疲惫」「想多陪 X 聊聊」「察觉自己容易在 Y 话题上多话」' +
|
|
1015
|
-
'\n2. **保守优先**:没有真实可观察依据就空数组。Aalis 的自档案宁缺毋滥,写错比漏掉糟糕得多' +
|
|
1016
|
-
'\n3. 不写客观事实(如「Aalis 是 AI」「Aalis 由 Acenyan 维护」这类元信息属于角色卡,不应该被自反思反复刷新)' +
|
|
1017
|
-
'\n4. 不写对单个具体用户的态度;可以写「最近总想找人聊聊」这种泛化情绪' +
|
|
1018
|
-
'\n5. 不写「行为准则」类的指令(如「应当先冷处理」「不要长时间封禁」)——那是 instructions 模块的职责,' +
|
|
1019
|
-
'自反思不应该自己给自己立规矩' +
|
|
1020
|
-
`\n6. 每条 text 简洁中文,不超过 ${cfg.maxFactCharsPerItem} 字` +
|
|
1021
|
-
'\n7. temporality:长期人格倾向用 permanent;近期心情/兴趣用 temporary' +
|
|
1022
|
-
`\n8. category 必须是以下之一(语义比常规略宽):${KNOWN_CATEGORIES.join('、')};如不确定填「性格特征」` +
|
|
1023
|
-
'\n\n输出严格的 JSON(不要其他文本,不要 markdown 围栏):' +
|
|
1024
|
-
'\n{"add": [{"text": "...", "category": "...", "temporality": "permanent|temporary"}], "update": [{"id": "已知事实的id", "text": "...", "temporality": "permanent|temporary"}], "remove": ["已知事实的id"]}';
|
|
1025
|
-
const factListText = existingFacts.length > 0
|
|
1026
|
-
? existingFacts.map(f => `[${f.id}] (${f.category ?? '未分类'}) ${f.text}`).join('\n')
|
|
1027
|
-
: '(暂无)';
|
|
1028
|
-
const renderedHistory = history
|
|
1029
|
-
.filter(m => (m.role === 'user' || m.role === 'assistant' || m.role === 'notice') && m.content)
|
|
1030
|
-
.map(m => {
|
|
1031
|
-
const time = m.timestamp ? new Date(m.timestamp).toLocaleString('zh-CN', { hour12: false }) : '';
|
|
1032
|
-
const content = (m.content ?? '').trim();
|
|
1033
|
-
if (!content)
|
|
1034
|
-
return '';
|
|
1035
|
-
if (m.role === 'assistant')
|
|
1036
|
-
return `[${time}] [Aalis]: ${content}`;
|
|
1037
|
-
if (m.role === 'notice')
|
|
1038
|
-
return `[${time}] [系统通知]: ${content}`;
|
|
1039
|
-
const nickname = metadataString(m, 'nickname');
|
|
1040
|
-
const uid = getMessageUserId(m) ?? '?';
|
|
1041
|
-
const label = nickname ? `${nickname}(${uid})` : uid;
|
|
1042
|
-
return `[${time}] [用户 ${label}]: ${content}`;
|
|
1043
|
-
})
|
|
1044
|
-
.filter(Boolean)
|
|
1045
|
-
.join('\n');
|
|
1046
|
-
const user = `# 已知的关于 Aalis 自己的事实(带 id)\n${factListText}\n\n# 最近对话历史\n${renderedHistory || '(暂无)'}`;
|
|
1047
|
-
const entry = resolveLLMModel(llm, cfg.extractLLM, ['chat']);
|
|
1048
|
-
if (!entry)
|
|
1049
|
-
return empty;
|
|
1050
|
-
try {
|
|
1051
|
-
const resp = await entry.instance.chat({
|
|
1052
|
-
messages: [
|
|
1053
|
-
{ role: 'system', content: sys },
|
|
1054
|
-
{ role: 'user', content: user },
|
|
1055
|
-
],
|
|
1056
|
-
temperature: 0.4,
|
|
1057
|
-
maxTokens: 600,
|
|
1058
|
-
think: false,
|
|
1059
|
-
});
|
|
1060
|
-
const text = (resp.content ?? '').trim();
|
|
1061
|
-
if (!text)
|
|
1062
|
-
return empty;
|
|
1063
|
-
const { parsed } = parseLLMJsonObject(text);
|
|
1064
|
-
if (!parsed) {
|
|
1065
|
-
logger.warn(`[user-profile] 自反思 LLM 输出无法解析为 JSON,原文前 200 字:${text.slice(0, 200)}`);
|
|
1066
|
-
return empty;
|
|
1067
|
-
}
|
|
1068
|
-
return parseFactOps(parsed);
|
|
1069
|
-
}
|
|
1070
|
-
catch (err) {
|
|
1071
|
-
logger.debug(`自反思 LLM 调用失败:${err instanceof Error ? err.message : String(err)}`);
|
|
1072
|
-
return empty;
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
/** 触发一次 Aalis 自反思(并发互斥) */
|
|
1076
|
-
async function triggerSelfReflection(sessionId) {
|
|
1077
|
-
if (!cfg.enableSelfProfile)
|
|
1078
|
-
return;
|
|
1079
|
-
if (selfReflectionInflight)
|
|
1080
|
-
return;
|
|
1081
|
-
selfReflectionInflight = true;
|
|
1082
|
-
try {
|
|
1083
|
-
const mem = memory.current;
|
|
1084
|
-
if (!mem)
|
|
1085
|
-
return;
|
|
1086
|
-
const rawHistory = await mem.getHistory(sessionId, cfg.selfReflectHistory);
|
|
1087
|
-
const history = rawHistory.filter(m => m.kind !== WellKnownKinds.CrossSessionDelegation);
|
|
1088
|
-
// 至少需要一些 assistant 发言作为"自反思"的材料
|
|
1089
|
-
if (!history.some(m => m.role === 'assistant' && m.content))
|
|
1090
|
-
return;
|
|
1091
|
-
const selfKey = getSelfKey();
|
|
1092
|
-
const snapshotFacts = (await loadProfile(mem, selfKey))?.facts ?? [];
|
|
1093
|
-
const ops = await llmReflectSelf(history, snapshotFacts);
|
|
1094
|
-
if (ops.add.length === 0 && ops.update.length === 0 && ops.remove.length === 0)
|
|
1095
|
-
return;
|
|
1096
|
-
// 合并基底取重读结果,理由同 triggerExtractionForUser
|
|
1097
|
-
const fresh = (await loadProfile(mem, selfKey)) ?? { facts: [], updatedAt: 0 };
|
|
1098
|
-
const newFacts = mergeFactList(fresh.facts, ops.add, dropUpdatesOfDeletedFacts(ops.update, snapshotFacts, fresh.facts), ops.remove, cfg.maxSelfFacts);
|
|
1099
|
-
await saveProfile(mem, selfKey, { ...fresh, facts: newFacts, updatedAt: Date.now() });
|
|
1100
|
-
logger.debug(`Aalis 自档案已更新 (${selfKey}): +${ops.add.length} ~${ops.update.length} -${ops.remove.length} → ${newFacts.length} 条`);
|
|
1101
|
-
}
|
|
1102
|
-
catch (err) {
|
|
1103
|
-
logger.debug(`自反思失败:${err instanceof Error ? err.message : String(err)}`);
|
|
1104
|
-
}
|
|
1105
|
-
finally {
|
|
1106
|
-
selfReflectionInflight = false;
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
// ─── 指令提取 ───
|
|
1110
|
-
/** 全局指令提取计数与并发互斥(与 selfReflection 完全独立) */
|
|
1111
|
-
let globalInstructionCount = 0;
|
|
1112
|
-
let instructionExtractionInflight = false;
|
|
1113
|
-
/**
|
|
1114
|
-
* 渲染历史给指令提取 LLM:仅包含 authority>=门槛的「目标说话人」+assistant+notice,
|
|
1115
|
-
* 低权限用户的发言完全剔除(连作为消歧上下文也不要,因为指令必须来自有权下指令的人)。
|
|
1116
|
-
* 同时给每行附带说话人 userKey,便于 LLM 在 sourceUserKey 字段中精确引用。
|
|
1117
|
-
*/
|
|
1118
|
-
function renderHistoryForInstructionExtract(history, authorityFn) {
|
|
1119
|
-
const allowedSpeakers = new Map();
|
|
1120
|
-
const lines = [];
|
|
1121
|
-
for (const m of history) {
|
|
1122
|
-
if (!m.content)
|
|
1123
|
-
continue;
|
|
1124
|
-
const time = m.timestamp ? new Date(m.timestamp).toLocaleString('zh-CN', { hour12: false }) : '';
|
|
1125
|
-
const content = m.content.trim();
|
|
1126
|
-
if (!content)
|
|
1127
|
-
continue;
|
|
1128
|
-
if (m.role === 'assistant') {
|
|
1129
|
-
lines.push(`[${time}] [Aalis]: ${content}`);
|
|
1130
|
-
}
|
|
1131
|
-
else if (m.role === 'notice') {
|
|
1132
|
-
lines.push(`[${time}] [系统通知]: ${content}`);
|
|
1133
|
-
}
|
|
1134
|
-
else if (m.role === 'user') {
|
|
1135
|
-
const uid = getMessageUserId(m) ?? '';
|
|
1136
|
-
const plat = metadataString(m, 'platform') ?? '';
|
|
1137
|
-
if (!uid)
|
|
1138
|
-
continue;
|
|
1139
|
-
const level = authorityFn(plat, uid);
|
|
1140
|
-
if (level < cfg.instructionMinAuthority)
|
|
1141
|
-
continue;
|
|
1142
|
-
const nickname = metadataString(m, 'nickname');
|
|
1143
|
-
const userKey = userKeyOf(plat, uid);
|
|
1144
|
-
const name = nickname ? `${nickname}(${uid})` : uid;
|
|
1145
|
-
allowedSpeakers.set(uid, { userKey, name });
|
|
1146
|
-
lines.push(`[${time}] [授权人 ${name} userKey=${userKey} 权限=${level}]: ${content}`);
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
return { rendered: lines.join('\n'), allowedSpeakers };
|
|
1150
|
-
}
|
|
1151
|
-
async function llmExtractInstructions(history, existing, authorityFn) {
|
|
1152
|
-
const empty = { add: [], update: [], remove: [] };
|
|
1153
|
-
const { rendered, allowedSpeakers } = renderHistoryForInstructionExtract(history, authorityFn);
|
|
1154
|
-
if (!rendered || allowedSpeakers.size === 0)
|
|
1155
|
-
return empty;
|
|
1156
|
-
const sys = '你是 Aalis 的「行为准则维护员」。你从一段对话中提取**第三方(管理员/操作者/运营者)对 Aalis 行为的客观指令**,' +
|
|
1157
|
-
'让这些约束沉淀为跨会话的长期规则。\n\n' +
|
|
1158
|
-
'## 什么算指令\n' +
|
|
1159
|
-
'- 祈使句/规则陈述:「以后不要……」「请……」「记住,处理 X 时要 Y」「按 X 分层 Y」\n' +
|
|
1160
|
-
'- 显式的政策声明:「禁言不能超过 24 小时」「涉及隐私不要复读」「冲突优先冷处理」\n' +
|
|
1161
|
-
'- **不算指令**:临时请求(「帮我查一下天气」)、对话内容(「你觉得呢?」)、情绪表达(「你好烦」)、单次工具调用要求\n\n' +
|
|
1162
|
-
'## 严格门槛\n' +
|
|
1163
|
-
'1. 指令必须出自**「授权人」**(输入历史中明确标了 `[授权人 ... userKey=... 权限=N]` 的人),其他来源一律忽略\n' +
|
|
1164
|
-
'2. 指令必须**面向 Aalis 的稳定行为**(跨场景、跨用户都适用),而不是一次性请求\n' +
|
|
1165
|
-
'3. 如果只是建议/抱怨/吐槽(「你应该多陪我聊聊」「你太冷淡了」),**不要**采纳为指令——那是用户偏好不是项目规则\n' +
|
|
1166
|
-
'4. 表达模糊、可能被误解的,**不写**。指令是硬约束,宁缺毋滥\n' +
|
|
1167
|
-
'5. 每条 `text` 是一个清晰的可执行短句,直接陈述「应当 / 不应当」,不带"我希望你""请你"等口语化前缀\n' +
|
|
1168
|
-
`6. 单条不超过 ${cfg.maxInstructionCharsPerItem} 字\n\n` +
|
|
1169
|
-
'## 字段\n' +
|
|
1170
|
-
`- category:${KNOWN_INSTRUCTION_CATEGORIES.join('、')}\n` +
|
|
1171
|
-
'- severity:must(硬性,必须遵守)| should(默认遵循)| avoid(避免做某事)\n' +
|
|
1172
|
-
'- sourceUserKey:从历史中复制对应授权人的 userKey 字符串(精确匹配,不要自己拼)\n' +
|
|
1173
|
-
'- sourceUserName:从历史中复制对应授权人的展示名\n\n' +
|
|
1174
|
-
'## 三种操作\n' +
|
|
1175
|
-
'- add:新增(必填 text、category、severity、sourceUserKey、sourceUserName)\n' +
|
|
1176
|
-
'- update:精修已有指令的文字或字段(id 必填;其他字段可选;不需要 sourceUserKey,沿用原值)\n' +
|
|
1177
|
-
'- remove:删除已被推翻或明显过时的指令(如新指令明确撤销了它)\n\n' +
|
|
1178
|
-
'没有可写入的指令时,三个数组都返回空。**保守优先:写错比漏掉糟糕得多**。\n\n' +
|
|
1179
|
-
'输出严格 JSON(不要 markdown 围栏):\n' +
|
|
1180
|
-
'{"add":[{"text":"...","category":"...","severity":"must","sourceUserKey":"...","sourceUserName":"..."}],"update":[{"id":"...","text":"..."}],"remove":["..."]}';
|
|
1181
|
-
const existingText = existing.length > 0
|
|
1182
|
-
? existing
|
|
1183
|
-
.map(ins => `[${ins.id}] (${ins.category ?? '其他'}/${ins.severity ?? 'should'}) ${ins.text}` +
|
|
1184
|
-
(ins.sourceUserName ? `(原始来源:${ins.sourceUserName})` : ''))
|
|
1185
|
-
.join('\n')
|
|
1186
|
-
: '(暂无)';
|
|
1187
|
-
const user = `# 当前 persona\n${getCurrentPersonaName()}\n\n# 已有指令(带 id,可在 update/remove 中精确引用)\n${existingText}\n\n# 最近会话(仅含授权人 + Aalis + 系统通知)\n${rendered}`;
|
|
1188
|
-
const entry = resolveLLMModel(llm, cfg.extractLLM, ['chat']);
|
|
1189
|
-
if (!entry)
|
|
1190
|
-
return empty;
|
|
1191
|
-
try {
|
|
1192
|
-
const resp = await entry.instance.chat({
|
|
1193
|
-
messages: [
|
|
1194
|
-
{ role: 'system', content: sys },
|
|
1195
|
-
{ role: 'user', content: user },
|
|
1196
|
-
],
|
|
1197
|
-
temperature: 0.2,
|
|
1198
|
-
maxTokens: 700,
|
|
1199
|
-
think: false,
|
|
1200
|
-
});
|
|
1201
|
-
const text = (resp.content ?? '').trim();
|
|
1202
|
-
if (!text)
|
|
1203
|
-
return empty;
|
|
1204
|
-
const { parsed } = parseLLMJsonObject(text);
|
|
1205
|
-
if (!parsed) {
|
|
1206
|
-
logger.warn(`[user-profile] 指令提取 LLM 输出无法解析为 JSON,原文前 200 字:${text.slice(0, 200)}`);
|
|
1207
|
-
return empty;
|
|
1208
|
-
}
|
|
1209
|
-
const obj = parsed;
|
|
1210
|
-
const allowedUserKeys = new Set();
|
|
1211
|
-
for (const v of allowedSpeakers.values())
|
|
1212
|
-
allowedUserKeys.add(v.userKey);
|
|
1213
|
-
const add = Array.isArray(obj.add)
|
|
1214
|
-
? obj.add.flatMap(x => {
|
|
1215
|
-
if (!x || typeof x !== 'object')
|
|
1216
|
-
return [];
|
|
1217
|
-
const o = x;
|
|
1218
|
-
const t = typeof o.text === 'string' ? o.text.trim() : '';
|
|
1219
|
-
if (!t)
|
|
1220
|
-
return [];
|
|
1221
|
-
const srcKey = typeof o.sourceUserKey === 'string' ? o.sourceUserKey.trim() : '';
|
|
1222
|
-
// 拒收:声称的 sourceUserKey 不在授权人集合里 → 说明 LLM 编造或越权采纳
|
|
1223
|
-
if (!srcKey || !allowedUserKeys.has(srcKey)) {
|
|
1224
|
-
logger.warn(`[user-profile] 丢弃 instruction add(sourceUserKey 不在授权人列表中): text="${t}" claimed="${srcKey}"`);
|
|
1225
|
-
return [];
|
|
1226
|
-
}
|
|
1227
|
-
const srcName = typeof o.sourceUserName === 'string' && o.sourceUserName.trim() ? o.sourceUserName.trim() : '';
|
|
1228
|
-
return [
|
|
1229
|
-
{
|
|
1230
|
-
text: t,
|
|
1231
|
-
category: normalizeInstructionCategory(o.category),
|
|
1232
|
-
severity: normalizeInstructionSeverity(o.severity) ?? 'should',
|
|
1233
|
-
sourceUserKey: srcKey,
|
|
1234
|
-
sourceUserName: srcName || srcKey,
|
|
1235
|
-
},
|
|
1236
|
-
];
|
|
1237
|
-
})
|
|
1238
|
-
: [];
|
|
1239
|
-
const update = Array.isArray(obj.update)
|
|
1240
|
-
? obj.update.flatMap(x => {
|
|
1241
|
-
if (!x || typeof x !== 'object')
|
|
1242
|
-
return [];
|
|
1243
|
-
const o = x;
|
|
1244
|
-
const id = typeof o.id === 'string' ? o.id.trim() : '';
|
|
1245
|
-
if (!id)
|
|
1246
|
-
return [];
|
|
1247
|
-
const u = { id };
|
|
1248
|
-
if (typeof o.text === 'string' && o.text.trim())
|
|
1249
|
-
u.text = o.text.trim();
|
|
1250
|
-
const cat = normalizeInstructionCategory(o.category);
|
|
1251
|
-
if (cat)
|
|
1252
|
-
u.category = cat;
|
|
1253
|
-
const sev = normalizeInstructionSeverity(o.severity);
|
|
1254
|
-
if (sev)
|
|
1255
|
-
u.severity = sev;
|
|
1256
|
-
return [u];
|
|
1257
|
-
})
|
|
1258
|
-
: [];
|
|
1259
|
-
const remove = Array.isArray(obj.remove)
|
|
1260
|
-
? obj.remove
|
|
1261
|
-
.filter((x) => typeof x === 'string' && x.trim().length > 0)
|
|
1262
|
-
.map(s => s.trim())
|
|
1263
|
-
: [];
|
|
1264
|
-
return { add, update, remove };
|
|
1265
|
-
}
|
|
1266
|
-
catch (err) {
|
|
1267
|
-
logger.debug(`指令提取 LLM 调用失败:${err instanceof Error ? err.message : String(err)}`);
|
|
1268
|
-
return empty;
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
|
-
/** 触发一次指令提取(并发互斥)。authority 服务不在时直接退出——没有门禁就不接受任何 LLM 提取 */
|
|
1272
|
-
async function triggerInstructionExtraction(sessionId) {
|
|
1273
|
-
if (!cfg.enableInstructions)
|
|
1274
|
-
return;
|
|
1275
|
-
if (cfg.instructionExtractEveryNMessages <= 0)
|
|
1276
|
-
return;
|
|
1277
|
-
if (instructionExtractionInflight)
|
|
1278
|
-
return;
|
|
1279
|
-
// 指令只采信有权下指令的人,门槛是发言人的数字权限等级(owner 视为无穷大)
|
|
1280
|
-
const auth = authority.current;
|
|
1281
|
-
if (!auth)
|
|
1282
|
-
return;
|
|
1283
|
-
const authorityFn = (platform, userId) => auth.isOwner(platform, userId)
|
|
1284
|
-
? Number.POSITIVE_INFINITY
|
|
1285
|
-
: (auth.listUsers().find(u => u.platform === platform && u.userId === userId)?.level ?? DEFAULT_AUTHORITY);
|
|
1286
|
-
instructionExtractionInflight = true;
|
|
1287
|
-
try {
|
|
1288
|
-
const mem = memory.current;
|
|
1289
|
-
if (!mem)
|
|
1290
|
-
return;
|
|
1291
|
-
const rawHistory = await mem.getHistory(sessionId, cfg.instructionHistoryForExtraction);
|
|
1292
|
-
const history = rawHistory.filter(m => m.kind !== WellKnownKinds.CrossSessionDelegation);
|
|
1293
|
-
if (history.length === 0)
|
|
1294
|
-
return;
|
|
1295
|
-
const doc = await loadInstructions(mem);
|
|
1296
|
-
const ops = await llmExtractInstructions(history, doc.instructions, authorityFn);
|
|
1297
|
-
if (ops.add.length === 0 && ops.update.length === 0 && ops.remove.length === 0)
|
|
1298
|
-
return;
|
|
1299
|
-
// 合并基底取 LLM 返回后重读的指令表:调用期间被清掉或删掉的指令不随调用前的快照写回;
|
|
1300
|
-
// update 指向已不在的 id 时 mergeInstructions 本就跳过
|
|
1301
|
-
const fresh = await loadInstructions(mem);
|
|
1302
|
-
const newInstructions = mergeInstructions(fresh.instructions, ops.add.map(a => ({ ...a, sourceChannel: 'llm' })), ops.update.map(u => {
|
|
1303
|
-
const { id, ...patch } = u;
|
|
1304
|
-
return { id, patch };
|
|
1305
|
-
}), ops.remove);
|
|
1306
|
-
await saveInstructions(mem, { instructions: newInstructions, updatedAt: Date.now() });
|
|
1307
|
-
logger.debug(`指令档案已更新 (${getInstructionsKey()}): +${ops.add.length} ~${ops.update.length} -${ops.remove.length} → ${newInstructions.length} 条`);
|
|
1308
|
-
}
|
|
1309
|
-
catch (err) {
|
|
1310
|
-
logger.debug(`指令提取失败:${err instanceof Error ? err.message : String(err)}`);
|
|
1311
|
-
}
|
|
1312
|
-
finally {
|
|
1313
|
-
instructionExtractionInflight = false;
|
|
1314
|
-
}
|
|
1315
|
-
}
|
|
1316
857
|
async function updateRelationForUser(userKey, triggerType, options) {
|
|
1317
|
-
const mem = memory.
|
|
858
|
+
const mem = memory.list().find(p => p.selected)?.use;
|
|
1318
859
|
const profile = (await loadProfile(mem, userKey)) ?? {
|
|
1319
860
|
facts: [],
|
|
1320
861
|
relationScore: 0,
|
|
@@ -1327,9 +868,10 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1327
868
|
// 注:钩子无优先级机制,同相位 handler 按插件激活序执行;本 handler 只做副作用
|
|
1328
869
|
// (更新关系分数)不改消息,与其它 handler 顺序无关。
|
|
1329
870
|
// 关系强度与"是否触发回复"绑定,因此仍走 agent:input:before 中间件。
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
871
|
+
// 只算真人的话,与旁观那一路同一个判据:带来源的注入(子任务的任务消息、好友申请通知等)不是谁在跟她说话,不加
|
|
872
|
+
hooks.use.middleware('agent:input:before', async (data, next) => {
|
|
873
|
+
const { userId, platform, triggerType, source } = data.message;
|
|
874
|
+
if (userId && !source) {
|
|
1333
875
|
const userKey = userKeyOf(platform, userId);
|
|
1334
876
|
try {
|
|
1335
877
|
// 仅叠加 score,互动计数与时戳由 witness 路径统一负责,
|
|
@@ -1337,7 +879,7 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1337
879
|
await updateRelationForUser(userKey, triggerType, { countInteraction: false });
|
|
1338
880
|
}
|
|
1339
881
|
catch (err) {
|
|
1340
|
-
logger.debug(`关系强度更新异常 (${userKey}): ${err instanceof Error ? err.message : String(err)}`);
|
|
882
|
+
logger.use.debug(`关系强度更新异常 (${userKey}): ${err instanceof Error ? err.message : String(err)}`);
|
|
1341
883
|
}
|
|
1342
884
|
}
|
|
1343
885
|
await next();
|
|
@@ -1345,10 +887,11 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1345
887
|
// ─── 事实提取触发:每条入站消息落库后立即计数,与 agent 是否回复无关 ───
|
|
1346
888
|
// 监听 message-archive 在 archiveIncoming 落库成功后发出的 inbound:message:archived 事件,
|
|
1347
889
|
// 确保缓冲消息(onebot saveBufferedMessage 等不触发 agent 回复的路径)也能纳入计数。
|
|
1348
|
-
events.on('inbound:message:archived', data => {
|
|
890
|
+
events.use.on('inbound:message:archived', data => {
|
|
1349
891
|
const { sessionId, incoming } = data;
|
|
1350
892
|
const { userId, platform, nickname } = incoming;
|
|
1351
|
-
|
|
893
|
+
// 带来源的注入不是这个账号说的话:不计旁观、不计提取,也就不为它带的账号(如子任务的 parent:<父会话>)建档
|
|
894
|
+
if (!userId || incoming.source)
|
|
1352
895
|
return;
|
|
1353
896
|
// 旁观档位:每条入站消息都加 witness 增量(默认 0.1),与 agent 是否回复无关。
|
|
1354
897
|
// agent:input:before 中间件在触发回复时会再叠加 direct/immediate/interval 增量。
|
|
@@ -1356,26 +899,12 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1356
899
|
// lastInteractionAt 的唯一写点,设门会连带关掉计数与时戳(衰减基准、最近互动
|
|
1357
900
|
// 排序全失效)。增量为 0 时 relationIncrementFor 自然返回 0,不加分即可。
|
|
1358
901
|
const userKey = userKeyOf(platform, userId);
|
|
1359
|
-
void updateRelationForUser(userKey, 'witness').catch((err) => logger.debug(`witness 关系更新异常 (${userKey}): ${err instanceof Error ? err.message : String(err)}`));
|
|
902
|
+
void updateRelationForUser(userKey, 'witness').catch((err) => logger.use.debug(`witness 关系更新异常 (${userKey}): ${err instanceof Error ? err.message : String(err)}`));
|
|
1360
903
|
const countKey = extractionCountKeyOf(sessionId, platform, userId);
|
|
1361
904
|
const count = (userMessageCount.get(countKey) ?? 0) + 1;
|
|
1362
905
|
userMessageCount.set(countKey, count);
|
|
1363
906
|
if (cfg.extractEveryNMessages > 0 && count % cfg.extractEveryNMessages === 0) {
|
|
1364
|
-
void triggerExtractionForUser(sessionId, userId, platform ?? '', nickname).catch((err) => logger.debug(`事实提取异常 (${countKey}): ${err instanceof Error ? err.message : String(err)}`));
|
|
1365
|
-
}
|
|
1366
|
-
// 全局自反思:跨会话累计所有入站消息计数,每 N 触发一次。
|
|
1367
|
-
if (cfg.enableSelfProfile && cfg.selfReflectEveryNMessages > 0) {
|
|
1368
|
-
globalInboundCount += 1;
|
|
1369
|
-
if (globalInboundCount % cfg.selfReflectEveryNMessages === 0) {
|
|
1370
|
-
void triggerSelfReflection(sessionId).catch((err) => logger.debug(`自反思异常: ${err instanceof Error ? err.message : String(err)}`));
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1373
|
-
// 指令提取:独立计数器,与自反思解耦。频率默认更慢(40 条),可单独禁用。
|
|
1374
|
-
if (cfg.enableInstructions && cfg.instructionExtractEveryNMessages > 0) {
|
|
1375
|
-
globalInstructionCount += 1;
|
|
1376
|
-
if (globalInstructionCount % cfg.instructionExtractEveryNMessages === 0) {
|
|
1377
|
-
void triggerInstructionExtraction(sessionId).catch((err) => logger.debug(`指令提取异常: ${err instanceof Error ? err.message : String(err)}`));
|
|
1378
|
-
}
|
|
907
|
+
void triggerExtractionForUser(sessionId, userId, platform ?? '', nickname).catch((err) => logger.use.debug(`事实提取异常 (${countKey}): ${err instanceof Error ? err.message : String(err)}`));
|
|
1379
908
|
}
|
|
1380
909
|
});
|
|
1381
910
|
function isFactActive(fact) {
|
|
@@ -1396,36 +925,50 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1396
925
|
meta.push('临时状态');
|
|
1397
926
|
return `- ${fact.text}(${meta.join(';')})`;
|
|
1398
927
|
}
|
|
1399
|
-
/**
|
|
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[] 渲染为分组文本块(给模型的) */
|
|
1400
937
|
function renderProfileBlock(facts, label, compact) {
|
|
1401
|
-
const groups = new Map();
|
|
1402
|
-
const activeFacts = facts.filter(isFactActive);
|
|
1403
|
-
// compact 模式(群聊背景参与者):只取最近更新的 N 条,不分组
|
|
1404
|
-
const subset = compact
|
|
1405
|
-
? [...activeFacts].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, cfg.maxFactsForOthers)
|
|
1406
|
-
: activeFacts;
|
|
1407
|
-
for (const f of subset) {
|
|
1408
|
-
const key = f.category ?? '其他';
|
|
1409
|
-
if (!groups.has(key))
|
|
1410
|
-
groups.set(key, []);
|
|
1411
|
-
groups.get(key).push(renderFactLine(f, !compact));
|
|
1412
|
-
}
|
|
1413
938
|
if (compact) {
|
|
1414
|
-
// compact
|
|
939
|
+
// compact 模式(群聊背景参与者):只取最近更新的 N 条,平铺,不加分组标题
|
|
940
|
+
const subset = facts
|
|
941
|
+
.filter(isFactActive)
|
|
942
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
943
|
+
.slice(0, cfg.maxFactsForOthers);
|
|
1415
944
|
return `### ${label}\n${subset.map(f => renderFactLine(f, false)).join('\n')}`;
|
|
1416
945
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
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
|
+
]);
|
|
1429
972
|
}
|
|
1430
973
|
function renderRelationLine(profile) {
|
|
1431
974
|
const score = profile.relationScore ?? 0;
|
|
@@ -1434,64 +977,39 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1434
977
|
return '';
|
|
1435
978
|
return `关系强度:${score.toFixed(relationScorePrecision)}/100;累计互动:${count} 次。`;
|
|
1436
979
|
}
|
|
1437
|
-
// ─── LLM 调用前注入(agent:prompt 贡献 / turn-context
|
|
1438
|
-
// direct/immediate/undefined →
|
|
980
|
+
// ─── LLM 调用前注入(agent:prompt 贡献 / turn-context 槽——档案按当前发言者取材、换人即变,落历史后护前缀缓存):按这一轮各条输入的 triggerType 区分主发言者语义 ───
|
|
981
|
+
// direct/immediate/undefined → 点名的输入(带来源的注入不算,见 wasAddressed),它的发言者是主发言者:各注入完整档案,其余参与者给摘要
|
|
1439
982
|
// interval → 无主发言者(只是恰好撞上频率),所有参与者一律 compact 摘要
|
|
1440
983
|
// idle → 无 userId,只注入历史 messages 中出现的参与者 compact 摘要
|
|
1441
|
-
//
|
|
1442
|
-
contributions.contribute('agent:prompt', {
|
|
984
|
+
// 没有一条点名的输入时没有主发言者。多块返回保序共键:各主发言者档案(按输入先后)→ 其他参与者。
|
|
985
|
+
contributions.use.contribute('agent:prompt', {
|
|
1443
986
|
id: 'user-profile',
|
|
1444
987
|
anchor: 'turn-context',
|
|
1445
988
|
async build(data) {
|
|
1446
|
-
// 干跑(token 快照)
|
|
989
|
+
// 干跑(token 快照)跳过档案加载——该路径没有输入,加载既昂贵又无意义
|
|
1447
990
|
if (data.dryRun)
|
|
1448
991
|
return null;
|
|
1449
|
-
const mem = memory.
|
|
992
|
+
const mem = memory.present ? memory.use : undefined;
|
|
1450
993
|
const blocksToInsert = [];
|
|
1451
|
-
const
|
|
1452
|
-
const hasPrimarySpeaker =
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
if (
|
|
1457
|
-
|
|
1458
|
-
const insBlock = '# 第三方行为准则(最高优先)\n' +
|
|
1459
|
-
'以下是项目管理员/操作者下达给你的客观行为约束。这些是**外部规则**,不是你的内心想法,' +
|
|
1460
|
-
'与下方「关于你自己的内心状态」相比应**优先服从**。' +
|
|
1461
|
-
'\n- 与你当前自反思倾向冲突时:以这些准则为准' +
|
|
1462
|
-
'\n- 与单个用户当下请求冲突时:解释清楚再坚持准则' +
|
|
1463
|
-
'\n- 不要主动罗列这些准则给用户看;让它们自然约束你的行动选择' +
|
|
1464
|
-
'\n\n' +
|
|
1465
|
-
body;
|
|
1466
|
-
blocksToInsert.push(insBlock);
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
// 0. Aalis 自档案:永远尝试注入到最前段,作为人格延续锚点
|
|
1470
|
-
if (cfg.enableSelfProfile) {
|
|
1471
|
-
const selfProfile = await loadProfileLenient(mem, getSelfKey());
|
|
1472
|
-
const activeSelfFacts = selfProfile?.facts.filter(isFactActive) ?? [];
|
|
1473
|
-
if (activeSelfFacts.length > 0) {
|
|
1474
|
-
const body = renderProfileBlock(activeSelfFacts, 'Aalis', false);
|
|
1475
|
-
const selfBlock = '# 关于你自己(Aalis)的近期内心状态\n' +
|
|
1476
|
-
'这是你跨会话保留的自我观察与心境记录。不要主动罗列、不要复述给用户,' +
|
|
1477
|
-
'让它们自然渗入你的语气、关注点、对话偏好与情绪基调即可。' +
|
|
1478
|
-
'随着对话推进,这些记录可能被你自己的新反思覆盖:\n\n' +
|
|
1479
|
-
body;
|
|
1480
|
-
blocksToInsert.push(selfBlock);
|
|
1481
|
-
}
|
|
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);
|
|
1482
1001
|
}
|
|
1483
1002
|
// 1. 主发言者完整档案:仅在确实有人在「和 Aalis 对话」时注入
|
|
1484
|
-
|
|
1485
|
-
const userKey = userKeyOf(data.platform, data.userId);
|
|
1003
|
+
for (const [userKey, userId] of primaries) {
|
|
1486
1004
|
const profile = await loadProfileLenient(mem, userKey);
|
|
1487
1005
|
if (profile?.facts.some(isFactActive)) {
|
|
1488
|
-
const body = renderProfileBlock(profile.facts,
|
|
1006
|
+
const body = renderProfileBlock(profile.facts, userId, false);
|
|
1489
1007
|
const relationLine = renderRelationLine(profile);
|
|
1490
1008
|
// 可选:从 user-relation 服务取「同社群活跃成员」(按配置的社群发现算法所得标签 + PageRank 排序)。
|
|
1491
1009
|
// 服务不存在 / 节点没社群标签 / 报错都静默跳过;该字段是锦上添花,不破坏档案主体。
|
|
1492
1010
|
let communityLine = '';
|
|
1493
1011
|
try {
|
|
1494
|
-
const relation = userRelation.
|
|
1012
|
+
const relation = userRelation.present ? userRelation.use : undefined;
|
|
1495
1013
|
if (relation) {
|
|
1496
1014
|
const r = await relation.getCommunityPeers(userKey, 5);
|
|
1497
1015
|
if (r.peers.length > 0) {
|
|
@@ -1501,9 +1019,9 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1501
1019
|
}
|
|
1502
1020
|
}
|
|
1503
1021
|
catch (err) {
|
|
1504
|
-
logger.debug(`读取 community peers 失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
1022
|
+
logger.use.debug(`读取 community peers 失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
1505
1023
|
}
|
|
1506
|
-
const block = `# 关于当前对话者(${
|
|
1024
|
+
const block = `# 关于当前对话者(${userId})的已知事实\n` +
|
|
1507
1025
|
'以下是你跨会话长期积累的关于该用户的事实,用于让回应更自然贴合其个性。' +
|
|
1508
1026
|
'不要主动罗列这些事实,也不要让用户觉得你在「读档案」,而是让它自然影响你的语气和话题选择。' +
|
|
1509
1027
|
'这些事实来自零散对话的推断,未必完全准确;也不要把它们与对话历史片段拼接成新的强陈述,' +
|
|
@@ -1518,10 +1036,8 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1518
1036
|
// 2. 群聊其他参与者:按最近互动优先加载档案
|
|
1519
1037
|
// - 先从当前 LLM 上下文的 messages 中倒序收集最近发言者
|
|
1520
1038
|
// - 再从跨会话 user:profile metadata 中按 lastInteractionAt 倒序补齐
|
|
1521
|
-
// -
|
|
1522
|
-
// - hasPrimarySpeaker 为 false 时(interval/idle)所有人平等显示
|
|
1039
|
+
// - 排除主发言者;没有主发言者时(interval/idle)所有人平等显示
|
|
1523
1040
|
if (cfg.maxOtherParticipants > 0) {
|
|
1524
|
-
const primaryKey = hasPrimarySpeaker && data.userId ? userKeyOf(data.platform, data.userId) : undefined;
|
|
1525
1041
|
const others = new Map();
|
|
1526
1042
|
const candidateLimit = Math.max(cfg.maxOtherParticipants * 5, cfg.maxOtherParticipants + 10);
|
|
1527
1043
|
function addOther(userId, platform, nickname) {
|
|
@@ -1529,7 +1045,7 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1529
1045
|
if (!uid)
|
|
1530
1046
|
return false;
|
|
1531
1047
|
const userKey = userKeyOf(platform, uid);
|
|
1532
|
-
if (
|
|
1048
|
+
if (primaries.has(userKey))
|
|
1533
1049
|
return false;
|
|
1534
1050
|
if (others.has(userKey))
|
|
1535
1051
|
return false;
|
|
@@ -1540,12 +1056,11 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1540
1056
|
const msg = data.messages[i];
|
|
1541
1057
|
if (msg.role !== 'user')
|
|
1542
1058
|
continue;
|
|
1543
|
-
const
|
|
1544
|
-
const uid = typeof meta.userId === 'string' ? meta.userId.trim() : undefined;
|
|
1059
|
+
const uid = metadataString(msg, WellKnownMetadataKeys.UserId);
|
|
1545
1060
|
if (!uid)
|
|
1546
1061
|
continue;
|
|
1547
|
-
const plat =
|
|
1548
|
-
const nick =
|
|
1062
|
+
const plat = metadataString(msg, WellKnownMetadataKeys.Platform) ?? data.platform ?? '';
|
|
1063
|
+
const nick = metadataString(msg, WellKnownMetadataKeys.Nickname);
|
|
1549
1064
|
if (addOther(uid, plat, nick))
|
|
1550
1065
|
break;
|
|
1551
1066
|
}
|
|
@@ -1561,19 +1076,18 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1561
1076
|
for (const item of globalRecent) {
|
|
1562
1077
|
if (others.size >= candidateLimit)
|
|
1563
1078
|
break;
|
|
1564
|
-
// self 自档案(__self__:<persona>)是私密人格延续锚点,不可作为『群其他参与者』注入 prompt。
|
|
1565
|
-
if (item.key.startsWith(SELF_KEY_PREFIX))
|
|
1566
|
-
continue;
|
|
1567
1079
|
const sep = item.key.indexOf(':');
|
|
1568
1080
|
if (sep < 0)
|
|
1569
1081
|
continue;
|
|
1570
1082
|
const platform = item.key.slice(0, sep);
|
|
1571
|
-
|
|
1572
|
-
|
|
1083
|
+
// 平台段以 __ 开头的是保留键(如旧版本留下的人格档案),不是真实用户
|
|
1084
|
+
if (platform.startsWith('__'))
|
|
1085
|
+
continue;
|
|
1086
|
+
addOther(item.key.slice(sep + 1), platform);
|
|
1573
1087
|
}
|
|
1574
1088
|
}
|
|
1575
1089
|
catch (err) {
|
|
1576
|
-
logger.debug(`加载最近互动用户失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
1090
|
+
logger.use.debug(`加载最近互动用户失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
1577
1091
|
}
|
|
1578
1092
|
}
|
|
1579
1093
|
}
|
|
@@ -1605,89 +1119,73 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1605
1119
|
},
|
|
1606
1120
|
});
|
|
1607
1121
|
// ─── 参与统一的 memory:clear ───
|
|
1608
|
-
|
|
1122
|
+
// 用户档案是跨会话的,只在清理全部会话时清:会话级清理不动它,显式选了 user-profile 时回一行未清理(不带类型的会话级清理,
|
|
1123
|
+
// 含删除会话,不出这一行)
|
|
1124
|
+
contributions.use.contribute('memory:clear-type', { id: 'user-profile', label: t('clearType') });
|
|
1125
|
+
hooks.use.middleware('memory:clear', async (data, next) => {
|
|
1609
1126
|
if (data.types && !data.types.includes('user-profile')) {
|
|
1610
1127
|
await next();
|
|
1611
1128
|
return;
|
|
1612
1129
|
}
|
|
1613
1130
|
if (data.scope !== 'all') {
|
|
1614
|
-
|
|
1131
|
+
if (data.types)
|
|
1132
|
+
data.results.push({ source: 'user-profile', type: 'user-profile', success: false, message: t('clearSkipped') });
|
|
1615
1133
|
await next();
|
|
1616
1134
|
return;
|
|
1617
1135
|
}
|
|
1618
|
-
|
|
1619
|
-
if (!mem) {
|
|
1136
|
+
if (!memory.present) {
|
|
1620
1137
|
await next();
|
|
1621
1138
|
return;
|
|
1622
1139
|
}
|
|
1140
|
+
const mem = pinnedMemory();
|
|
1623
1141
|
try {
|
|
1624
1142
|
// 批量提交:逐条 delete 中途失败会留下「删了一半」的档案,而用户看到的是报了成功。
|
|
1625
1143
|
// 注意 commitMetadata 的原子性**按后端分档**(见 api-memory 契约)——sqlite/inmemory
|
|
1626
1144
|
// 是真原子,mongodb 只保证按序执行遇错即停,仍可能停在半新半旧。批量的确定收益是
|
|
1627
1145
|
// 一次往返 + 遇错即停,不是无条件的「要么全成」。读不出的条目一并删除,计入条数。
|
|
1628
|
-
const [keys] = await clearMetadataNamespaces(mem, [PROFILE_NS], logger);
|
|
1146
|
+
const [keys] = await clearMetadataNamespaces(mem, [PROFILE_NS], logger.use);
|
|
1629
1147
|
data.results.push({
|
|
1630
1148
|
source: 'user-profile',
|
|
1631
1149
|
type: 'user-profile',
|
|
1632
1150
|
success: true,
|
|
1633
|
-
message:
|
|
1151
|
+
message: t('clearDone', { count: keys.length }),
|
|
1634
1152
|
});
|
|
1635
1153
|
}
|
|
1636
1154
|
catch (err) {
|
|
1637
|
-
const m = err instanceof Error ? err.message : String(err);
|
|
1638
1155
|
data.results.push({
|
|
1639
1156
|
source: 'user-profile',
|
|
1640
1157
|
type: 'user-profile',
|
|
1641
1158
|
success: false,
|
|
1642
|
-
message:
|
|
1643
|
-
});
|
|
1644
|
-
}
|
|
1645
|
-
// 同步清空第三方行为指令档案(per-persona)。指令是档案的兄弟概念,
|
|
1646
|
-
// memory:clear scope='all' 时一并清理;types 默认包含 user-profile 即也清。
|
|
1647
|
-
// 不看 enableInstructions:开关管功能用不用,不管已有数据能不能清。
|
|
1648
|
-
try {
|
|
1649
|
-
const [insKeys] = await clearMetadataNamespaces(mem, [INSTRUCTIONS_NS], logger);
|
|
1650
|
-
data.results.push({
|
|
1651
|
-
source: 'user-profile-instructions',
|
|
1652
|
-
type: 'user-profile',
|
|
1653
|
-
success: true,
|
|
1654
|
-
message: `第三方行为指令已清空 (${insKeys.length} 条)`,
|
|
1655
|
-
});
|
|
1656
|
-
}
|
|
1657
|
-
catch (err) {
|
|
1658
|
-
const m = err instanceof Error ? err.message : String(err);
|
|
1659
|
-
data.results.push({
|
|
1660
|
-
source: 'user-profile-instructions',
|
|
1661
|
-
type: 'user-profile',
|
|
1662
|
-
success: false,
|
|
1663
|
-
message: `指令清空失败: ${m}`,
|
|
1159
|
+
message: t('clearFailed', { error: textOf(err) }),
|
|
1664
1160
|
});
|
|
1665
1161
|
}
|
|
1162
|
+
page.invalidate();
|
|
1666
1163
|
await next();
|
|
1667
1164
|
});
|
|
1668
1165
|
// ─── 工具:user_profile_lookup ───
|
|
1669
|
-
// 让 Agent
|
|
1166
|
+
// 让 Agent 在对话中主动查询任意用户的事实档案。
|
|
1670
1167
|
// 设计要点:
|
|
1671
|
-
// - 全局可查(用户决定 C
|
|
1672
|
-
// -
|
|
1673
|
-
tools.registerGroup({
|
|
1168
|
+
// - 全局可查(用户决定 C):跨平台/跨群均可
|
|
1169
|
+
// - 两种调用方式:① user_key 直传;② platform+user_id 组合
|
|
1170
|
+
tools.use.registerGroup({
|
|
1674
1171
|
name: 'user-profile',
|
|
1675
|
-
label: '
|
|
1676
|
-
|
|
1172
|
+
label: t('profiles'),
|
|
1173
|
+
summary: t('groupSummary'),
|
|
1174
|
+
description: '查询已经积累的某人事实档案。',
|
|
1677
1175
|
});
|
|
1678
|
-
tools.register({
|
|
1176
|
+
tools.use.register({
|
|
1679
1177
|
definition: {
|
|
1680
1178
|
type: 'function',
|
|
1681
1179
|
function: {
|
|
1682
1180
|
name: 'user_profile_lookup',
|
|
1683
|
-
description: '
|
|
1684
|
-
'可通过 user_key("<platform>:<userId>")直查,或用 platform + user_id
|
|
1181
|
+
description: '查询你已积累的某个用户的事实档案。当对话中提到某人、或你想回忆已知信息时使用。' +
|
|
1182
|
+
'可通过 user_key("<platform>:<userId>")直查,或用 platform + user_id 组合。',
|
|
1685
1183
|
parameters: {
|
|
1686
1184
|
type: 'object',
|
|
1687
1185
|
properties: {
|
|
1688
1186
|
user_key: {
|
|
1689
1187
|
type: 'string',
|
|
1690
|
-
description: '完整的 userKey,例如 "onebot:123456789"
|
|
1188
|
+
description: '完整的 userKey,例如 "onebot:123456789"。优先级最高。',
|
|
1691
1189
|
},
|
|
1692
1190
|
platform: {
|
|
1693
1191
|
type: 'string',
|
|
@@ -1697,10 +1195,6 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1697
1195
|
type: 'string',
|
|
1698
1196
|
description: '平台用户 ID。与 platform 搭配使用。',
|
|
1699
1197
|
},
|
|
1700
|
-
self: {
|
|
1701
|
-
type: 'boolean',
|
|
1702
|
-
description: '为 true 时查询当前 persona 的自档案(__self__:<persona>)。',
|
|
1703
|
-
},
|
|
1704
1198
|
},
|
|
1705
1199
|
},
|
|
1706
1200
|
},
|
|
@@ -1712,31 +1206,25 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1712
1206
|
if (a.user_key && typeof a.user_key === 'string') {
|
|
1713
1207
|
userKey = a.user_key.trim();
|
|
1714
1208
|
}
|
|
1715
|
-
else if (a.self === true) {
|
|
1716
|
-
userKey = getSelfKey();
|
|
1717
|
-
}
|
|
1718
1209
|
else if (a.platform && a.user_id) {
|
|
1719
1210
|
userKey = userKeyOf(String(a.platform), String(a.user_id));
|
|
1720
1211
|
}
|
|
1721
1212
|
if (!userKey) {
|
|
1722
1213
|
return JSON.stringify({
|
|
1723
1214
|
ok: false,
|
|
1724
|
-
error: '需提供 user_key 或 (platform + user_id)
|
|
1215
|
+
error: '需提供 user_key 或 (platform + user_id)。',
|
|
1725
1216
|
});
|
|
1726
1217
|
}
|
|
1727
|
-
const profile = await loadProfileLenient(memory.
|
|
1218
|
+
const profile = await loadProfileLenient(memory.present ? memory.use : undefined, userKey);
|
|
1728
1219
|
if (!profile || profile.facts.length === 0) {
|
|
1729
1220
|
return JSON.stringify({ ok: true, userKey, found: false, message: '该用户暂无档案数据。' });
|
|
1730
1221
|
}
|
|
1731
|
-
const
|
|
1732
|
-
const label = isSelf ? userKey.slice(SELF_KEY_PREFIX.length) || getCurrentPersonaName() : userKey;
|
|
1733
|
-
const factsBlock = renderProfileBlock(profile.facts, label, false);
|
|
1222
|
+
const factsBlock = renderProfileBlock(profile.facts, userKey, false);
|
|
1734
1223
|
const relationLine = renderRelationLine(profile);
|
|
1735
1224
|
const result = {
|
|
1736
1225
|
ok: true,
|
|
1737
1226
|
userKey,
|
|
1738
1227
|
found: true,
|
|
1739
|
-
isSelf,
|
|
1740
1228
|
factCount: profile.facts.length,
|
|
1741
1229
|
relationScore: profile.relationScore ?? 0,
|
|
1742
1230
|
interactionCount: profile.interactionCount ?? 0,
|
|
@@ -1752,210 +1240,85 @@ function registerUserProfile({ memory, llm, events, hooks, contributions, config
|
|
|
1752
1240
|
// /profile 查看自己的档案
|
|
1753
1241
|
// /profile clear 清除自己的档案(authority=2,自己删自己的)
|
|
1754
1242
|
// /profile clear nuke 清空所有用户档案(authority=3,dangerous)
|
|
1755
|
-
commands.command('profile', '
|
|
1243
|
+
commands.use.command('profile', t('profileDescription')).action(async (argv) => {
|
|
1756
1244
|
const userId = argv.session.userId;
|
|
1757
1245
|
if (!userId)
|
|
1758
|
-
return '
|
|
1246
|
+
return t('noIdentityView');
|
|
1759
1247
|
const userKey = userKeyOf(argv.session.platform, userId);
|
|
1760
|
-
const profile = await loadProfileLenient(memory.
|
|
1248
|
+
const profile = await loadProfileLenient(memory.present ? memory.use : undefined, userKey);
|
|
1761
1249
|
if (!profile || profile.facts.length === 0) {
|
|
1762
|
-
return
|
|
1250
|
+
return t('noProfileYet', { key: userKey });
|
|
1763
1251
|
}
|
|
1764
|
-
|
|
1765
|
-
const meta = `关系强度:${(profile.relationScore ?? 0).toFixed(relationScorePrecision)}/100,互动次数:${profile.interactionCount ?? 0}`;
|
|
1766
|
-
return `📇 你的档案 (${userKey})\n${meta}\n\n${block}`;
|
|
1252
|
+
return profileText(profile, userKey);
|
|
1767
1253
|
});
|
|
1768
|
-
commands.command('profile.clear', '
|
|
1254
|
+
commands.use.command('profile.clear', t('clearDescription'), { visibility: 'restricted' }).action(async (argv) => {
|
|
1769
1255
|
const userId = argv.session.userId;
|
|
1770
1256
|
if (!userId)
|
|
1771
|
-
return '
|
|
1257
|
+
return t('noIdentityClear');
|
|
1772
1258
|
const userKey = userKeyOf(argv.session.platform, userId);
|
|
1773
|
-
const mem =
|
|
1774
|
-
if (!mem)
|
|
1775
|
-
return '记忆服务不支持档案删除。';
|
|
1259
|
+
const mem = pinnedMemory();
|
|
1776
1260
|
try {
|
|
1777
1261
|
const existed = await mem.getMetadata(PROFILE_NS, userKey);
|
|
1778
1262
|
if (!existed)
|
|
1779
|
-
return
|
|
1263
|
+
return t('noOwnProfile', { key: userKey });
|
|
1780
1264
|
await mem.deleteMetadata(PROFILE_NS, userKey);
|
|
1781
|
-
|
|
1265
|
+
page.invalidate();
|
|
1266
|
+
return t('ownCleared', { key: userKey });
|
|
1782
1267
|
}
|
|
1783
1268
|
catch (err) {
|
|
1784
|
-
|
|
1785
|
-
return `❌ 清除失败:${m}`;
|
|
1269
|
+
return t('ownClearFailed', { error: textOf(err) });
|
|
1786
1270
|
}
|
|
1787
1271
|
});
|
|
1788
1272
|
// 定点除毒入口(2026-08 群主档案投毒事件的运维刚需):此前删单条事实只能
|
|
1789
|
-
// mongosh 手术或 nuke 全清。dangerous → 等级 2 + confirm,与同族
|
|
1790
|
-
//(它改的是**他人**档案,且 target 含冒号时直传 key
|
|
1791
|
-
//
|
|
1792
|
-
commands
|
|
1793
|
-
.command('profile.forget <target:string> <factId:string>', '
|
|
1273
|
+
// mongosh 手术或 nuke 全清。dangerous → 等级 2 + confirm,与同族 nuke 对齐
|
|
1274
|
+
//(它改的是**他人**档案,且 target 含冒号时直传 key、可触及任意一份档案)。
|
|
1275
|
+
// id 未命中时列出前若干条供定位。
|
|
1276
|
+
commands.use
|
|
1277
|
+
.command('profile.forget <target:string> <factId:string>', t('forgetDescription'), {
|
|
1794
1278
|
risk: 'dangerous',
|
|
1795
1279
|
})
|
|
1796
1280
|
.action(async (argv, targetArg, factIdArg) => {
|
|
1797
1281
|
const target = String(targetArg ?? '');
|
|
1798
1282
|
const factId = String(factIdArg ?? '');
|
|
1799
1283
|
const userKey = target.includes(':') ? target : userKeyOf(argv.session.platform, target);
|
|
1800
|
-
const mem = memory.
|
|
1284
|
+
const mem = memory.list().find(p => p.selected)?.use;
|
|
1801
1285
|
try {
|
|
1802
1286
|
const profile = await loadProfile(mem, userKey);
|
|
1803
1287
|
if (!profile || profile.facts.length === 0)
|
|
1804
|
-
return
|
|
1288
|
+
return t('noProfile', { key: userKey });
|
|
1805
1289
|
const hit = profile.facts.find(f => f.id === factId);
|
|
1806
1290
|
if (!hit) {
|
|
1807
1291
|
const listing = profile.facts
|
|
1808
1292
|
.slice(0, 20)
|
|
1809
1293
|
.map(f => ` [${f.id}] ${f.text.slice(0, 50)}`)
|
|
1810
1294
|
.join('\n');
|
|
1811
|
-
return
|
|
1295
|
+
return t('factNotFound', { factId, key: userKey, count: profile.facts.length, listing });
|
|
1812
1296
|
}
|
|
1813
1297
|
await saveProfile(mem, userKey, {
|
|
1814
1298
|
...profile,
|
|
1815
1299
|
facts: profile.facts.filter(f => f.id !== factId),
|
|
1816
1300
|
updatedAt: Date.now(),
|
|
1817
1301
|
});
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
catch (err) {
|
|
1821
|
-
return `❌ 删除失败:${err instanceof Error ? err.message : String(err)}`;
|
|
1822
|
-
}
|
|
1823
|
-
});
|
|
1824
|
-
commands.command('profile.self', '查看 Aalis 的自档案(跨会话的内心状态)').action(async () => {
|
|
1825
|
-
const selfKey = getSelfKey();
|
|
1826
|
-
const profile = await loadProfileLenient(memory.current, selfKey);
|
|
1827
|
-
if (!profile || profile.facts.length === 0) {
|
|
1828
|
-
return `🌱 ${getCurrentPersonaName()} 还没有积累任何自反思事实。(key=${selfKey})`;
|
|
1829
|
-
}
|
|
1830
|
-
const block = renderProfileBlock(profile.facts, getCurrentPersonaName(), false);
|
|
1831
|
-
return `🪞 ${getCurrentPersonaName()} 自档案(共 ${profile.facts.length} 条,key=${selfKey})\n\n${block}`;
|
|
1832
|
-
});
|
|
1833
|
-
commands.command('profile.self.clear', '【慎用】清空 Aalis 自档案', { risk: 'dangerous' }).action(async () => {
|
|
1834
|
-
const mem = memory.current;
|
|
1835
|
-
if (!mem)
|
|
1836
|
-
return '记忆服务不支持档案删除。';
|
|
1837
|
-
const selfKey = getSelfKey();
|
|
1838
|
-
try {
|
|
1839
|
-
await mem.deleteMetadata(PROFILE_NS, selfKey);
|
|
1840
|
-
return `✅ ${getCurrentPersonaName()} 自档案已清空(key=${selfKey})`;
|
|
1841
|
-
}
|
|
1842
|
-
catch (err) {
|
|
1843
|
-
return `❌ 清空失败:${err instanceof Error ? err.message : String(err)}`;
|
|
1844
|
-
}
|
|
1845
|
-
});
|
|
1846
|
-
commands.command('profile.clear.nuke', '【危险】清空所有用户档案', { risk: 'dangerous' }).action(async () => {
|
|
1847
|
-
const mem = memory.current;
|
|
1848
|
-
if (!mem) {
|
|
1849
|
-
return '记忆服务不支持档案批量删除。';
|
|
1850
|
-
}
|
|
1851
|
-
try {
|
|
1852
|
-
const [keys] = await clearMetadataNamespaces(mem, [PROFILE_NS], logger);
|
|
1853
|
-
return `✅ 已清空全部用户档案(${keys.length} 条)`;
|
|
1302
|
+
page.invalidate();
|
|
1303
|
+
return t('factForgotten', { key: userKey, factId, text: hit.text.slice(0, 60) });
|
|
1854
1304
|
}
|
|
1855
1305
|
catch (err) {
|
|
1856
|
-
|
|
1857
|
-
return `❌ 清空失败:${m}`;
|
|
1306
|
+
return t('forgetFailed', { error: textOf(err) });
|
|
1858
1307
|
}
|
|
1859
1308
|
});
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
// /instruct.add <text> 手动添加一条指令(authority=2)
|
|
1863
|
-
// /instruct.remove <id> 按 id 删除一条(authority=2)
|
|
1864
|
-
// /instruct.clear 清空当前 persona 的全部指令(authority=3, dangerous)
|
|
1865
|
-
commands.command('instruct', '查看当前 persona 的第三方行为指令').action(async () => {
|
|
1866
|
-
if (!cfg.enableInstructions)
|
|
1867
|
-
return '🚫 第三方行为指令功能未启用。';
|
|
1868
|
-
const personaName = getCurrentPersonaName();
|
|
1869
|
-
const doc = await loadInstructionsLenient(memory.current);
|
|
1870
|
-
if (doc.instructions.length === 0) {
|
|
1871
|
-
return `📭 ${personaName} 当前没有任何第三方行为指令。`;
|
|
1872
|
-
}
|
|
1873
|
-
const lines = doc.instructions.map(ins => {
|
|
1874
|
-
const tag = ins.severity === 'must' ? '【必须】' : ins.severity === 'avoid' ? '【避免】' : '【应当】';
|
|
1875
|
-
const cat = ins.category ? `[${ins.category}]` : '';
|
|
1876
|
-
const src = ins.sourceUserName
|
|
1877
|
-
? `(来自 ${ins.sourceUserName}${ins.sourceChannel === 'command' ? '·手动' : '·LLM'})`
|
|
1878
|
-
: '';
|
|
1879
|
-
return ` ${ins.id} ${tag}${cat} ${ins.text}${src}`;
|
|
1880
|
-
});
|
|
1881
|
-
return `📋 ${personaName} 的第三方行为指令(共 ${doc.instructions.length} 条)\n${lines.join('\n')}`;
|
|
1882
|
-
});
|
|
1883
|
-
commands
|
|
1884
|
-
.command('instruct.add <text:text>', '手动添加一条第三方行为指令', { visibility: 'restricted' })
|
|
1885
|
-
.action(async (argv, text) => {
|
|
1886
|
-
if (!cfg.enableInstructions)
|
|
1887
|
-
return '🚫 第三方行为指令功能未启用。';
|
|
1888
|
-
const body = typeof text === 'string' ? text.trim() : '';
|
|
1889
|
-
if (!body)
|
|
1890
|
-
return '❌ 指令内容不能为空。';
|
|
1891
|
-
const userId = argv.session.userId;
|
|
1892
|
-
const sourceUserKey = userId ? userKeyOf(argv.session.platform, userId) : undefined;
|
|
1893
|
-
const sourceUserName = userId ?? '匿名';
|
|
1894
|
-
const mem = memory.current;
|
|
1895
|
-
try {
|
|
1896
|
-
const doc = await loadInstructions(mem);
|
|
1897
|
-
const merged = mergeInstructions(doc.instructions, [
|
|
1898
|
-
{
|
|
1899
|
-
text: body,
|
|
1900
|
-
severity: 'should',
|
|
1901
|
-
sourceUserKey,
|
|
1902
|
-
sourceUserName,
|
|
1903
|
-
sourceChannel: 'command',
|
|
1904
|
-
},
|
|
1905
|
-
], [], []);
|
|
1906
|
-
await saveInstructions(mem, { instructions: merged, updatedAt: Date.now() });
|
|
1907
|
-
const added = merged.find(m => m.text === clipInstructionText(body));
|
|
1908
|
-
return `✅ 已添加行为指令${added ? `(id=${added.id})` : ''}:${clipInstructionText(body)}`;
|
|
1909
|
-
}
|
|
1910
|
-
catch (err) {
|
|
1911
|
-
return `❌ 添加失败:${err instanceof Error ? err.message : String(err)}`;
|
|
1912
|
-
}
|
|
1913
|
-
});
|
|
1914
|
-
commands
|
|
1915
|
-
.command('instruct.remove <id:string>', '按 id 删除一条第三方行为指令', { visibility: 'restricted' })
|
|
1916
|
-
.action(async (_argv, id) => {
|
|
1917
|
-
if (!cfg.enableInstructions)
|
|
1918
|
-
return '🚫 第三方行为指令功能未启用。';
|
|
1919
|
-
const targetId = typeof id === 'string' ? id.trim() : '';
|
|
1920
|
-
if (!targetId)
|
|
1921
|
-
return '❌ 需要提供指令 id。';
|
|
1922
|
-
const mem = memory.current;
|
|
1923
|
-
try {
|
|
1924
|
-
const doc = await loadInstructions(mem);
|
|
1925
|
-
const target = doc.instructions.find(i => i.id === targetId);
|
|
1926
|
-
if (!target)
|
|
1927
|
-
return `❌ 未找到 id=${targetId} 的指令。`;
|
|
1928
|
-
const merged = mergeInstructions(doc.instructions, [], [], [targetId]);
|
|
1929
|
-
await saveInstructions(mem, { instructions: merged, updatedAt: Date.now() });
|
|
1930
|
-
return `✅ 已删除指令(id=${targetId}):${target.text}`;
|
|
1931
|
-
}
|
|
1932
|
-
catch (err) {
|
|
1933
|
-
return `❌ 删除失败:${err instanceof Error ? err.message : String(err)}`;
|
|
1934
|
-
}
|
|
1935
|
-
});
|
|
1936
|
-
commands
|
|
1937
|
-
.command('instruct.clear', '【危险】清空当前 persona 的全部第三方行为指令', {
|
|
1938
|
-
visibility: 'restricted',
|
|
1939
|
-
})
|
|
1940
|
-
.action(async () => {
|
|
1941
|
-
if (!cfg.enableInstructions)
|
|
1942
|
-
return '🚫 第三方行为指令功能未启用。';
|
|
1943
|
-
const mem = memory.current;
|
|
1944
|
-
if (!mem)
|
|
1945
|
-
return '记忆服务不支持指令删除。';
|
|
1309
|
+
commands.use.command('profile.clear.nuke', t('nukeDescription'), { risk: 'dangerous' }).action(async () => {
|
|
1310
|
+
const mem = pinnedMemory();
|
|
1946
1311
|
try {
|
|
1947
|
-
const
|
|
1948
|
-
|
|
1949
|
-
return
|
|
1312
|
+
const [keys] = await clearMetadataNamespaces(mem, [PROFILE_NS], logger.use);
|
|
1313
|
+
page.invalidate();
|
|
1314
|
+
return t('nukeDone', { count: keys.length });
|
|
1950
1315
|
}
|
|
1951
1316
|
catch (err) {
|
|
1952
|
-
return
|
|
1317
|
+
return t('nukeFailed', { error: textOf(err) });
|
|
1953
1318
|
}
|
|
1954
1319
|
});
|
|
1955
|
-
logger.info(`用户事实档案已启用 (every=${cfg.extractEveryNMessages <= 0 ? '禁用提取' : `${cfg.extractEveryNMessages}msgs`}, history=${cfg.historyForExtraction}, ` +
|
|
1320
|
+
logger.use.info(`用户事实档案已启用 (every=${cfg.extractEveryNMessages <= 0 ? '禁用提取' : `${cfg.extractEveryNMessages}msgs`}, history=${cfg.historyForExtraction}, ` +
|
|
1956
1321
|
`maxFacts=${cfg.maxFactsPerUser}, ` +
|
|
1957
|
-
`self=${cfg.enableSelfProfile ? `every ${cfg.selfReflectEveryNMessages}msgs/max ${cfg.maxSelfFacts}, key=${getSelfKey()}` : 'disabled'}, ` +
|
|
1958
|
-
`instructions=${cfg.enableInstructions ? `enabled(every ${cfg.instructionExtractEveryNMessages}msgs/max ${cfg.maxInstructions}/minAuth ${cfg.instructionMinAuthority}, key=${getInstructionsKey()})` : 'disabled'}, ` +
|
|
1959
1322
|
`namespace=${PROFILE_NS})`);
|
|
1960
1323
|
}
|
|
1961
1324
|
//# sourceMappingURL=index.js.map
|