@aalis/plugin-commands 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,86 +1,40 @@
1
+ import { agent } from '@aalis/api-agent';
1
2
  import { authority } from '@aalis/api-authority';
2
3
  import { commands as commandsService } from '@aalis/api-commands';
4
+ import { contributions } from '@aalis/api-contributions';
3
5
  import { gateway, INBOUND_PHASE } from '@aalis/api-gateway';
4
6
  import { hooks } from '@aalis/api-hooks';
7
+ import { i18n } from '@aalis/api-i18n';
5
8
  import { memory } from '@aalis/api-memory';
6
- import { isStorageNotFound, storage } from '@aalis/api-storage';
7
9
  import { tools } from '@aalis/api-tools';
8
- import { appService, config, definePlugin, events, lifecycle, logger, optional, provide, services, } from '@aalis/core';
10
+ import { appService, config, definePlugin, events, lifecycle, logger, optional, pluginsService, provide, } from '@aalis/core';
9
11
  import { defineConfig, parseConfig } from '@aalis/schema-config';
12
+ import { isTextRef, TextError, textLines, textOf, toSource } from '@aalis/schema-i18n';
13
+ import { AT_SELF_PATTERN } from '@aalis/schema-message';
10
14
  import { CommandRegistry } from './commands.js';
15
+ import { t } from './texts.js';
11
16
  const configSchema = defineConfig({
12
17
  commandPrefix: {
13
18
  type: 'string',
14
- label: '指令前缀',
19
+ label: t('prefixLabel'),
15
20
  default: '/',
16
21
  onInvalid: 'error',
17
- description: '指令触发前缀,设为空字符串可使用纯关键词触发',
22
+ description: t('prefixDescription'),
18
23
  },
19
24
  });
20
- /**
21
- * 删除目录及内部所有内容,返回顶层子项数(用于"清了 N 张/N 个会话"提示)。
22
- * 目录不存在返回 -1。
23
- */
24
- async function removeDirCounted(storage, dirUri) {
25
- try {
26
- const st = await storage.stat(dirUri);
27
- if (!st.isDirectory)
28
- return -1;
29
- const list = await storage.list(dirUri);
30
- await storage.delete(dirUri);
31
- return list.entries.length;
32
- }
33
- catch (err) {
34
- if (isStorageNotFound(err))
35
- return -1;
36
- throw err;
37
- }
38
- }
39
- /** 可清理类型。globalOnly:跨会话数据,只在 /clear all 时清理 */
40
- export const CLEAR_TYPES = [
41
- { id: 'context', label: '消息历史与会话上下文(含摘要与待办)' },
42
- { id: 'summary', label: '会话摘要' },
43
- { id: 'vector', label: '向量记忆' },
44
- { id: 'image', label: '图片缓存' },
45
- { id: 'video', label: '视频缓存' },
46
- { id: 'audio', label: '语音缓存' },
47
- { id: 'file', label: '文件缓存' },
48
- { id: 'persona', label: '会话角色状态' },
49
- { id: 'checkpoint', label: '检查点(对话回滚存档)' },
50
- { id: 'user-profile', label: '用户档案', globalOnly: true },
51
- { id: 'user-relation', label: '用户关系图谱', globalOnly: true },
52
- ];
53
- /**
54
- * 附件缓存种类 → `data:/` 目录 + 中文标签。
55
- * 目录名与 @aalis/plugin-adapter-onebot 的 attachment-cache `KIND_DIR` 对齐
56
- * (image→images / video→videos / audio→audios / file→files),落盘路径为
57
- * `data:/{dir}/{safeSessionId}/{hash}.{ext}`。改这里即同步 /clear 覆盖面。
58
- */
59
- export const ATTACHMENT_KINDS = [
60
- { type: 'image', dir: 'images', label: '图片' },
61
- { type: 'video', dir: 'videos', label: '视频' },
62
- { type: 'audio', dir: 'audios', label: '语音' },
63
- { type: 'file', dir: 'files', label: '文件' },
64
- ];
65
- const CLEAR_TYPE_ALIASES = {
66
- history: 'context',
67
- messages: 'context',
68
- profile: 'user-profile',
69
- profiles: 'user-profile',
70
- };
71
- function normalizeClearTypes(raw, prefix) {
25
+ /** 按登记表核对 -t 的取值:不给或含 all 时清全部类型(undefined),有不认识的抛给人看的错 */
26
+ function normalizeClearTypes(raw, known, prefix) {
72
27
  const values = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];
73
28
  const types = values
74
29
  .flatMap(v => String(v).split(','))
75
30
  .map(v => v.trim())
76
- .filter(Boolean)
77
- .map(v => CLEAR_TYPE_ALIASES[v] ?? v);
31
+ .filter(Boolean);
78
32
  if (types.length === 0 || types.includes('all'))
79
33
  return undefined;
80
- const known = new Set(CLEAR_TYPES.map(t => t.id));
81
- const unknown = types.find(t => !known.has(t));
34
+ const unknown = types.find(type => !known.some(k => k.id === type));
82
35
  if (unknown) {
83
- throw new Error(`未知清理类型: ${unknown}。可用类型: all, ${CLEAR_TYPES.map(t => t.id).join(', ')}。说明见 ${prefix}help clear`);
36
+ const ids = ['all', ...known.map(k => k.id)].join(', ');
37
+ throw new TextError(t('unknownClearType', { type: unknown, types: ids, prefix }));
84
38
  }
85
39
  return [...new Set(types)];
86
40
  }
@@ -93,11 +47,18 @@ const uses = {
93
47
  */
94
48
  commands: optional(commandsService),
95
49
  gateway,
96
- storage: optional(storage),
97
50
  memory: optional(memory),
51
+ /** /clear 清消息历史时经它先停下并按住这些会话;缺席时没有在跑的回合要停 */
52
+ agent: optional(agent),
98
53
  authority: optional(authority),
99
- /** /status 列已登记的工具数 */
54
+ /** /status 写已登记的工具数 */
100
55
  tools: optional(tools),
56
+ /** /status 写插件在跑与出错的个数;缺席时不写这一行 */
57
+ plugins: optional(pluginsService),
58
+ /** /clear 的类型按 memory:clear-type 的登记表取;缺席时只认 all */
59
+ contributions: optional(contributions),
60
+ /** 回复与帮助按这条指令所在会话的语言渲染;缺席时按原文 */
61
+ i18n: optional(i18n),
101
62
  app: appService,
102
63
  events,
103
64
  hooks,
@@ -106,101 +67,50 @@ const uses = {
106
67
  logger,
107
68
  config,
108
69
  provide,
109
- services,
110
70
  };
111
71
  export default definePlugin({
112
72
  name: '@aalis/plugin-commands',
113
- displayName: '内置指令',
73
+ displayName: t('displayName'),
114
74
  subsystem: 'core',
115
75
  configSchema,
116
76
  provides: [commandsService],
117
77
  uses,
118
78
  apply: registerCommands,
119
79
  });
120
- function registerCommands({ commands, gateway, storage, memory, authority, tools, app, events, hooks, lifecycle, logger, config, provide, services, }) {
80
+ function registerCommands({ commands, gateway, memory, agent, authority, tools, plugins, contributions, i18n, app, events, hooks, lifecycle, logger, config, provide, }) {
121
81
  const cfg = parseConfig(configSchema, config.use, logger.use);
122
82
  // 创建指令注册表并注册为服务
123
- const registry = new CommandRegistry(logger.use, authority);
83
+ const registry = new CommandRegistry(logger.use, authority, i18n);
124
84
  // 可见性的运行时覆盖(authorityOverrides)现归 authority 配置,不在指令注册表加载。
125
85
  // 配置指令系统
126
86
  registry.prefix = cfg.commandPrefix;
127
87
  // 注册服务
128
88
  provide.use(commandsService, registry);
129
- // ===== 统一 memory:clear 中间件:附件缓存清理(图片/视频/语音/文件)=====
130
- //
131
- // 附件按 data:/{images|videos|audios|files}/{safeSessionId} 落盘(与 onebot
132
- // attachment-cache 的 KIND_DIR 对齐)。独立 middleware 使 /clear 与 deleteSession
133
- // 走同一清理路径——deleteSession 触发的 memory:clear 也清附件,杜绝文件泄漏
134
- // (四类必须齐清,漏掉任一类即留下残留文件)。
135
- //
136
- // types 语义:未指定=清全部附件;指定则只清命中的种类(如 /clear -t video)。
137
- hooks.use.middleware('memory:clear', async (data, next) => {
138
- const safeSessionId = data.sessionId?.replace(/[:/\\]/g, '_');
139
- for (const k of ATTACHMENT_KINDS) {
140
- if (data.types && !data.types.includes(k.type))
141
- continue;
142
- try {
143
- if (data.scope === 'all') {
144
- const removed = await removeDirCounted(storage.use, `data:/${k.dir}`);
145
- data.results.push({
146
- source: `${k.type}-cache`,
147
- type: k.type,
148
- success: true,
149
- message: removed >= 0 ? `所有${k.label}缓存已清空(${removed} 个会话目录)` : `${k.label}缓存目录不存在,无需清空`,
150
- });
151
- }
152
- else if (safeSessionId) {
153
- const removed = await removeDirCounted(storage.use, `data:/${k.dir}/${safeSessionId}`);
154
- data.results.push({
155
- source: `${k.type}-cache`,
156
- type: k.type,
157
- success: true,
158
- message: removed >= 0 ? `当前会话${k.label}缓存已清空(${removed} 个)` : `当前会话无${k.label}缓存`,
159
- });
160
- }
161
- }
162
- catch (err) {
163
- const msg = err instanceof Error ? err.message : String(err);
164
- data.results.push({
165
- source: `${k.type}-cache`,
166
- type: k.type,
167
- success: false,
168
- message: `${k.label}缓存清空失败: ${msg}`,
169
- });
170
- }
171
- }
172
- await next();
173
- });
174
89
  // ===== inbound:command 相位:命令命中则执行并中断后续相位 =====
175
90
  //
176
91
  // 命令拦截只在 inbound:command 命名相位做,适配器不内联拦截,
177
92
  // 所有平台共享同一套命令解析路径。
178
93
  // plugin-gateway 的 INBOUND_PHASE_ORDER 把本相位放在 trigger / flow 之前。
179
94
  //
180
- // 受信任系统源:scheduler 等内部触发器写入 message.source。
181
- // 这些来源的权限身份来自创建时固化的 message.actor(scheduler 在 setJob / addJob /
182
- // 静态配置载入这三条建任务路径上各自 snapshot 创建者身份),守卫按 actor 的真实等级
183
- // 评估——与普通用户走同一闸门,不存在 bypassGuard 式的全绕过。
184
- // **actor 缺省即匿名**:创建者匿名时不得回填 owner,否则受信来源就成了提权通道
185
- // (在触发路径把空 actor 补成 `webui:console` 之类,等于开了一条 owner 快速通道)。
186
- // skipConfirm 仍然需要:cron 上下文里没人可问(authorize 仍生效,always 档照常要确认、无从确认即拒)。
187
- // 同时这些 source 通常指向 internal 虚拟 session(无适配器接收),
188
- // 因此结果不走 outbound 而是写日志,避免发到虚空。
189
- // 受信系统源 = 免确认(skipConfirm)+ 结果写日志而非回发。仅 scheduler:
190
- // 它建任务的入口是 dangerous + confirm:'always'(L2 且每次 owner 批准),拿到受信特权
191
- // 的成本与特权本身相称。
192
- // 刻意排除的两项(2026-08-28 对抗审计定,勿再"修死项"重开):
193
- // 'system' —— 只出现在出站消息,无入站生产者,纯死值,删。
194
- // 'workflow:*' —— 曾想按前缀纳入,但 workflow_define/workflow_run 只是 sensitive(L1)、
195
- // 零确认,一旦受信,L1 就能定义 send-message 节点向任意会话静默投递 /clear 这类
196
- // 0 级 + confirm:'session' 命令、且无回显——confirm 闸(防注入误清)被绕过。
197
- // 故 workflow 派发**不受信**:带 source 的注入问不到发出者,需要确认的指令直接拒(fail-closed)。
198
- // 要让 workflow 自动化免确认,须先把 define/run 抬到与 scheduler 相称的档位,那是单独的决定。
199
- const TRUSTED_SOURCE_EXACT = new Set(['scheduler']);
200
- const isTrustedSystemSource = (source) => !!source && TRUSTED_SOURCE_EXACT.has(source);
201
- /** 回到消息所在的会话。required 依赖在调度收敛前可能短暂缺席,故保留事件兜底 */
202
- async function reply(message, content) {
203
- const out = { content, sessionId: message.sessionId, platform: message.platform, source: 'command' };
95
+ // 只有人亲手写的文字才执行指令。真人入站(平台适配器、WebUI、终端)不带 source,照常解析;带 source 的注入不论
96
+ // source 叫什么,都不过指令解析、当普通消息往下走,只有声明了 ownerApproved 的照常解析:文字在建立时经 owner 过目过原文。
97
+ // 带声明的注入:
98
+ // - 权限身份取创建时固化的 message.actor,守卫按它的真实等级评估,与普通用户走同一闸门。**actor 缺省即匿名**:
99
+ // 创建者匿名时不得回填 owner(在触发路径把空 actor 补成 `webui:console` 之类,等于开了一条 owner 快速通道);
100
+ // - 没人可问,免 session 档的确认(skipConfirm;authorize 仍生效,always 档照常要确认、无从确认即拒);
101
+ // 免确认只对创建者是 owner 的注入生效(执行守卫按授权身份判),别人建的任务里需要确认的指令照旧直接拒;
102
+ // - 通常指向没有适配器接收的 internal 虚拟会话,结果写日志而不回发,避免发到虚空。
103
+ /**
104
+ * 回到消息所在的会话。文字对象交给网关按这个会话的语言渲染,content 写原文。required 依赖在调度收敛前可能短暂缺席,
105
+ * 故保留事件兜底(发出原文)
106
+ */
107
+ async function reply(message, result) {
108
+ const out = {
109
+ ...(isTextRef(result) ? { content: toSource(result), text: result } : { content: result }),
110
+ sessionId: message.sessionId,
111
+ platform: message.platform,
112
+ source: 'command',
113
+ };
204
114
  const gatewayService = gateway.present ? gateway.use : undefined;
205
115
  if (gatewayService)
206
116
  await gatewayService.dispatchOutbound(out);
@@ -210,7 +120,7 @@ function registerCommands({ commands, gateway, storage, memory, authority, tools
210
120
  // ===== 意图确认:人亲手输入、声明了 confirm 的指令,在同一会话里问发出者本人 =====
211
121
  //
212
122
  // 只认这个人在这个会话里的下一条消息(带 source 的注入不认):y 为确认,其余取消,这条回复吞掉、不往下走;
213
- // 时限内不回也取消。问的是「确定要这么做吗」,不是 owner 审批:群成员在私聊里清自己的记忆照样能确认。
123
+ // 时限内不回也取消。问的是「确定要这么做吗」,不是 owner 审批:非 owner 的发出者本人确认即可。
214
124
  const INTENT_TIMEOUT_MS = 60_000;
215
125
  const YES = /^\s*(?:y|yes)\s*$/i;
216
126
  /** 等回复的意图确认:键是会话与发出者,值是结算函数 */
@@ -228,7 +138,7 @@ function registerCommands({ commands, gateway, storage, memory, authority, tools
228
138
  const timer = setTimeout(() => settle(false), INTENT_TIMEOUT_MS);
229
139
  timer.unref?.();
230
140
  intents.set(key, settle);
231
- void reply(message, `确认执行 ${raw} 吗?回复 y 确认,回复其他内容取消;${INTENT_TIMEOUT_MS / 1000} 秒内不回复也取消。`);
141
+ void reply(message, t('confirmIntent', { command: raw, seconds: INTENT_TIMEOUT_MS / 1000 }));
232
142
  });
233
143
  }
234
144
  // 停用(含重载)时等待中的确认按取消结算:新实例不认得它们,留到时限只会在拆卸之后才回「已取消」。
@@ -239,41 +149,46 @@ function registerCommands({ commands, gateway, storage, memory, authority, tools
239
149
  }, '取消等待中的意图确认');
240
150
  hooks.use.middleware(INBOUND_PHASE.COMMAND, async (data, next) => {
241
151
  const { message } = data;
242
- const settle = message.source === undefined ? intents.get(intentKey(message)) : undefined;
152
+ const injected = message.source !== undefined;
153
+ // 开头对她的 @(AT_SELF_PATTERN,只认开头)不算正文,意图确认的回复与指令同用这一份:在群里 @ 她或引用她的消息发指令、
154
+ // 回 y 时,QQ 客户端会在开头插一个。开头 @ 的是别人时照旧不算
155
+ const content = message.content ?? '';
156
+ const at = AT_SELF_PATTERN.exec(content);
157
+ const text = at?.index === 0 ? content.slice(at[0].length) : content;
158
+ const settle = injected ? undefined : intents.get(intentKey(message));
243
159
  if (settle) {
244
- settle(YES.test(message.content ?? ''));
160
+ settle(YES.test(text));
245
161
  return;
246
162
  }
247
- // 内部触发(idle-trigger 等无 userId)不参与命令解析
248
- if (!message.content)
163
+ // 带 source 又没声明 ownerApproved 的注入不是人亲手写的,与没有文字的消息一样不参与命令解析
164
+ if (!content || (injected && !message.ownerApproved))
249
165
  return next();
250
- const parsed = registry.parseCommand(message.content);
166
+ const parsed = registry.parseCommand(text);
251
167
  if (!parsed)
252
168
  return next();
253
169
  // 解析到 "<prefix>foo" 但没有任何插件注册过该指令 → 当作普通消息处理
254
170
  // (归档、trigger、agent 等下游相位继续工作),避免对错字/打字噪音回显"未知指令"。
255
171
  if (!registry.hasMatch(parsed.name, parsed.args))
256
172
  return next();
257
- const isSystemTrigger = isTrustedSystemSource(message.source);
258
173
  try {
259
174
  // 优先用 actor(系统触发器注入的代理身份),fallback 到消息原始身份。
260
175
  // 与 agent 工具调用路径解析调用者身份同语义。
261
- const result = await registry.execute(parsed.name, {
176
+ const result = await registry.run(parsed.name, {
262
177
  sessionId: message.sessionId,
263
178
  platform: message.actor?.platform ?? message.platform,
264
179
  userId: message.actor?.userId ?? message.userId,
265
180
  sessionType: message.sessionType,
266
181
  args: parsed.args,
267
182
  raw: parsed.raw,
268
- skipConfirm: isSystemTrigger,
183
+ // 走到这里的注入都声明了 ownerApproved
184
+ skipConfirm: injected,
269
185
  // 人亲手输入的消息才问得到发出者
270
- confirmIntent: message.source === undefined && message.userId !== undefined ? askIntent(message, parsed.raw) : undefined,
186
+ confirmIntent: !injected && message.userId !== undefined ? askIntent(message, parsed.raw) : undefined,
271
187
  });
272
188
  if (result) {
273
- if (isSystemTrigger) {
274
- // 系统触发器(scheduler)的 sessionId 通常是 internal 虚拟 session,
275
- // 走 outbound 也无人接收;直接写日志便于排查。
276
- const preview = result.length > 500 ? `${result.slice(0, 500)}…` : result;
189
+ if (injected) {
190
+ const output = toSource(result);
191
+ const preview = output.length > 500 ? `${output.slice(0, 500)}…` : output;
277
192
  logger.use.info(`[${message.source}] ${parsed.raw} → session=${message.sessionId} 结果:\n${preview}`);
278
193
  }
279
194
  else {
@@ -289,85 +204,88 @@ function registerCommands({ commands, gateway, storage, memory, authority, tools
289
204
  // ===== 内置指令 =====
290
205
  // 只列调用者能用的指令:用不了的在帮助里当作不存在
291
206
  commands.use
292
- .command('help [name:text]', '列出你能用的指令;带指令名看用法详情')
293
- .argument('name', '指令名,子指令用空格或点隔开,如 clear all')
207
+ .command('help [name:text]', t('helpDescription'))
208
+ .argument('name', t('helpName'))
294
209
  .action(async (argv, name) => {
295
- const caller = { platform: argv.session.platform, userId: argv.session.userId };
296
210
  const path = String(name ?? '')
297
211
  .split(/[\s.]+/)
298
212
  .filter(Boolean);
299
- return path.length > 0 ? registry.detail(path, caller) : registry.overview(caller);
213
+ return path.length > 0 ? registry.detail(path, argv.session) : registry.overview(argv.session);
300
214
  });
301
- commands.use.command('status', '显示系统状态').action(async () => {
302
- const lines = ['**系统状态:**', ''];
303
- // 按名枚举:这张表回答的只是"在不在",其中大半个子系统本插件并不依赖,
304
- // 按名动态查不产生依赖边,也就不会把它们拖进本插件的激活闸与关停顺序。
305
- const checks = [
306
- ['WebUI Server', services.use.get('webui-server') !== undefined],
307
- ['CLI', services.use.get('cli') !== undefined],
308
- ['LLM 服务', services.use.get('llm') !== undefined],
309
- ['Agent', services.use.get('agent') !== undefined],
310
- ['记忆服务', services.use.get('memory') !== undefined],
311
- ['人格服务', services.use.get('persona') !== undefined],
312
- ['Embedding', services.use.get('embedding') !== undefined],
313
- ['向量库', services.use.get('vectorstore') !== undefined],
314
- ];
315
- for (const [label, ok] of checks) {
316
- lines.push(`- ${label}: ${ok ? '✅ 可用' : '❌ 不可用'}`);
317
- }
318
- lines.push(`- 已注册工具: ${tools.present ? tools.use.getAll().length : 0} 个`);
319
- lines.push(`- 已注册指令: ${registry.getAll().length} 个`);
320
- return lines.join('\n');
215
+ // 只给计数:逐项的检查(哪个插件为什么无法启动、缺什么服务)交给 /doctor,末尾指过去
216
+ commands.use.command('status', t('statusDescription')).action(async () => {
217
+ const status = plugins.present ? plugins.use.getStatus() : undefined;
218
+ const count = (state) => status?.filter(p => p.state === state).length ?? 0;
219
+ return textLines([
220
+ t('statusTitle'),
221
+ '',
222
+ ...(status ? [t('statusPlugins', { active: count('active'), error: count('error') })] : []),
223
+ t('statusTools', { count: tools.present ? tools.use.getAll().length : 0 }),
224
+ t('statusCommands', { count: registry.getAll().length }),
225
+ '',
226
+ t('statusDoctor', { prefix: registry.prefix }),
227
+ ]);
321
228
  });
322
- commands.use.command('shutdown', '关闭应用', { visibility: 'restricted' }).action(async () => {
229
+ commands.use.command('shutdown', t('shutdownDescription'), { visibility: 'restricted' }).action(async () => {
323
230
  const host = app.use;
324
231
  setTimeout(async () => {
325
232
  await host.stop();
326
233
  process.exit(0);
327
234
  }, 500);
328
- return '正在关闭应用…';
235
+ return t('shuttingDown');
329
236
  });
330
- commands.use.command('restart', '重启应用', { visibility: 'restricted' }).action(async () => {
237
+ commands.use.command('restart', t('restartDescription'), { visibility: 'restricted' }).action(async () => {
331
238
  app.use.restart();
332
- return '正在重启应用…';
239
+ return t('restarting');
333
240
  });
334
- async function runClear(cmdCtx, scope, types) {
241
+ // ===== /clear —— 清空记忆 =====
242
+ // 默认清当前会话;全局清理通过显式危险子指令 /clear all 进入,避免把高危语义藏在普通选项里。
243
+ // 类型由处理它的插件登记(本插件登记它清的消息历史 context),-t 的可选项按登记表现取,同一类型只列一次。
244
+ contributions.use.contribute('memory:clear-type', { id: 'context', label: t('typeContext') });
245
+ /** 登记的清理类型,按收集次序去重:同一类型几家登记时取第一家的显示名 */
246
+ function clearTypes() {
247
+ if (!contributions.present)
248
+ return [];
249
+ const byId = new Map();
250
+ for (const { spec } of contributions.use.collect('memory:clear-type'))
251
+ if (!byId.has(spec.id))
252
+ byId.set(spec.id, spec);
253
+ return [...byId.values()];
254
+ }
255
+ async function runClear(cmdCtx, scope, types, known) {
335
256
  const isGlobal = scope === 'all';
336
257
  // 预检:消息历史清不了就整条不执行。各插件的 memory:clear 中间件先于消息清理运行,放行就会清掉摘要、待办、
337
258
  // 附件等,留下「消息还在、其余已清」的半截状态。预检只决定执行与否,清理时另取当时的记忆后端
338
259
  const clearsMessages = !types || types.includes('context');
339
260
  const memoryAtStart = memory.present ? memory.use : undefined;
340
- if (clearsMessages && !memoryAtStart) {
341
- return ('⚠ 没有在运行的记忆后端,本次未清理任何内容:在 WebUI 插件区启用一个(如 SQLite 记忆),' +
342
- '或把它从配置文件的 disabledPlugins 里删掉;启用了还起不来就用 /doctor 看原因。');
343
- }
344
- if (clearsMessages && isGlobal && !memoryAtStart?.clearAll) {
345
- return '⚠ 记忆后端不支持全局清空消息历史,本次未清理任何内容。可用 -t 指定 context 以外的类型单独清理。';
346
- }
261
+ if (clearsMessages && !memoryAtStart)
262
+ return t('noMemoryBackend');
263
+ if (clearsMessages && isGlobal && !memoryAtStart?.clearAll)
264
+ return t('noGlobalClear');
347
265
  const clearData = {
348
266
  scope,
349
267
  types,
350
268
  sessionId: cmdCtx.sessionId,
351
269
  results: [],
352
270
  };
353
- await hooks.use.run('memory:clear', clearData, async () => {
271
+ const run = () => hooks.use.run('memory:clear', clearData, async () => {
354
272
  if (!clearsMessages)
355
273
  return;
356
274
  try {
357
275
  // 中间件链可能要等一阵(在途落库、向量清空与保存),期间 memory 胜者可能已更换:按此刻的胜者清理,
358
276
  // 不复用预检取到的实例,免得清到已下线的旧后端
359
277
  if (!memory.present)
360
- throw new Error('记忆服务在清理期间下线');
278
+ throw new TextError(t('memoryGone'));
361
279
  const memoryService = memory.use;
362
280
  if (isGlobal) {
363
281
  if (!memoryService.clearAll)
364
- throw new Error('清理期间换上的记忆后端不支持全局清空');
282
+ throw new TextError(t('noGlobalClearMidway'));
365
283
  await memoryService.clearAll();
366
284
  clearData.results.push({
367
285
  source: 'memory',
368
286
  type: 'context',
369
287
  success: true,
370
- message: '所有消息历史和归档已清空',
288
+ message: t('historyClearedAll'),
371
289
  });
372
290
  }
373
291
  else {
@@ -376,71 +294,57 @@ function registerCommands({ commands, gateway, storage, memory, authority, tools
376
294
  source: 'memory',
377
295
  type: 'context',
378
296
  success: true,
379
- message: '当前会话消息历史已清空',
297
+ message: t('historyClearedSession'),
380
298
  });
381
299
  }
382
300
  }
383
301
  catch (err) {
384
- const msg = err instanceof Error ? err.message : String(err);
385
- clearData.results.push({ source: 'memory', type: 'context', success: false, message: `清空失败: ${msg}` });
302
+ clearData.results.push({
303
+ source: 'memory',
304
+ type: 'context',
305
+ success: false,
306
+ message: t('clearFailed', { error: textOf(err) }),
307
+ });
386
308
  }
387
- // 图片缓存与 vectorstore/persona/user-profile 等子系统的清理
388
- // 统一由各自的 memory:clear middleware 处理(见上面的附件缓存 middleware)。
309
+ // 附件、向量、人设、用户档案等子系统的清理由各自的 memory:clear 中间件处理(附件归写入它的插件)。
389
310
  // runClear 仅负责调度 hook 与处理 memory 主体。
390
311
  });
391
- const lines = clearData.results.map(r => `${r.success ? '✅' : '⚠'} ${r.message}`);
312
+ // 清消息历史时经 agent 先停下这些会话在跑的一轮、按住它们,再跑整条清理链:停下与按住在各家清理之外,不看它们的登记先后
313
+ await (clearsMessages && agent.present
314
+ ? agent.use.clearing(isGlobal ? undefined : [cmdCtx.sessionId], run)
315
+ : run());
316
+ // 每行就是各家的说明,成败已写在说明里
317
+ const lines = clearData.results.map(r => r.message);
392
318
  // 显式要求的类型逐个核对:没有任何一行标注该类型,就是没有已启用的插件处理它,照实说明
393
- for (const t of CLEAR_TYPES) {
394
- if (!types?.includes(t.id) || clearData.results.some(r => r.type === t.id))
319
+ for (const { id, label } of known) {
320
+ if (!types?.includes(id) || clearData.results.some(r => r.type === id))
395
321
  continue;
396
- lines.push(!isGlobal && t.globalOnly
397
- ? `⚠ ${t.label}只在 ${registry.prefix}clear all 时清理,本次未清理`
398
- : `⚠ ${t.label}:没有已启用的插件处理这一类型,未清理`);
322
+ lines.push(t('unhandledType', { type: label }));
399
323
  }
400
- if (lines.length === 0)
401
- return '无可清除的记忆模块。';
402
- return lines.join('\n');
324
+ return lines.length === 0 ? t('nothingCleared') : textLines(lines);
403
325
  }
404
- /**
405
- * 清空共享会话(群/频道)所需的最低等级。私聊会话归用户本人,不受此限——
406
- * 清自己的记忆是自助行为;群会话一人清掉会毁掉所有人的上下文,故要求同
407
- * `visibility:'restricted'` 的等级 2。
408
- */
409
- const CLEAR_SHARED_MIN_LEVEL = 2;
410
326
  async function runClearFromOptions(argv, scope) {
327
+ const known = clearTypes();
411
328
  let types;
412
329
  try {
413
- types = normalizeClearTypes(argv.options.type, registry.prefix);
330
+ types = normalizeClearTypes(argv.options.type, known, registry.prefix);
414
331
  }
415
332
  catch (err) {
416
- return err instanceof Error ? err.message : String(err);
417
- }
418
- // 共享会话设防:静态声明只能给出与会话归属无关的策略,这一档必须在运行期判。
419
- if (argv.session.sessionType && argv.session.sessionType !== 'private') {
420
- const auth = authority.present ? authority.use : undefined;
421
- const isOwner = auth?.isOwner(argv.session.platform, argv.session.userId) ?? false;
422
- const level = auth?.listUsers().find(u => u.platform === argv.session.platform && u.userId === argv.session.userId)?.level ??
423
- 0;
424
- if (!isOwner && level < CLEAR_SHARED_MIN_LEVEL) {
425
- return `清空共享会话需要等级 ${CLEAR_SHARED_MIN_LEVEL}(当前 ${level})。私聊里可以随时清理自己的会话。`;
426
- }
333
+ return textOf(err);
427
334
  }
428
- return runClear({ sessionId: argv.session.sessionId }, scope, types);
335
+ return runClear({ sessionId: argv.session.sessionId }, scope, types, known);
429
336
  }
430
337
  const clearTypeOption = {
431
- description: '清理类型,可重复或用逗号分隔',
432
- choices: [
433
- { value: 'all', description: '全部类型' },
434
- ...CLEAR_TYPES.map(t => ({ value: t.id, description: t.globalOnly ? `${t.label}(仅全局清理)` : t.label })),
338
+ description: t('clearTypeDescription'),
339
+ choices: () => [
340
+ { value: 'all', description: t('clearTypeAll') },
341
+ ...clearTypes().map(({ id, label }) => ({ value: id, description: label })),
435
342
  ],
436
343
  };
437
344
  commands.use
438
- // 清空**当前会话**的记忆。风险随会话归属而变,静态声明取「最松的安全默认」:
439
- // confirm:'session' 但不抬等级——私聊里会话是用户自己的,清自己的记忆属自助行为,
440
- // 要 2 级等于剥夺;确认则挡住提示词注入触发的误清(需真人点一下)。
441
- // 群聊会话是共享的,一人清掉毁掉所有人的上下文——那一档在 action 里按
442
- // sessionType 另行设防(与 /bind 等私聊敏感指令同一模式)。
443
- .command('clear', '清空当前会话记忆;用 --type 选择消息、摘要、向量、图片等清理类型', {
345
+ // 清空**当前会话**的记忆;确认挡住提示词注入触发的误清(需真人回一句)。
346
+ .command('clear', t('clearDescription'), {
347
+ risk: 'sensitive',
444
348
  confirm: 'session',
445
349
  })
446
350
  .option('type', '-t <type:string[]>', clearTypeOption)
@@ -452,7 +356,7 @@ function registerCommands({ commands, gateway, storage, memory, authority, tools
452
356
  commands.use
453
357
  // 全局清空,比 /clear 更重:同样 dangerous(等级 2 + 二次确认)。
454
358
  // 原先只写 visibility:'restricted' 拿到了等级 2 但漏了 confirm。
455
- .command('clear.all', '【危险】按 --type 清空全部会话;未指定类型时清空全部类型', {
359
+ .command('clear.all', t('clearAllDescription'), {
456
360
  risk: 'dangerous',
457
361
  })
458
362
  .option('type', '-t <type:string[]>', clearTypeOption)