@aalis/plugin-commands 0.13.1 → 0.15.0

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