evolclaw 3.2.0 → 3.4.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/CHANGELOG.md +53 -0
- package/README.md +7 -4
- package/dist/agents/{resolve.js → baseagent.js} +34 -5
- package/dist/agents/claude-runner.js +120 -31
- package/dist/agents/codex-app-server-client.js +364 -0
- package/dist/agents/codex-runner.js +1152 -140
- package/dist/agents/gemini-runner.js +2 -2
- package/dist/agents/runner-types.js +58 -0
- package/dist/aun/aid/store.js +1 -1
- package/dist/aun/outbox.js +14 -2
- package/dist/aun/storage/download.js +1 -1
- package/dist/aun/storage/upload.js +13 -1
- package/dist/channels/aun.js +869 -358
- package/dist/channels/dingtalk.js +77 -140
- package/dist/channels/feishu.js +125 -154
- package/dist/channels/qqbot.js +75 -138
- package/dist/channels/wechat.js +75 -136
- package/dist/channels/wecom.js +75 -138
- package/dist/cli/agent-command.js +591 -0
- package/dist/cli/agent.js +23 -8
- package/dist/cli/aun-commands.js +1444 -0
- package/dist/cli/ctl-command.js +78 -0
- package/dist/cli/daemon-commands.js +2707 -0
- package/dist/cli/index.js +23 -4905
- package/dist/cli/init.js +33 -6
- package/dist/cli/model.js +1 -1
- package/dist/cli/restart-monitor.js +539 -0
- package/dist/cli/stats.js +558 -0
- package/dist/cli/version.js +87 -0
- package/dist/cli/watch-logs.js +33 -0
- package/dist/cli/watch-msg.js +5 -2
- package/dist/config-store.js +12 -6
- package/dist/core/channel-loader.js +88 -83
- package/dist/core/command/command-handler.js +1189 -0
- package/dist/core/command/menu-handler.js +1478 -0
- package/dist/core/command/slash-gate.js +142 -0
- package/dist/core/command/slash-handler.js +2090 -0
- package/dist/core/evolagent-registry.js +82 -0
- package/dist/core/evolagent.js +17 -1
- package/dist/core/interaction-router.js +8 -0
- package/dist/core/message/command-handler-agent-control.js +63 -1
- package/dist/core/message/im-renderer.js +91 -51
- package/dist/core/message/items-formatter.js +9 -1
- package/dist/core/message/message-bridge.js +73 -24
- package/dist/core/message/message-log.js +1 -0
- package/dist/core/message/message-processor.js +432 -94
- package/dist/core/message/message-queue.js +70 -2
- package/dist/core/message/pending-hints.js +232 -0
- package/dist/core/model/model-catalog.js +1 -1
- package/dist/core/model/model-scope.js +2 -2
- package/dist/core/permission.js +25 -12
- package/dist/core/relation/peer-identity.js +16 -1
- package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
- package/dist/core/session/session-manager.js +86 -26
- package/dist/core/session/session-title.js +26 -0
- package/dist/core/stats/billing.js +151 -0
- package/dist/core/stats/budget.js +93 -0
- package/dist/core/stats/db.js +334 -0
- package/dist/core/stats/eck-vars.js +84 -0
- package/dist/core/stats/index.js +10 -0
- package/dist/core/stats/normalizer.js +78 -0
- package/dist/core/stats/query.js +760 -0
- package/dist/core/stats/writer.js +115 -0
- package/dist/core/trigger/manager.js +34 -0
- package/dist/core/trigger/parser.js +9 -3
- package/dist/core/trigger/scheduler.js +20 -17
- package/dist/data/error-dict.json +7 -0
- package/dist/{agents → eck}/manifest-engine.js +20 -1
- package/dist/{agents → eck}/message-renderer.js +24 -1
- package/dist/index.js +174 -9
- package/dist/ipc.js +116 -1
- package/dist/utils/cross-platform.js +58 -5
- package/dist/utils/ecweb-launch.js +49 -0
- package/dist/utils/ecweb-pair.js +20 -0
- package/dist/utils/error-utils.js +18 -5
- package/dist/utils/npm-ops.js +38 -8
- package/dist/utils/stats.js +77 -6
- package/kits/docs/evolclaw/INDEX.md +3 -1
- package/kits/docs/evolclaw/fs-architecture.md +1215 -0
- package/kits/docs/evolclaw/fs.md +131 -0
- package/kits/docs/evolclaw/group-fs.md +209 -0
- package/kits/docs/evolclaw/stats.md +70 -0
- package/kits/docs/venues/aun-group.md +29 -6
- package/kits/docs/venues/group.md +5 -4
- package/kits/eck_message_manifest.json +30 -3
- package/kits/rules/05-venue.md +1 -1
- package/kits/templates/message-fragments/inject-default.md +2 -0
- package/package.json +5 -6
- package/dist/agents/baseagent-normalize.js +0 -19
- package/dist/core/command-handler.js +0 -3876
- package/dist/core/relation/peer-key.js +0 -16
- package/dist/evolclaw-config.js +0 -11
- package/dist/utils/channel-helpers.js +0 -46
- /package/dist/core/{cache/file-cache.js → daemon-file-cache.js} +0 -0
- /package/dist/{agents → eck}/kit-renderer.js +0 -0
|
@@ -0,0 +1,1189 @@
|
|
|
1
|
+
import { renderCommandCardAsText } from '../interaction-router.js';
|
|
2
|
+
import { buildEnvelope, sendInteractionPayload } from '../message/message-processor.js';
|
|
3
|
+
import { resolvePaths, getPackageRoot } from '../../paths.js';
|
|
4
|
+
import { logger } from '../../utils/logger.js';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import { parseTriggerSet, parseTriggerUpdate } from '../trigger/parser.js';
|
|
10
|
+
import { calcNextFireAt } from '../trigger/scheduler.js';
|
|
11
|
+
import { tryParseChannelKey } from '../channel-loader.js';
|
|
12
|
+
import { displaySessionTitle } from '../session/session-title.js';
|
|
13
|
+
import { isQuickCommand } from './slash-gate.js';
|
|
14
|
+
import { handleSlashCommand } from './slash-handler.js';
|
|
15
|
+
import { execMenuAction as menuExecMenuAction, execMenuForEcweb as menuExecMenuForEcweb, execMenuForControl as menuExecMenuForControl, execMenuQuery as menuExecMenuQuery, execMenuUpdate as menuExecMenuUpdate, getMenuItems as menuGetMenuItems, getSubMenuItems as menuGetSubMenuItems, } from './menu-handler.js';
|
|
16
|
+
export { isProcessLevelOwner } from './menu-handler.js';
|
|
17
|
+
const CLI_EXEC_TIMEOUT_MS = 15_000;
|
|
18
|
+
const CLI_EXEC_MAX_OUTPUT = 128 * 1024;
|
|
19
|
+
/**
|
|
20
|
+
* 写入用户级 ~/.claude/settings.json(与 Claude CLI 行为一致)
|
|
21
|
+
*/
|
|
22
|
+
function writeUserSettings(updates) {
|
|
23
|
+
try {
|
|
24
|
+
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
25
|
+
let settings = {};
|
|
26
|
+
if (fs.existsSync(settingsPath)) {
|
|
27
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
28
|
+
}
|
|
29
|
+
if (updates.model !== undefined)
|
|
30
|
+
settings.model = updates.model;
|
|
31
|
+
if (updates.effortLevel !== undefined) {
|
|
32
|
+
if (updates.effortLevel === null) {
|
|
33
|
+
delete settings.effortLevel;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
settings.effortLevel = updates.effortLevel;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const claudeDir = path.join(os.homedir(), '.claude');
|
|
40
|
+
if (!fs.existsSync(claudeDir)) {
|
|
41
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
44
|
+
return { success: true };
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
return { success: false, error: error.message };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function isAdminRole(role) {
|
|
51
|
+
return role === 'owner' || role === 'admin';
|
|
52
|
+
}
|
|
53
|
+
export class CommandHandler {
|
|
54
|
+
sessionManager;
|
|
55
|
+
messageCache;
|
|
56
|
+
eventBus;
|
|
57
|
+
adapters = new Map();
|
|
58
|
+
policies = new Map();
|
|
59
|
+
channelObjects = new Map(); // name → actual channel instance (for /check)
|
|
60
|
+
channelTypeMap = new Map(); // name → channelType (for grouping)
|
|
61
|
+
processor;
|
|
62
|
+
messageQueue;
|
|
63
|
+
permissionGateway;
|
|
64
|
+
interactionRouter;
|
|
65
|
+
statsCollector;
|
|
66
|
+
agentMap;
|
|
67
|
+
primaryRunnerKey;
|
|
68
|
+
agentRegistry;
|
|
69
|
+
triggerScheduler;
|
|
70
|
+
triggerManager;
|
|
71
|
+
/**
|
|
72
|
+
* Get the runner for a (channel, baseagent) pair.
|
|
73
|
+
*
|
|
74
|
+
* Resolves the owning EvolAgent via the registry; falls back to default key.
|
|
75
|
+
* `baseagent` typically comes from `session.agentId` (e.g. 'claude').
|
|
76
|
+
*/
|
|
77
|
+
getAgent(channel, baseagent) {
|
|
78
|
+
if (channel && baseagent) {
|
|
79
|
+
const evolName = this.agentRegistry?.resolveByChannel(channel)?.name || '<unknown>';
|
|
80
|
+
const key = `${evolName}::${baseagent}`;
|
|
81
|
+
if (this.agentMap.has(key))
|
|
82
|
+
return this.agentMap.get(key);
|
|
83
|
+
}
|
|
84
|
+
if (this.agentMap.has(this.primaryRunnerKey))
|
|
85
|
+
return this.agentMap.get(this.primaryRunnerKey);
|
|
86
|
+
return this.agentMap.values().next().value;
|
|
87
|
+
}
|
|
88
|
+
/** Return the list of baseagents available to a given channel (per-EvolAgent isolation). */
|
|
89
|
+
getAvailableBaseagents(channel) {
|
|
90
|
+
const evolName = this.agentRegistry?.resolveByChannel(channel)?.name || '<unknown>';
|
|
91
|
+
const prefix = `${evolName}::`;
|
|
92
|
+
const result = [];
|
|
93
|
+
for (const key of this.agentMap.keys()) {
|
|
94
|
+
if (key.startsWith(prefix))
|
|
95
|
+
result.push(key.slice(prefix.length));
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
/** Extract the baseagent component from `primaryRunnerKey` (e.g. `aid::claude` → `claude`). */
|
|
100
|
+
parseDefaultBaseagent() {
|
|
101
|
+
const idx = this.primaryRunnerKey.indexOf('::');
|
|
102
|
+
return idx >= 0 ? this.primaryRunnerKey.slice(idx + 2) : this.primaryRunnerKey;
|
|
103
|
+
}
|
|
104
|
+
constructor(sessionManager, agentRunnerOrMap, messageCache, eventBus, primaryRunnerKey) {
|
|
105
|
+
this.sessionManager = sessionManager;
|
|
106
|
+
this.messageCache = messageCache;
|
|
107
|
+
this.eventBus = eventBus;
|
|
108
|
+
if (agentRunnerOrMap instanceof Map) {
|
|
109
|
+
this.agentMap = agentRunnerOrMap;
|
|
110
|
+
this.primaryRunnerKey = primaryRunnerKey || '<unknown>::claude';
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
// 测试 / 单 runner 路径:占位 agent name 用 '<unknown>'
|
|
114
|
+
this.agentMap = new Map([[`<unknown>::${agentRunnerOrMap.name}`, agentRunnerOrMap]]);
|
|
115
|
+
this.primaryRunnerKey = `<unknown>::${agentRunnerOrMap.name}`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** 注入 EvolAgentRegistry,用于判断通道是否被 EvolAgent 管理 */
|
|
119
|
+
setAgentRegistry(registry) {
|
|
120
|
+
this.agentRegistry = registry;
|
|
121
|
+
}
|
|
122
|
+
/** 注入触发器调度器(由 index.ts 在初始化后调用) */
|
|
123
|
+
setTriggerScheduler(scheduler, manager) {
|
|
124
|
+
this.triggerScheduler = scheduler;
|
|
125
|
+
this.triggerManager = manager;
|
|
126
|
+
}
|
|
127
|
+
/** 返回管理当前通道的 EvolAgent,无则返回 null */
|
|
128
|
+
getOwningAgent(channel) {
|
|
129
|
+
if (!this.agentRegistry)
|
|
130
|
+
return null;
|
|
131
|
+
return this.agentRegistry.resolveByChannel(channel);
|
|
132
|
+
}
|
|
133
|
+
/** 返回当前通道的有效项目路径:从 owning agent 取。*/
|
|
134
|
+
getEffectiveDefaultPath(channel) {
|
|
135
|
+
const owning = this.getOwningAgent(channel);
|
|
136
|
+
if (owning)
|
|
137
|
+
return owning.projectPath;
|
|
138
|
+
return process.cwd();
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 持久化 baseagent.model:写到 agent config.json;找不到 owning agent 时
|
|
142
|
+
* 退到用户级 ~/.claude/settings.json(Claude 专用)。
|
|
143
|
+
*/
|
|
144
|
+
persistBaseagentModel(channel, baseagentName, newModel) {
|
|
145
|
+
const owning = this.getOwningAgent(channel);
|
|
146
|
+
if (owning) {
|
|
147
|
+
try {
|
|
148
|
+
owning.setBaseagentModel(newModel);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
return `⚠️ 写入 agent config 失败: ${e?.message || e}`;
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
// 无 owning agent(罕见,新结构下应当不会发生)→ 仅 Claude 走用户级 fallback
|
|
156
|
+
if (baseagentName !== 'claude') {
|
|
157
|
+
return `⚠️ 找不到通道 "${channel}" 所属的 self-agent`;
|
|
158
|
+
}
|
|
159
|
+
const updates = {};
|
|
160
|
+
if (newModel)
|
|
161
|
+
updates.model = newModel;
|
|
162
|
+
const writeResult = writeUserSettings(updates);
|
|
163
|
+
if (!writeResult.success) {
|
|
164
|
+
return `⚠️ 写入用户配置失败: ${writeResult.error}`;
|
|
165
|
+
}
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 持久化 baseagent.effort:写到 agent config.json;找不到时退到用户级 settings。
|
|
170
|
+
*/
|
|
171
|
+
persistBaseagentEffort(channel, baseagentName, newEffort) {
|
|
172
|
+
const owning = this.getOwningAgent(channel);
|
|
173
|
+
if (owning) {
|
|
174
|
+
try {
|
|
175
|
+
owning.setBaseagentEffort(newEffort);
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
return `⚠️ 写入 agent config 失败: ${e?.message || e}`;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
if (baseagentName !== 'claude') {
|
|
183
|
+
return `⚠️ 找不到通道 "${channel}" 所属的 self-agent`;
|
|
184
|
+
}
|
|
185
|
+
const updates = { effortLevel: newEffort ?? null };
|
|
186
|
+
const writeResult = writeUserSettings(updates);
|
|
187
|
+
if (!writeResult.success) {
|
|
188
|
+
return `⚠️ 写入用户配置失败: ${writeResult.error}`;
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
/** 项目列表快捷访问(无 channel 上下文时的 fallback,尽量不用) */
|
|
193
|
+
get projects() {
|
|
194
|
+
return {};
|
|
195
|
+
}
|
|
196
|
+
/** 根据项目路径查找配置中的项目名称 */
|
|
197
|
+
getConfiguredProjectName(projectPath) {
|
|
198
|
+
return Object.entries(this.projects).find(([_, p]) => p === projectPath)?.[0];
|
|
199
|
+
}
|
|
200
|
+
/** 根据项目路径查找项目名称(未配置时回退到目录名) */
|
|
201
|
+
getProjectName(projectPath) {
|
|
202
|
+
return this.getConfiguredProjectName(projectPath) || path.basename(projectPath);
|
|
203
|
+
}
|
|
204
|
+
/** 格式化运行时间 */
|
|
205
|
+
formatUptime(ms) {
|
|
206
|
+
const sec = Math.floor(ms / 1000);
|
|
207
|
+
const d = Math.floor(sec / 86400);
|
|
208
|
+
const h = Math.floor((sec % 86400) / 3600);
|
|
209
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
210
|
+
const s = sec % 60;
|
|
211
|
+
const parts = [];
|
|
212
|
+
if (d > 0)
|
|
213
|
+
parts.push(`${d}天`);
|
|
214
|
+
if (h > 0)
|
|
215
|
+
parts.push(`${h}时`);
|
|
216
|
+
if (m > 0)
|
|
217
|
+
parts.push(`${m}分`);
|
|
218
|
+
if (parts.length === 0)
|
|
219
|
+
parts.push(`${s}秒`);
|
|
220
|
+
return parts.join('');
|
|
221
|
+
}
|
|
222
|
+
/** 获取消息队列 key:话题用 session.id,主会话用 channel-channelId */
|
|
223
|
+
getQueueKey(session, _channel, _channelId) {
|
|
224
|
+
// 队列和 agent 均使用 session.id 作为 key
|
|
225
|
+
return session?.id || '';
|
|
226
|
+
}
|
|
227
|
+
/** 从 session 提取渠道预构建的回复上下文 */
|
|
228
|
+
getReplyContext(session) {
|
|
229
|
+
return session.metadata?.replyContext;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* 发送 CommandCard 卡片。卡片成功返回 null(调用方直接 return),失败返回降级文本。
|
|
233
|
+
* CommandCard 不进 InteractionRouter,按钮点击由 channel 直接构造伪命令入站消息。
|
|
234
|
+
*
|
|
235
|
+
* 走统一 adapter.send(envelope, { kind: 'interaction', ... }) 入口。
|
|
236
|
+
*/
|
|
237
|
+
async sendCommandCard(opts) {
|
|
238
|
+
const adapter = this.adapters.get(opts.channel);
|
|
239
|
+
if (opts.interaction.kind.kind !== 'command-card') {
|
|
240
|
+
logger.warn(`[CommandHandler] sendCommandCard called with non-CommandCard kind`);
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const card = opts.interaction.kind;
|
|
244
|
+
if (opts.canWrite === false)
|
|
245
|
+
return renderCommandCardAsText(card);
|
|
246
|
+
if (!adapter?.send)
|
|
247
|
+
return renderCommandCardAsText(card);
|
|
248
|
+
try {
|
|
249
|
+
const envelope = buildEnvelope({
|
|
250
|
+
channel: opts.channel,
|
|
251
|
+
channelId: opts.channelId,
|
|
252
|
+
agentName: this.agentRegistry?.resolveByChannel(opts.channel)?.name,
|
|
253
|
+
replyContext: opts.replyCtx,
|
|
254
|
+
});
|
|
255
|
+
const fallbackText = renderCommandCardAsText(card);
|
|
256
|
+
const messageId = await sendInteractionPayload(adapter, envelope, opts.interaction, fallbackText, opts.replyCtx);
|
|
257
|
+
if (messageId)
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
logger.warn(`[CommandHandler] sendCommandCard failed: ${e}`);
|
|
262
|
+
}
|
|
263
|
+
return renderCommandCardAsText(card);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* 通用降级应答入口:按 (sessionId, fallbackCommand) 查找 pending interaction 并路由。
|
|
267
|
+
* 返回 { matched: true } 表示已处理,调用方直接返回 result。
|
|
268
|
+
*/
|
|
269
|
+
async handleInteractionFallback(command, args, sessionId, userId) {
|
|
270
|
+
if (!this.interactionRouter)
|
|
271
|
+
return { matched: false };
|
|
272
|
+
const pendingId = this.interactionRouter.findPendingByCommand(sessionId, command);
|
|
273
|
+
if (!pendingId)
|
|
274
|
+
return { matched: false };
|
|
275
|
+
const initiatorId = this.interactionRouter.getInitiator(pendingId);
|
|
276
|
+
if (initiatorId && userId && initiatorId !== userId) {
|
|
277
|
+
return { matched: true, result: '⚠️ 仅卡片发起者可应答' };
|
|
278
|
+
}
|
|
279
|
+
this.interactionRouter.handle({
|
|
280
|
+
type: 'interaction.response',
|
|
281
|
+
id: pendingId,
|
|
282
|
+
action: args,
|
|
283
|
+
operatorId: userId,
|
|
284
|
+
});
|
|
285
|
+
return { matched: true, result: '✓ 已回答' };
|
|
286
|
+
}
|
|
287
|
+
/** 获取活跃会话,无会话时自动创建(话题除外) */
|
|
288
|
+
async ensureSession(channel, channelId, threadId, chatType, selfAID) {
|
|
289
|
+
if (threadId) {
|
|
290
|
+
// 话题会话:仅查询,不创建
|
|
291
|
+
const session = await this.sessionManager.getThreadSession(channel, channelId, threadId);
|
|
292
|
+
if (!session) {
|
|
293
|
+
return { error: '❌ 话题中尚未创建会话\n发送消息后自动创建' };
|
|
294
|
+
}
|
|
295
|
+
return { session };
|
|
296
|
+
}
|
|
297
|
+
const ct = chatType === 'group' ? 'group' : chatType === 'private' ? 'private' : undefined;
|
|
298
|
+
const channelType = this.resolveChannelType(channel);
|
|
299
|
+
const sid = selfAID ?? this.resolveSelfAID(channel);
|
|
300
|
+
const session = await this.sessionManager.getActiveSession(channel, channelId)
|
|
301
|
+
?? await this.sessionManager.getOrCreateSession(channel, channelId, this.getEffectiveDefaultPath(channel), undefined, undefined, undefined, undefined, ct, undefined, sid, channelType);
|
|
302
|
+
// 如果 session 已存在但 chatType 跟传入的不一致,更新
|
|
303
|
+
if (ct && session.chatType !== ct) {
|
|
304
|
+
await this.sessionManager.updateSession(session.id, { chatType: ct });
|
|
305
|
+
session.chatType = ct;
|
|
306
|
+
}
|
|
307
|
+
return { session };
|
|
308
|
+
}
|
|
309
|
+
setProcessor(processor) {
|
|
310
|
+
this.processor = processor;
|
|
311
|
+
}
|
|
312
|
+
setMessageQueue(messageQueue) {
|
|
313
|
+
this.messageQueue = messageQueue;
|
|
314
|
+
}
|
|
315
|
+
setPermissionGateway(gateway) {
|
|
316
|
+
this.permissionGateway = gateway;
|
|
317
|
+
}
|
|
318
|
+
setInteractionRouter(router) {
|
|
319
|
+
this.interactionRouter = router;
|
|
320
|
+
}
|
|
321
|
+
setStatsCollector(collector) {
|
|
322
|
+
this.statsCollector = collector;
|
|
323
|
+
}
|
|
324
|
+
registerAdapter(adapter) {
|
|
325
|
+
this.adapters.set(adapter.channelName, adapter);
|
|
326
|
+
}
|
|
327
|
+
registerChannel(name, channel, channelType) {
|
|
328
|
+
this.channelObjects.set(name, channel);
|
|
329
|
+
if (channelType)
|
|
330
|
+
this.channelTypeMap.set(name, channelType);
|
|
331
|
+
}
|
|
332
|
+
/** 将实例名解析为渠道类型(用于 session 查询) */
|
|
333
|
+
resolveChannelType(channelName) {
|
|
334
|
+
return this.channelTypeMap.get(channelName) || tryParseChannelKey(channelName)?.type || channelName;
|
|
335
|
+
}
|
|
336
|
+
/** CommandCard success is acknowledged by the card UI; failures still return command.error text. */
|
|
337
|
+
shouldSuppressCardTriggerResult(source, _channel) {
|
|
338
|
+
return source === 'card-trigger';
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* 从 channel key(<type>#<selfAID>#<name>)解析本地身份 AID。
|
|
342
|
+
* 非 evolagent 通道(裸 channelType,如 'feishu')解析失败返回 undefined。
|
|
343
|
+
* aun 通道创建 session 时必须提供 selfAID,故所有 getOrCreateSession 调用都经此兜底。
|
|
344
|
+
*/
|
|
345
|
+
resolveSelfAID(channel) {
|
|
346
|
+
return tryParseChannelKey(channel)?.selfAID;
|
|
347
|
+
}
|
|
348
|
+
registerPolicy(channelName, policy) {
|
|
349
|
+
this.policies.set(channelName, policy);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* 注销渠道(热重载断开渠道时调用)。清理所有按实例名登记的 map,
|
|
353
|
+
* 避免死实例残留在 /status、菜单路由和 adapter 查找里。
|
|
354
|
+
*/
|
|
355
|
+
unregisterChannel(channelName) {
|
|
356
|
+
this.adapters.delete(channelName);
|
|
357
|
+
this.channelObjects.delete(channelName);
|
|
358
|
+
this.channelTypeMap.delete(channelName);
|
|
359
|
+
this.policies.delete(channelName);
|
|
360
|
+
}
|
|
361
|
+
getAdapter(channelName) {
|
|
362
|
+
// 先按实例名查找,再按 channelType 查找
|
|
363
|
+
let adapter = this.adapters.get(channelName);
|
|
364
|
+
if (adapter)
|
|
365
|
+
return adapter;
|
|
366
|
+
for (const [name, a] of this.adapters) {
|
|
367
|
+
if ((this.channelTypeMap.get(name) || name) === channelName)
|
|
368
|
+
return a;
|
|
369
|
+
}
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
getPolicy(channel) {
|
|
373
|
+
return this.policies.get(channel) || {
|
|
374
|
+
canSwitchProject: () => true,
|
|
375
|
+
canListProjects: () => true,
|
|
376
|
+
canCreateSession: () => true,
|
|
377
|
+
canDeleteSession: () => true,
|
|
378
|
+
canImportCliSession: () => true,
|
|
379
|
+
messagePrefix: () => '',
|
|
380
|
+
showMiddleResult: () => true,
|
|
381
|
+
showIdleMonitor: () => true,
|
|
382
|
+
accumulateErrors: () => true,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
resolveMenuChatType(channel, channelId, explicit) {
|
|
386
|
+
if (explicit)
|
|
387
|
+
return explicit;
|
|
388
|
+
const active = this.sessionManager.getActiveSessionSync(channel, channelId);
|
|
389
|
+
return active?.chatType === 'group' ? 'group' : 'private';
|
|
390
|
+
}
|
|
391
|
+
canReadTopics(role) {
|
|
392
|
+
return role !== 'anonymous';
|
|
393
|
+
}
|
|
394
|
+
canDeleteTopic(role, chatType, topic, userId) {
|
|
395
|
+
if (role === 'anonymous')
|
|
396
|
+
return false;
|
|
397
|
+
if (isAdminRole(role))
|
|
398
|
+
return true;
|
|
399
|
+
if (chatType === 'group')
|
|
400
|
+
return false;
|
|
401
|
+
return !!userId && topic.metadata?.peerId === userId;
|
|
402
|
+
}
|
|
403
|
+
buildTopicMenuItem(s) {
|
|
404
|
+
const displayName = displaySessionTitle(s.name, s.threadId || s.id.slice(0, 8));
|
|
405
|
+
const item = {
|
|
406
|
+
value: s.threadId,
|
|
407
|
+
label: displayName,
|
|
408
|
+
};
|
|
409
|
+
if (s.agentSessionId) {
|
|
410
|
+
item.agentSessionId = s.agentSessionId;
|
|
411
|
+
const fileInfo = this.sessionManager.getSessionFileInfo(s.projectPath, s.agentSessionId, s.agentId);
|
|
412
|
+
if (fileInfo.turns)
|
|
413
|
+
item.turns = fileInfo.turns;
|
|
414
|
+
const firstMsg = this.sessionManager.readSessionFirstMessage(s.projectPath, s.agentSessionId, s.agentId);
|
|
415
|
+
if (firstMsg)
|
|
416
|
+
item.preview = firstMsg.length > 80 ? firstMsg.slice(0, 80) + '...' : firstMsg;
|
|
417
|
+
}
|
|
418
|
+
if (s.updatedAt)
|
|
419
|
+
item.lastActive = s.updatedAt;
|
|
420
|
+
return item;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* 返回结构化命令菜单(供 menu.query 使用)
|
|
424
|
+
* owner 看到全部命令,admin 看到管理级命令(不含 owner-only),guest 仅看到用户级命令
|
|
425
|
+
*/
|
|
426
|
+
getMenuItems(role, chatType = 'private', scope = 'agent') {
|
|
427
|
+
return menuGetMenuItems.call(this, role, chatType, scope);
|
|
428
|
+
}
|
|
429
|
+
/** 动态子菜单:根据 cmd 路径返回选项列表(供 menu.query + cmd 使用) */
|
|
430
|
+
async getSubMenuItems(cmd, channel, channelId, userId, args, overrideIdentity, explicitChatType, fromControlChannel = false) {
|
|
431
|
+
return await menuGetSubMenuItems.call(this, cmd, channel, channelId, userId, args, overrideIdentity, explicitChatType, fromControlChannel);
|
|
432
|
+
}
|
|
433
|
+
// ── Menu Protocol exec ────────────────────────────────────────────────
|
|
434
|
+
async loadMenuContext(channel, channelId) {
|
|
435
|
+
const session = await this.sessionManager.getActiveSession(channel, channelId);
|
|
436
|
+
const evolagent = this.agentRegistry?.resolveByChannel(channel) ?? null;
|
|
437
|
+
return { session, evolagent };
|
|
438
|
+
}
|
|
439
|
+
requireSession(s) {
|
|
440
|
+
return s ? null : { error: '当前无活跃会话', code: 'NO_ACTIVE_SESSION' };
|
|
441
|
+
}
|
|
442
|
+
/** menu.query — 查询当前值。 */
|
|
443
|
+
async execMenuQuery(cmd, channel, channelId, userId, args, explicitChatType, fromControlChannel = false) {
|
|
444
|
+
return await menuExecMenuQuery.call(this, cmd, channel, channelId, userId, args, explicitChatType, fromControlChannel);
|
|
445
|
+
}
|
|
446
|
+
/** menu.update — 写入新值。 */
|
|
447
|
+
async execMenuUpdate(cmd, value, channel, channelId, userId, overrideIdentity, fromControlChannel = false, args) {
|
|
448
|
+
return await menuExecMenuUpdate.call(this, cmd, value, channel, channelId, userId, overrideIdentity, fromControlChannel, args);
|
|
449
|
+
}
|
|
450
|
+
/** menu.action — 触发动词。 */
|
|
451
|
+
async execMenuAction(cmd, action, args, channel, channelId, userId, overrideIdentity, explicitChatType, requestId, fromControlChannel = false) {
|
|
452
|
+
return await menuExecMenuAction.call(this, cmd, action, args, channel, channelId, userId, overrideIdentity, explicitChatType, requestId, fromControlChannel);
|
|
453
|
+
}
|
|
454
|
+
/** ECWeb 专用入口:注入 owner identity,进程级操作检查 owners 非空。不暴露 cli。 */
|
|
455
|
+
async execMenuForEcweb(payload) {
|
|
456
|
+
return await menuExecMenuForEcweb.call(this, payload);
|
|
457
|
+
}
|
|
458
|
+
/** 控制 AID channel 专用入口:peerId 须 ∈ evolclaw.owners,全量权限。 */
|
|
459
|
+
async execMenuForControl(payload, peerId) {
|
|
460
|
+
return await menuExecMenuForControl.call(this, payload, peerId);
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* CLI 透传执行:spawn `node dist/cli/index.js <argv>` 子进程,捕获输出回传。
|
|
464
|
+
* 不 in-process 调用(CLI handler 用 console.log + process.exit,spawn 行为与终端一致且隔离)。
|
|
465
|
+
* 调用方已完成 owner 校验与白名单过滤。
|
|
466
|
+
*/
|
|
467
|
+
async execCliPassthrough(argv) {
|
|
468
|
+
const { spawn } = await import('child_process');
|
|
469
|
+
const cliEntry = path.join(getPackageRoot(), 'dist', 'cli', 'index.js');
|
|
470
|
+
const startedAt = Date.now();
|
|
471
|
+
return await new Promise((resolve) => {
|
|
472
|
+
let stdout = '';
|
|
473
|
+
let stderr = '';
|
|
474
|
+
let total = 0;
|
|
475
|
+
let truncated = false;
|
|
476
|
+
let settled = false;
|
|
477
|
+
const child = spawn('node', [cliEntry, ...argv], {
|
|
478
|
+
env: { ...process.env, EVOLCLAW_HOME: resolvePaths().root },
|
|
479
|
+
windowsHide: true,
|
|
480
|
+
});
|
|
481
|
+
const append = (buf, sink) => {
|
|
482
|
+
if (truncated)
|
|
483
|
+
return;
|
|
484
|
+
const remaining = CLI_EXEC_MAX_OUTPUT - total;
|
|
485
|
+
if (remaining <= 0) {
|
|
486
|
+
truncated = true;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const chunk = buf.length > remaining ? buf.subarray(0, remaining) : buf;
|
|
490
|
+
total += chunk.length;
|
|
491
|
+
if (sink === 'out')
|
|
492
|
+
stdout += chunk.toString('utf-8');
|
|
493
|
+
else
|
|
494
|
+
stderr += chunk.toString('utf-8');
|
|
495
|
+
if (buf.length > remaining)
|
|
496
|
+
truncated = true;
|
|
497
|
+
};
|
|
498
|
+
child.stdout?.on('data', (b) => append(b, 'out'));
|
|
499
|
+
child.stderr?.on('data', (b) => append(b, 'err'));
|
|
500
|
+
const timer = setTimeout(() => {
|
|
501
|
+
if (settled)
|
|
502
|
+
return;
|
|
503
|
+
settled = true;
|
|
504
|
+
try {
|
|
505
|
+
child.kill('SIGKILL');
|
|
506
|
+
}
|
|
507
|
+
catch { }
|
|
508
|
+
logger.warn(`[CommandHandler] cli exec timeout: ${argv.join(' ')}`);
|
|
509
|
+
resolve({ error: `执行超时(${CLI_EXEC_TIMEOUT_MS / 1000}s):${argv[0]}`, code: 'TIMEOUT' });
|
|
510
|
+
}, CLI_EXEC_TIMEOUT_MS);
|
|
511
|
+
child.on('error', (e) => {
|
|
512
|
+
if (settled)
|
|
513
|
+
return;
|
|
514
|
+
settled = true;
|
|
515
|
+
clearTimeout(timer);
|
|
516
|
+
resolve({ error: e?.message || String(e), code: 'INTERNAL' });
|
|
517
|
+
});
|
|
518
|
+
child.on('close', (exitCode) => {
|
|
519
|
+
if (settled)
|
|
520
|
+
return;
|
|
521
|
+
settled = true;
|
|
522
|
+
clearTimeout(timer);
|
|
523
|
+
resolve({ data: {
|
|
524
|
+
exitCode: exitCode ?? -1,
|
|
525
|
+
stdout, stderr, truncated,
|
|
526
|
+
durationMs: Date.now() - startedAt,
|
|
527
|
+
} });
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
/** 把 menu.action 委派给已有 slash 命令处理逻辑,把 OutboundPayload 包成结构化结果。 */
|
|
532
|
+
async delegateAsAction(action, slashCmd, channel, channelId, userId, opts = {}) {
|
|
533
|
+
try {
|
|
534
|
+
const result = await this._handleInternal(slashCmd, channel, channelId, undefined, userId, undefined, undefined, undefined, undefined, undefined, opts.overrideIdentity);
|
|
535
|
+
if (result == null) {
|
|
536
|
+
// null / undefined: 命令未识别或前置守卫拦截(如 idle 检查),视为失败
|
|
537
|
+
return { error: '命令未执行(可能被前置守卫拦截)', code: 'EXEC_FAILED' };
|
|
538
|
+
}
|
|
539
|
+
if (typeof result !== 'object' || !('kind' in result)) {
|
|
540
|
+
return { data: { action, success: true } };
|
|
541
|
+
}
|
|
542
|
+
const payload = result;
|
|
543
|
+
if (payload.kind === 'command.error') {
|
|
544
|
+
return { error: payload.text || '执行失败', code: 'EXEC_FAILED' };
|
|
545
|
+
}
|
|
546
|
+
const data = { action, success: true };
|
|
547
|
+
if (payload.text)
|
|
548
|
+
data.message = payload.text;
|
|
549
|
+
if (payload.structured)
|
|
550
|
+
data.structured = payload.structured;
|
|
551
|
+
// 对于切换/创建类动作,附加切换后的活跃 session 信息便于客户端继续操作
|
|
552
|
+
if (opts.enrichSession) {
|
|
553
|
+
const newSession = await this.sessionManager.getActiveSession(channel, channelId);
|
|
554
|
+
if (newSession) {
|
|
555
|
+
data.session = { id: newSession.id, name: newSession.name || null };
|
|
556
|
+
if (newSession.agentSessionId)
|
|
557
|
+
data.session.agentSessionId = newSession.agentSessionId;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return { data };
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
return { error: e?.message || String(e), code: 'INTERNAL' };
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
isCommand(content) {
|
|
567
|
+
return isQuickCommand(content);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* 主命令处理入口
|
|
571
|
+
*/
|
|
572
|
+
async handle(content, channel, channelId, sendMessage, userId, threadId, chatType, source, messageId, selfAID) {
|
|
573
|
+
const result = await this._handleInternal(content, channel, channelId, sendMessage, userId, threadId, chatType, source, messageId, selfAID);
|
|
574
|
+
return result;
|
|
575
|
+
}
|
|
576
|
+
async _handleInternal(content, channel, channelId, sendMessage, userId, threadId, chatType, source, messageId, selfAID, overrideIdentity) {
|
|
577
|
+
return await handleSlashCommand.call(this, content, channel, channelId, sendMessage, userId, threadId, chatType, source, messageId, selfAID, overrideIdentity);
|
|
578
|
+
}
|
|
579
|
+
async handleTrigger(content, channel, channelId, peerId, isAdmin, messageId) {
|
|
580
|
+
// Resolve trigger manager/scheduler from the owning agent of this channel
|
|
581
|
+
const owningAgent = this.getOwningAgent(channel);
|
|
582
|
+
const scheduler = (owningAgent?.triggerScheduler ?? this.triggerScheduler);
|
|
583
|
+
const manager = (owningAgent?.triggerManager ?? this.triggerManager);
|
|
584
|
+
// Bare /trigger → list active
|
|
585
|
+
if (content === '/trigger') {
|
|
586
|
+
if (!manager)
|
|
587
|
+
return '⚠️ 触发器功能未启用';
|
|
588
|
+
const active = manager.listActive();
|
|
589
|
+
if (active.length === 0)
|
|
590
|
+
return '📭 当前没有活跃的触发器';
|
|
591
|
+
const lines = active.map(t => {
|
|
592
|
+
const next = new Date(t.nextFireAt).toLocaleString();
|
|
593
|
+
const fired = t.fireCount > 0 ? ` | 已触发 ${t.fireCount} 次` : '';
|
|
594
|
+
return `• **${t.name}** [${t.scheduleType}] 下次: ${next}${fired}`;
|
|
595
|
+
});
|
|
596
|
+
return `📋 活跃触发器(${active.length} 个):\n\n${lines.join('\n')}`;
|
|
597
|
+
}
|
|
598
|
+
const sub = content.slice('/trigger '.length).trim();
|
|
599
|
+
// /trigger list → list all (active + history)
|
|
600
|
+
if (sub === 'list' || sub.startsWith('list ')) {
|
|
601
|
+
if (!manager)
|
|
602
|
+
return '⚠️ 触发器功能未启用';
|
|
603
|
+
const { active, history } = manager.listAll();
|
|
604
|
+
const lines = [];
|
|
605
|
+
if (active.length > 0) {
|
|
606
|
+
lines.push(`**活跃 (${active.length})**`);
|
|
607
|
+
for (const t of active) {
|
|
608
|
+
const next = new Date(t.nextFireAt).toLocaleString();
|
|
609
|
+
lines.push(`• ${t.name} [${t.scheduleType}] 下次: ${next} | 触发 ${t.fireCount} 次`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (history.length > 0) {
|
|
613
|
+
lines.push(`\n**历史 (${history.length})**`);
|
|
614
|
+
for (const h of history.slice(-10)) {
|
|
615
|
+
const done = new Date(h.doneAt).toLocaleString();
|
|
616
|
+
lines.push(`• ${h.name} [${h.doneReason}] ${done}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (lines.length === 0)
|
|
620
|
+
return '📭 没有触发器记录';
|
|
621
|
+
return lines.join('\n');
|
|
622
|
+
}
|
|
623
|
+
// /trigger cancel <name|id>
|
|
624
|
+
if (sub.startsWith('cancel ')) {
|
|
625
|
+
if (!manager || !scheduler)
|
|
626
|
+
return '⚠️ 触发器功能未启用';
|
|
627
|
+
const nameOrId = sub.slice('cancel '.length).trim();
|
|
628
|
+
if (!nameOrId)
|
|
629
|
+
return '❌ 用法:/trigger cancel <名称>';
|
|
630
|
+
// Find trigger: non-admin lookup is scoped to (peerId, channel) to avoid info disclosure
|
|
631
|
+
// Non-admins can cancel by name or by their own trigger's UUID
|
|
632
|
+
let trigger;
|
|
633
|
+
if (isAdmin) {
|
|
634
|
+
trigger = manager.getByName(nameOrId) ?? manager.getById(nameOrId);
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
trigger = manager.getByNameScoped(nameOrId, peerId, channel)
|
|
638
|
+
?? manager.getByIdScoped(nameOrId, peerId, channel);
|
|
639
|
+
}
|
|
640
|
+
if (!trigger) {
|
|
641
|
+
return isAdmin
|
|
642
|
+
? `❌ 未找到触发器:${nameOrId}`
|
|
643
|
+
: `❌ 未找到触发器 "${nameOrId}",或无权限取消`;
|
|
644
|
+
}
|
|
645
|
+
manager.moveToDone(trigger.id, 'cancelled');
|
|
646
|
+
scheduler.cancel(trigger.id);
|
|
647
|
+
this.eventBus.publish({ type: 'trigger:cancelled', triggerId: trigger.id, name: trigger.name, by: peerId });
|
|
648
|
+
return `✅ 触发器已取消:**${trigger.name}**`;
|
|
649
|
+
}
|
|
650
|
+
// /trigger update <name|id> [--参数...]
|
|
651
|
+
if (sub.startsWith('update ')) {
|
|
652
|
+
if (!manager || !scheduler)
|
|
653
|
+
return '⚠️ 触发器功能未启用';
|
|
654
|
+
const args = sub.slice('update '.length);
|
|
655
|
+
const result = parseTriggerUpdate(args);
|
|
656
|
+
if (!result.ok)
|
|
657
|
+
return `❌ ${result.error}`;
|
|
658
|
+
const { nameOrId, value: patch } = result;
|
|
659
|
+
// Find trigger: non-admin lookup is scoped
|
|
660
|
+
let trigger;
|
|
661
|
+
if (isAdmin) {
|
|
662
|
+
trigger = manager.getByName(nameOrId) ?? manager.getById(nameOrId);
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
trigger = manager.getByNameScoped(nameOrId, peerId, channel)
|
|
666
|
+
?? manager.getByIdScoped(nameOrId, peerId, channel);
|
|
667
|
+
}
|
|
668
|
+
if (!trigger) {
|
|
669
|
+
return isAdmin
|
|
670
|
+
? `❌ 未找到触发器:${nameOrId}`
|
|
671
|
+
: `❌ 未找到触发器 "${nameOrId}",或无权限修改`;
|
|
672
|
+
}
|
|
673
|
+
// If schedule changed, recalculate nextFireAt
|
|
674
|
+
if (patch.scheduleType && patch.scheduleValue) {
|
|
675
|
+
const now = Date.now();
|
|
676
|
+
patch.nextFireAt = calcNextFireAt(patch.scheduleType, patch.scheduleValue, now);
|
|
677
|
+
}
|
|
678
|
+
// 跨渠道迁移:改了 targetChannel 时必须同步重算 targetChannelType,
|
|
679
|
+
// 并按 session 策略重新绑定执行会话(与 set 路径保持一致,否则 trigger
|
|
680
|
+
// 仍按旧 channelType 路由 / 仍绑在旧渠道的 boundSessionId 上)。
|
|
681
|
+
const effectiveChannel = patch.targetChannel ?? trigger.targetChannel;
|
|
682
|
+
const effectiveChannelId = patch.targetChannelId ?? trigger.targetChannelId;
|
|
683
|
+
if (patch.targetChannel) {
|
|
684
|
+
patch.targetChannelType = this.resolveChannelType(patch.targetChannel);
|
|
685
|
+
}
|
|
686
|
+
// 解析最终生效的 session 策略(patch 优先,否则沿用原 trigger)
|
|
687
|
+
const effStrategy = patch.targetSessionStrategy ?? trigger.targetSessionStrategy;
|
|
688
|
+
// 渠道或策略变化时,按策略重新绑定会话
|
|
689
|
+
if (patch.targetChannel || patch.targetSessionStrategy) {
|
|
690
|
+
if (effStrategy === 'current') {
|
|
691
|
+
if (patch.targetChannel && patch.targetChannel !== trigger.targetChannel) {
|
|
692
|
+
return '❌ 跨渠道不支持 --session current,请改用 latest 或 thread';
|
|
693
|
+
}
|
|
694
|
+
const active = await this.sessionManager.getActiveSession(effectiveChannel, effectiveChannelId);
|
|
695
|
+
if (!active)
|
|
696
|
+
return '❌ 目标渠道当前没有活跃会话,改用 --session latest 或先在该渠道发一条消息';
|
|
697
|
+
patch.boundSessionId = active.id;
|
|
698
|
+
}
|
|
699
|
+
else if (effStrategy === 'thread') {
|
|
700
|
+
const adapter = this.adapters.get(effectiveChannel);
|
|
701
|
+
if (!adapter?.capabilities.thread)
|
|
702
|
+
return '❌ 目标渠道不支持 thread 会话';
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
// latest 策略:清除旧的 boundSessionId(若有),按渠道动态取最新会话
|
|
706
|
+
if (trigger.boundSessionId)
|
|
707
|
+
patch.boundSessionId = undefined;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
let updated;
|
|
711
|
+
try {
|
|
712
|
+
updated = manager.update(trigger.id, patch);
|
|
713
|
+
scheduler.update(updated);
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
return `❌ 更新失败:${err.message}`;
|
|
717
|
+
}
|
|
718
|
+
const nextStr = new Date(updated.nextFireAt).toLocaleString();
|
|
719
|
+
return `✅ 触发器已更新:**${updated.name}**\n下次触发:${nextStr}`;
|
|
720
|
+
}
|
|
721
|
+
// /trigger set ...
|
|
722
|
+
if (sub.startsWith('set ')) {
|
|
723
|
+
if (!manager || !scheduler)
|
|
724
|
+
return '⚠️ 触发器功能未启用';
|
|
725
|
+
const args = sub.slice('set '.length);
|
|
726
|
+
const result = parseTriggerSet(args);
|
|
727
|
+
if (!result.ok)
|
|
728
|
+
return `❌ ${result.error}`;
|
|
729
|
+
const reg = await this.registerTriggerFromParsed(result.value, channel, channelId, peerId, messageId);
|
|
730
|
+
if (!reg.ok)
|
|
731
|
+
return `❌ ${reg.error}`;
|
|
732
|
+
const nextStr = new Date(reg.trigger.nextFireAt).toLocaleString();
|
|
733
|
+
return `✅ 触发器已注册:**${reg.trigger.name}**\n下次触发:${nextStr}`;
|
|
734
|
+
}
|
|
735
|
+
return `❌ 未知子命令。用法:\n/trigger — 查看活跃触发器\n/trigger list — 查看所有触发器\n/trigger set <参数> — 注册触发器\n/trigger update <名称|ID> <参数> — 修改触发器\n/trigger cancel <名称> — 取消触发器`;
|
|
736
|
+
}
|
|
737
|
+
/** 从已解析的 trigger 参数组装 Trigger 并注册。文本路径(handleTrigger)与 menu 路径共用。
|
|
738
|
+
* parsed 形状 = parseTriggerSet 的 result.value(ParsedTriggerSet)。
|
|
739
|
+
* 失败 return { ok:false, error };成功 return { ok:true, trigger }。本方法不改变原文本路径行为。 */
|
|
740
|
+
async registerTriggerFromParsed(parsed, channel, channelId, peerId, messageId) {
|
|
741
|
+
const owningAgent = this.getOwningAgent(channel);
|
|
742
|
+
const scheduler = (owningAgent?.triggerScheduler ?? this.triggerScheduler);
|
|
743
|
+
const manager = (owningAgent?.triggerManager ?? this.triggerManager);
|
|
744
|
+
if (!manager || !scheduler)
|
|
745
|
+
return { ok: false, error: '触发器功能未启用' };
|
|
746
|
+
const now = Date.now();
|
|
747
|
+
const nextFireAt = calcNextFireAt(parsed.scheduleType, parsed.scheduleValue, now);
|
|
748
|
+
// Auto-generate name if not provided
|
|
749
|
+
const name = parsed.name ?? `trigger-${Date.now().toString(36)}`;
|
|
750
|
+
// Validate target channel exists
|
|
751
|
+
const targetChannelName = parsed.targetChannel ?? channel;
|
|
752
|
+
if (parsed.targetChannel && !this.adapters.has(parsed.targetChannel)) {
|
|
753
|
+
return { ok: false, error: `目标渠道不存在或未启用:${parsed.targetChannel}` };
|
|
754
|
+
}
|
|
755
|
+
// Validate channelId format for AUN: must look like an AID (contains '.')
|
|
756
|
+
const targetChannelType = this.resolveChannelType(targetChannelName);
|
|
757
|
+
const targetChannelId = parsed.targetChannelId ?? channelId;
|
|
758
|
+
if (targetChannelType === 'aun' && parsed.targetChannelId && !parsed.targetChannelId.includes('.')) {
|
|
759
|
+
return { ok: false, error: `AUN 渠道的 --channelid 必须是 AID 格式(如 user.agentid.pub),收到:"${parsed.targetChannelId}"` };
|
|
760
|
+
}
|
|
761
|
+
const trigger = {
|
|
762
|
+
id: crypto.randomUUID(),
|
|
763
|
+
name,
|
|
764
|
+
scheduleType: parsed.scheduleType,
|
|
765
|
+
scheduleValue: parsed.scheduleValue,
|
|
766
|
+
nextFireAt,
|
|
767
|
+
targetChannel: targetChannelName,
|
|
768
|
+
targetChannelId,
|
|
769
|
+
targetChannelType,
|
|
770
|
+
targetThreadId: parsed.targetThreadId,
|
|
771
|
+
targetSessionStrategy: parsed.targetSessionStrategy,
|
|
772
|
+
agentId: parsed.agentId,
|
|
773
|
+
prompt: parsed.prompt,
|
|
774
|
+
createdByPeerId: peerId,
|
|
775
|
+
createdByChannel: channel,
|
|
776
|
+
fireCount: 0,
|
|
777
|
+
failCount: 0,
|
|
778
|
+
createdAt: now,
|
|
779
|
+
updatedAt: now,
|
|
780
|
+
};
|
|
781
|
+
try {
|
|
782
|
+
// Strategy-based session binding
|
|
783
|
+
if (parsed.targetSessionStrategy === 'current') {
|
|
784
|
+
if (parsed.targetChannel && parsed.targetChannel !== channel) {
|
|
785
|
+
return { ok: false, error: '跨渠道不支持 --session current,请改用 latest 或 thread' };
|
|
786
|
+
}
|
|
787
|
+
const active = await this.sessionManager.getActiveSession(channel, channelId);
|
|
788
|
+
if (!active)
|
|
789
|
+
return { ok: false, error: '当前没有活跃会话,改用 --session latest 或 thread' };
|
|
790
|
+
trigger.boundSessionId = active.id;
|
|
791
|
+
}
|
|
792
|
+
else if (parsed.targetSessionStrategy === 'thread') {
|
|
793
|
+
const targetAdapterName = parsed.targetChannel ?? channel;
|
|
794
|
+
const adapter = this.adapters.get(targetAdapterName);
|
|
795
|
+
if (!adapter?.capabilities.thread)
|
|
796
|
+
return { ok: false, error: '目标渠道不支持 thread 会话' };
|
|
797
|
+
const channelType = adapter.channelKey.split('#')[0];
|
|
798
|
+
trigger.targetChannelType = channelType;
|
|
799
|
+
if (channelType === 'aun') {
|
|
800
|
+
trigger.threadKind = 'aun';
|
|
801
|
+
trigger.targetThreadId = `trigger-${trigger.id}`;
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
if (!messageId)
|
|
805
|
+
return { ok: false, error: '飞书 thread 模式需要消息 ID,请重新发送命令' };
|
|
806
|
+
trigger.threadKind = 'feishu';
|
|
807
|
+
trigger.rootMessageId = messageId;
|
|
808
|
+
trigger.pendingThread = true;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
// Validate name uniqueness before persisting (manager.register writes to disk)
|
|
812
|
+
// scheduler.register is in-memory only and cannot fail, so order is safe here.
|
|
813
|
+
// If manager.register throws (duplicate name/ID), nothing is persisted.
|
|
814
|
+
manager.register(trigger);
|
|
815
|
+
scheduler.register(trigger);
|
|
816
|
+
}
|
|
817
|
+
catch (err) {
|
|
818
|
+
return { ok: false, error: `注册失败:${err.message}` };
|
|
819
|
+
}
|
|
820
|
+
return { ok: true, trigger };
|
|
821
|
+
}
|
|
822
|
+
// ── /rewind helpers ──
|
|
823
|
+
async handleRewindList(session, agent) {
|
|
824
|
+
try {
|
|
825
|
+
const messages = await agent.getSessionMessages(session.agentSessionId, session.projectPath);
|
|
826
|
+
const turns = this.buildTurnList(messages);
|
|
827
|
+
if (turns.length === 0) {
|
|
828
|
+
return '📋 当前会话暂无对话记录';
|
|
829
|
+
}
|
|
830
|
+
const lines = turns.map(t => `#${t.index} ${t.userContent}`);
|
|
831
|
+
return [
|
|
832
|
+
`📋 会话历史 (共 ${turns.length} 轮)`,
|
|
833
|
+
'',
|
|
834
|
+
...lines,
|
|
835
|
+
'',
|
|
836
|
+
'💡 /rewind <N> chat|file|all — 撤销第N轮',
|
|
837
|
+
].join('\n');
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
logger.error('[CommandHandler] Failed to read session messages:', error);
|
|
841
|
+
return `❌ 读取会话历史失败: ${error instanceof Error ? error.message : '未知错误'}`;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
async handleRewind(session, agent, turnNum, mode) {
|
|
845
|
+
try {
|
|
846
|
+
const messages = await agent.getSessionMessages(session.agentSessionId, session.projectPath);
|
|
847
|
+
const turns = this.buildTurnList(messages);
|
|
848
|
+
if (turnNum < 1 || turnNum > turns.length) {
|
|
849
|
+
return `❌ 轮次超出范围,当前共 ${turns.length} 轮`;
|
|
850
|
+
}
|
|
851
|
+
// /rewind N = 撤销第N轮(及之后),保留 1..N-1
|
|
852
|
+
const rewindTarget = turns[turnNum - 1]; // 被撤销的轮次(用于文件回退)
|
|
853
|
+
const keepTarget = turnNum >= 2 ? turns[turnNum - 2] : null; // 保留到的轮次(用于对话回退)
|
|
854
|
+
const results = [];
|
|
855
|
+
// 文件回退(立即执行)
|
|
856
|
+
if (mode === 'file' || mode === 'all') {
|
|
857
|
+
if (!agent.rewindFiles) {
|
|
858
|
+
return '❌ 当前 Agent 不支持文件回退';
|
|
859
|
+
}
|
|
860
|
+
const fileResult = await agent.rewindFiles(session.agentSessionId, session.projectPath, rewindTarget.userUuid);
|
|
861
|
+
if (!fileResult.canRewind) {
|
|
862
|
+
if (mode === 'file') {
|
|
863
|
+
return `❌ 当前会话无文件快照,无法回退文件${fileResult.error ? `\n原因: ${fileResult.error}` : ''}`;
|
|
864
|
+
}
|
|
865
|
+
results.push(`⚠️ 文件回退失败${fileResult.error ? `: ${fileResult.error}` : '(无文件快照)'}`);
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
const detail = fileResult.filesChanged
|
|
869
|
+
? `(恢复了 ${fileResult.filesChanged.length} 个文件)`
|
|
870
|
+
: '';
|
|
871
|
+
if (agent.capabilities?.fileRewind === 'git-head') {
|
|
872
|
+
results.push(`✅ 已按 Git HEAD 恢复文件${detail}(Codex 当前不提供逐轮文件快照)`);
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
results.push(`✅ 已恢复文件到第 ${turnNum} 轮之前的状态${detail}`);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
// 对话回退:Codex app-server 可直接 rollback;Claude 走 resumeAt 延迟到下次消息生效。
|
|
880
|
+
if (mode === 'chat' || mode === 'all') {
|
|
881
|
+
const discarded = turns.length - turnNum + 1;
|
|
882
|
+
if (agent.rollbackSessionTurns) {
|
|
883
|
+
const ok = await agent.rollbackSessionTurns(session.agentSessionId, session.projectPath, discarded);
|
|
884
|
+
if (!ok)
|
|
885
|
+
return '❌ 对话回退失败';
|
|
886
|
+
const meta = { ...(session.metadata || {}) };
|
|
887
|
+
delete meta.resumeAt;
|
|
888
|
+
await this.sessionManager.updateSession(session.id, { metadata: meta });
|
|
889
|
+
}
|
|
890
|
+
else if (keepTarget) {
|
|
891
|
+
const meta = { ...(session.metadata || {}), resumeAt: keepTarget.assistantUuid };
|
|
892
|
+
await this.sessionManager.updateSession(session.id, { metadata: meta });
|
|
893
|
+
}
|
|
894
|
+
else {
|
|
895
|
+
// N=1:撤销全部对话,清空 session 从头开始
|
|
896
|
+
const meta = { ...(session.metadata || {}) };
|
|
897
|
+
delete meta.resumeAt;
|
|
898
|
+
await this.sessionManager.updateSession(session.id, {
|
|
899
|
+
metadata: meta,
|
|
900
|
+
agentSessionId: null,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
results.push(`✅ 已撤销第 ${turnNum} 轮${discarded > 1 ? `及后续共 ${discarded} 轮` : ''}`, keepTarget ? `下次发言将从第 ${turnNum - 1} 轮继续` : '下次发言将开始全新对话');
|
|
904
|
+
}
|
|
905
|
+
this.eventBus.publish({
|
|
906
|
+
type: 'session:rewind',
|
|
907
|
+
sessionId: session.id,
|
|
908
|
+
turnNum,
|
|
909
|
+
mode,
|
|
910
|
+
});
|
|
911
|
+
return results.join('\n');
|
|
912
|
+
}
|
|
913
|
+
catch (error) {
|
|
914
|
+
logger.error('[CommandHandler] Rewind failed:', error);
|
|
915
|
+
return `❌ 回退失败: ${error instanceof Error ? error.message : '未知错误'}`;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
buildTurnList(messages) {
|
|
919
|
+
const turns = [];
|
|
920
|
+
let pendingUser = null;
|
|
921
|
+
for (const msg of messages) {
|
|
922
|
+
if (msg.type === 'user') {
|
|
923
|
+
const m = msg.message;
|
|
924
|
+
if (Array.isArray(m?.content) && m.content.every((c) => c.type === 'tool_result')) {
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
const content = this.extractUserContent(msg.message);
|
|
928
|
+
if (content) {
|
|
929
|
+
pendingUser = { content, uuid: msg.uuid };
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
else if (msg.type === 'assistant' && pendingUser) {
|
|
933
|
+
turns.push({
|
|
934
|
+
index: turns.length + 1,
|
|
935
|
+
userContent: pendingUser.content,
|
|
936
|
+
userUuid: pendingUser.uuid,
|
|
937
|
+
assistantUuid: msg.uuid,
|
|
938
|
+
});
|
|
939
|
+
pendingUser = null;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return turns;
|
|
943
|
+
}
|
|
944
|
+
// ── Agent Ctl ──
|
|
945
|
+
static CTL_COMMANDS = [
|
|
946
|
+
'/help', '/status', '/check', '/pwd',
|
|
947
|
+
'/model', '/effort', '/perm', '/agent', '/baseagent',
|
|
948
|
+
'/compact', '/file', '/send', '/restart', '/aid', '/rpc', '/storage',
|
|
949
|
+
'/rename', '/name', '/trigger',
|
|
950
|
+
'/chatmode', '/dispatch', '/activity',
|
|
951
|
+
];
|
|
952
|
+
/** ctl 中仅允许查询形态的指令;写形态(带参)一律拒绝 */
|
|
953
|
+
static CTL_READONLY = new Set(['/baseagent']);
|
|
954
|
+
/**
|
|
955
|
+
* 从 session 恢复 ReplyContext,用于 ctl send 主动发送文本时的路由
|
|
956
|
+
* - 群聊话题:metadata.replyContext.{threadId,peerId}
|
|
957
|
+
* - 私聊:metadata.peerId
|
|
958
|
+
* - taskId/chatmode:从 processing_state 和 sessionMode 注入
|
|
959
|
+
*/
|
|
960
|
+
buildCtlReplyContext(session) {
|
|
961
|
+
const ctx = {};
|
|
962
|
+
const meta = session.metadata;
|
|
963
|
+
if (meta?.replyContext?.threadId)
|
|
964
|
+
ctx.threadId = meta.replyContext.threadId;
|
|
965
|
+
if (meta?.replyContext?.peerId)
|
|
966
|
+
ctx.peerId = meta.replyContext.peerId;
|
|
967
|
+
if (!ctx.peerId && meta?.peerId)
|
|
968
|
+
ctx.peerId = meta.peerId;
|
|
969
|
+
// 话题(Feishu thread)路由:透传 replyToMessageId + replyInThread,
|
|
970
|
+
// 否则 ctl send/file 会丢失话题归属、落到主会话气泡。
|
|
971
|
+
if (meta?.replyContext?.replyToMessageId)
|
|
972
|
+
ctx.replyToMessageId = meta.replyContext.replyToMessageId;
|
|
973
|
+
if (meta?.replyContext?.replyInThread)
|
|
974
|
+
ctx.replyInThread = meta.replyContext.replyInThread;
|
|
975
|
+
const taskId = this.sessionManager.getActiveTaskId(session.id);
|
|
976
|
+
const chatmode = session.sessionMode || 'interactive';
|
|
977
|
+
const encrypted = this.sessionManager.getSessionEncrypt(session.id);
|
|
978
|
+
// 诊断日志:记录 task_id 解析结果
|
|
979
|
+
logger.info(`[CommandHandler] buildCtlReplyContext: sessionId=${session.id} taskId=${taskId ?? 'none'} chatmode=${chatmode} threadId=${ctx.threadId ?? 'none'} replyTo=${ctx.replyToMessageId ?? 'none'} inThread=${ctx.replyInThread ?? false}`);
|
|
980
|
+
if (taskId || chatmode !== 'interactive' || encrypted != null) {
|
|
981
|
+
ctx.metadata = {};
|
|
982
|
+
if (taskId)
|
|
983
|
+
ctx.metadata.taskId = taskId;
|
|
984
|
+
if (chatmode !== 'interactive')
|
|
985
|
+
ctx.metadata.chatmode = chatmode;
|
|
986
|
+
if (encrypted != null)
|
|
987
|
+
ctx.metadata.encrypted = encrypted;
|
|
988
|
+
}
|
|
989
|
+
return Object.keys(ctx).length > 0 ? ctx : undefined;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Agent ctl 入口:通过 IPC 接收 Agent 自主管理指令
|
|
993
|
+
* 复用现有 slash cmd 逻辑,权限继承 session 用户角色
|
|
994
|
+
*/
|
|
995
|
+
async handleCtl(cmd, sessionId) {
|
|
996
|
+
logger.info(`[ctl] cmd="${cmd}" sessionId=${sessionId}`);
|
|
997
|
+
// 1. 白名单检查
|
|
998
|
+
const inputCmd = cmd.split(' ')[0];
|
|
999
|
+
if (!CommandHandler.CTL_COMMANDS.includes(inputCmd)) {
|
|
1000
|
+
return { ok: false, error: `不允许的指令: ${inputCmd}` };
|
|
1001
|
+
}
|
|
1002
|
+
// 1.1 只读守卫:带参形态(写操作)在 ctl 中禁止
|
|
1003
|
+
if (CommandHandler.CTL_READONLY.has(inputCmd) && cmd.trimEnd().length > inputCmd.length) {
|
|
1004
|
+
return { ok: false, error: `${inputCmd} 在 ctl 中仅支持查询形态,不支持带参切换` };
|
|
1005
|
+
}
|
|
1006
|
+
// 2. 通过 sessionId 查 session
|
|
1007
|
+
const session = await this.sessionManager.getSessionById(sessionId);
|
|
1008
|
+
if (!session) {
|
|
1009
|
+
return { ok: false, error: '无效的 session' };
|
|
1010
|
+
}
|
|
1011
|
+
// 3. 从 session.metadata.peerId 获取 userId(用于权限判断)
|
|
1012
|
+
const userId = session.metadata?.peerId;
|
|
1013
|
+
// 3.1 /agent: EvolAgent 管理(转发到 CLI)
|
|
1014
|
+
if (cmd === '/agent' || cmd.startsWith('/agent ')) {
|
|
1015
|
+
const arg = cmd.slice('/agent'.length).trim();
|
|
1016
|
+
// 无参数时返回用法
|
|
1017
|
+
if (!arg) {
|
|
1018
|
+
return { ok: true, result: `用法:\n /agent list 列出所有 agent\n /agent show [name] 查看 agent 详情\n /agent enable <name> 启用 agent\n /agent disable <name> 停用 agent\n /agent get <name> <key> 读取配置字段\n /agent set <name> <key> <val> 修改配置字段\n /agent rename <name> <newname> 修改名称\n /agent reload [name] 热重载配置` };
|
|
1019
|
+
}
|
|
1020
|
+
const parts = arg.split(/\s+/);
|
|
1021
|
+
const subCmd = parts[0];
|
|
1022
|
+
// ctl 禁止 new/delete(仅限 CLI 操作)
|
|
1023
|
+
if (subCmd === 'new' || subCmd === 'delete') {
|
|
1024
|
+
return { ok: false, error: `❌ /agent ${subCmd} 仅限 CLI 操作,请使用: evolclaw agent ${subCmd} ...` };
|
|
1025
|
+
}
|
|
1026
|
+
// 自我保护:不能 disable 自己所在的 agent
|
|
1027
|
+
const selfAgent = this.getOwningAgent(session.channel);
|
|
1028
|
+
const selfName = selfAgent?.name;
|
|
1029
|
+
if (selfName && subCmd === 'disable' && parts[1] === selfName) {
|
|
1030
|
+
return { ok: false, error: `❌ 不能 disable 自己所在的 agent: ${selfName}` };
|
|
1031
|
+
}
|
|
1032
|
+
// 转发到 CLI
|
|
1033
|
+
const cliArgs = ['agent', ...parts];
|
|
1034
|
+
try {
|
|
1035
|
+
const { execFile } = await import('node:child_process');
|
|
1036
|
+
const { promisify } = await import('node:util');
|
|
1037
|
+
const execFileAsync = promisify(execFile);
|
|
1038
|
+
const { stdout, stderr } = await execFileAsync('evolclaw', cliArgs, {
|
|
1039
|
+
timeout: 30000,
|
|
1040
|
+
encoding: 'utf-8',
|
|
1041
|
+
env: { ...process.env, AUN_LOG_INI_DISABLE: '1' },
|
|
1042
|
+
});
|
|
1043
|
+
const output = (stdout || '').trim();
|
|
1044
|
+
if (!output && stderr)
|
|
1045
|
+
return { ok: true, result: `⚠ ${stderr.trim().slice(0, 500)}` };
|
|
1046
|
+
return { ok: true, result: output || '(无输出)' };
|
|
1047
|
+
}
|
|
1048
|
+
catch (e) {
|
|
1049
|
+
const msg = e.stderr?.trim() || e.stdout?.trim() || String(e.message || e);
|
|
1050
|
+
return { ok: false, error: msg.slice(0, 500) };
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
// 4. /send 文本消息:直接通过 adapter 主动发送,不走 handle()
|
|
1054
|
+
if (cmd.startsWith('/send ') || cmd === '/send') {
|
|
1055
|
+
// 解析 --encrypt 标志和消息文本
|
|
1056
|
+
const raw = cmd.startsWith('/send ') ? cmd.slice(6).trim() : '';
|
|
1057
|
+
const forceEncrypt = raw.startsWith('--encrypt ');
|
|
1058
|
+
const text = forceEncrypt ? raw.slice(10).trim() : raw;
|
|
1059
|
+
if (!text)
|
|
1060
|
+
return { ok: false, error: '消息内容不能为空' };
|
|
1061
|
+
const adapter = this.adapters.get(session.channel);
|
|
1062
|
+
if (!adapter)
|
|
1063
|
+
return { ok: false, error: `adapter 未找到: ${session.channel}` };
|
|
1064
|
+
try {
|
|
1065
|
+
const replyContext = this.buildCtlReplyContext(session);
|
|
1066
|
+
const taskId = replyContext?.metadata?.taskId;
|
|
1067
|
+
const chatmode = replyContext?.metadata?.chatmode ?? 'interactive';
|
|
1068
|
+
// --encrypt 覆盖 session 加密状态
|
|
1069
|
+
// 添加 source: 'ctl' 标记(用于区分 ec ctl send)
|
|
1070
|
+
const enrichedReplyContext = forceEncrypt
|
|
1071
|
+
? { ...(replyContext ?? {}), metadata: { ...(replyContext?.metadata ?? {}), encrypted: true, source: 'ctl' } }
|
|
1072
|
+
: { ...(replyContext ?? {}), metadata: { ...(replyContext?.metadata ?? {}), source: 'ctl' } };
|
|
1073
|
+
await adapter.send(buildEnvelope({ taskId, channel: adapter.channelName, channelId: session.channelId, chatmode, replyContext: enrichedReplyContext }), { kind: 'result.text', text, isFinal: true });
|
|
1074
|
+
// 出方向 jsonl 写入已下沉到 aun.ts:deliverTextEntry,message.send 成功后统一写入。
|
|
1075
|
+
return { ok: true, result: 'ok' };
|
|
1076
|
+
}
|
|
1077
|
+
catch (err) {
|
|
1078
|
+
return { ok: false, error: err.message || String(err) };
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
// 5. file 路径限制:只允许 projectPath 下的文件
|
|
1082
|
+
if (cmd.startsWith('/file')) {
|
|
1083
|
+
const sendArgs = cmd.slice(5).trim();
|
|
1084
|
+
const parts = sendArgs.split(/\s+/);
|
|
1085
|
+
const filePath = parts[parts.length - 1];
|
|
1086
|
+
if (filePath) {
|
|
1087
|
+
const resolved = path.resolve(session.projectPath, filePath).replace(/\\/g, '/');
|
|
1088
|
+
const projectPath = session.projectPath.replace(/\\/g, '/');
|
|
1089
|
+
if (!resolved.startsWith(projectPath)) {
|
|
1090
|
+
return { ok: false, error: '路径越界:只能发送项目目录下的文件' };
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
// 5.1 /aid, /rpc, /storage — ctl 专属,转发到 CLI 执行
|
|
1095
|
+
if (cmd === '/aid' || cmd.startsWith('/aid ') ||
|
|
1096
|
+
cmd === '/rpc' || cmd.startsWith('/rpc ') ||
|
|
1097
|
+
cmd === '/storage' || cmd.startsWith('/storage ')) {
|
|
1098
|
+
// 权限检查:仅 owner
|
|
1099
|
+
if (userId) {
|
|
1100
|
+
const identity = this.sessionManager.resolveIdentity(session.channel, userId);
|
|
1101
|
+
if (identity.role !== 'owner') {
|
|
1102
|
+
return { ok: false, error: '无权限:此命令仅限 owner 使用' };
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
// 无参数时返回用法说明
|
|
1106
|
+
if (cmd === '/aid') {
|
|
1107
|
+
return { ok: true, result: `用法:\n /aid list 列出本地所有 AID\n /aid show <aid> 查看 AID 详情\n /aid new <aid> 创建新 AID\n /aid delete <aid> 删除本地 AID\n /aid lookup <aid> 远程探测 AID\n /aid agentmd put <aid> 签名并上传 agent.md\n /aid agentmd get <aid> 下载并验签 agent.md` };
|
|
1108
|
+
}
|
|
1109
|
+
if (cmd === '/rpc') {
|
|
1110
|
+
return { ok: true, result: `用法: /rpc --as <aid> --params <json>\n示例: /rpc --as myaid.agentid.pub --params {"method":"meta.ping","params":{}}` };
|
|
1111
|
+
}
|
|
1112
|
+
if (cmd === '/storage') {
|
|
1113
|
+
return { ok: true, result: `用法:\n /storage upload <aid> <file> <path> [--public]\n /storage download <aid> <url> [local-path]\n /storage ls <aid> [prefix]\n /storage rm <aid> <path>\n /storage quota <aid>` };
|
|
1114
|
+
}
|
|
1115
|
+
// /aid 自我保护:不能删除当前 agent 所用的 AID
|
|
1116
|
+
if (cmd.startsWith('/aid delete ')) {
|
|
1117
|
+
const targetAid = cmd.slice('/aid delete '.length).trim();
|
|
1118
|
+
const selfAgent = this.getOwningAgent(session.channel);
|
|
1119
|
+
const selfAid = selfAgent?.config?.aid;
|
|
1120
|
+
if (selfAid && targetAid === selfAid) {
|
|
1121
|
+
return { ok: false, error: `❌ 不能删除当前 agent 所用的 AID: ${selfAid}` };
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
const cliArgs = cmd.slice(1); // strip leading /
|
|
1125
|
+
try {
|
|
1126
|
+
const { execFile } = await import('node:child_process');
|
|
1127
|
+
const { promisify } = await import('node:util');
|
|
1128
|
+
const execFileAsync = promisify(execFile);
|
|
1129
|
+
const { stdout, stderr } = await execFileAsync('evolclaw', cliArgs.split(/\s+/), {
|
|
1130
|
+
timeout: 30000,
|
|
1131
|
+
encoding: 'utf-8',
|
|
1132
|
+
env: { ...process.env, AUN_LOG_INI_DISABLE: '1' },
|
|
1133
|
+
});
|
|
1134
|
+
const output = (stdout || '').trim();
|
|
1135
|
+
if (!output && stderr)
|
|
1136
|
+
return { ok: true, result: `⚠ ${stderr.trim().slice(0, 500)}` };
|
|
1137
|
+
return { ok: true, result: output || '(无输出)' };
|
|
1138
|
+
}
|
|
1139
|
+
catch (e) {
|
|
1140
|
+
const msg = e.stderr?.trim() || e.stdout?.trim() || String(e.message || e);
|
|
1141
|
+
return { ok: false, error: msg.slice(0, 500) };
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
// 6. 调用现有 handle(),不传 sendMessage 回调(结果直接返回)
|
|
1145
|
+
try {
|
|
1146
|
+
const result = await this.handle(cmd, session.channel, session.channelId, undefined, // 不发送消息
|
|
1147
|
+
userId);
|
|
1148
|
+
const text = typeof result === 'string' ? result : (result && 'text' in result ? result.text : '(无输出)');
|
|
1149
|
+
return { ok: true, result: text || '(无输出)' };
|
|
1150
|
+
}
|
|
1151
|
+
catch (err) {
|
|
1152
|
+
return { ok: false, error: err.message };
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
extractUserContent(message) {
|
|
1156
|
+
const m = message;
|
|
1157
|
+
let text = '';
|
|
1158
|
+
if (typeof m?.content === 'string') {
|
|
1159
|
+
text = m.content;
|
|
1160
|
+
}
|
|
1161
|
+
else if (Array.isArray(m?.content)) {
|
|
1162
|
+
text = m.content.filter((b) => b.type === 'text').map((b) => b.text).join(' ');
|
|
1163
|
+
}
|
|
1164
|
+
text = text.trim();
|
|
1165
|
+
// Strip injection wrappers before previewing (outermost first):
|
|
1166
|
+
// 1. Interrupt wrapper: 【新消息插入】\n...\n【请无视之前中断继续处理】
|
|
1167
|
+
text = text.replace(/^【新消息插入】\s*/, '').replace(/\s*【请无视之前中断继续处理】$/, '').trim();
|
|
1168
|
+
// 2. Current format: ‹metadata›\ncontent (message-renderer item.md)
|
|
1169
|
+
if (text.startsWith('‹')) {
|
|
1170
|
+
const nl = text.indexOf('\n');
|
|
1171
|
+
if (nl !== -1)
|
|
1172
|
+
text = text.slice(nl + 1).trim();
|
|
1173
|
+
}
|
|
1174
|
+
// 3. Legacy XML format: <messages><message sender="..." time="...">content</message></messages>
|
|
1175
|
+
if (text.startsWith('<messages>')) {
|
|
1176
|
+
const parts = [];
|
|
1177
|
+
const re = /<message(?:\s[^>]*)?>([\s\S]*?)<\/message>/g;
|
|
1178
|
+
let match;
|
|
1179
|
+
while ((match = re.exec(text)) !== null)
|
|
1180
|
+
parts.push(match[1].trim());
|
|
1181
|
+
if (parts.length > 0)
|
|
1182
|
+
text = parts.join(' ');
|
|
1183
|
+
}
|
|
1184
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
1185
|
+
if (!text)
|
|
1186
|
+
return '';
|
|
1187
|
+
return text.length > 50 ? text.substring(0, 50) + '…' : text;
|
|
1188
|
+
}
|
|
1189
|
+
}
|