evolclaw 3.1.11 → 3.2.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 +24 -0
- package/README.md +26 -0
- package/dist/agents/kit-renderer.js +5 -1
- package/dist/agents/manifest-engine.js +108 -35
- package/dist/agents/message-renderer.js +2 -0
- package/dist/aun/aid/control-aid.js +67 -0
- package/dist/aun/aid/identity.js +20 -7
- package/dist/aun/aid/store.js +2 -2
- package/dist/channels/aun.js +161 -61
- package/dist/channels/feishu.js +3 -3
- package/dist/cli/agent.js +38 -10
- package/dist/cli/index.js +31 -3
- package/dist/cli/init-channel.js +38 -148
- package/dist/cli/init.js +162 -82
- package/dist/config-store.js +38 -7
- package/dist/core/cache/file-cache.js +216 -0
- package/dist/core/command-handler.js +291 -68
- package/dist/core/evolagent-registry.js +3 -0
- package/dist/core/evolagent.js +28 -23
- package/dist/core/message/command-handler-agent-control.js +153 -0
- package/dist/core/message/create-status.js +67 -0
- package/dist/core/message/message-bridge.js +5 -3
- package/dist/core/message/message-processor.js +44 -36
- package/dist/core/message/message-queue.js +13 -6
- package/dist/core/model/model-scope.js +39 -6
- package/dist/core/session/adapters/claude-session-file-adapter.js +48 -5
- package/dist/evolclaw-config.js +11 -0
- package/dist/index.js +57 -2
- package/dist/ipc.js +6 -0
- package/dist/paths.js +7 -3
- package/kits/templates/message-fragments/item.md +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileCache —— 统一的文件缓存(daemon-only)。
|
|
3
|
+
*
|
|
4
|
+
* daemon 每条消息处理时读大量文件(manifest、fragment、md、working memory、
|
|
5
|
+
* 关系级 preferences 等)。本类统一缓存"文件 → 解析后内容",按策略门控变化检查。
|
|
6
|
+
*
|
|
7
|
+
* 设计与边界详见 docs/file-cache-design.md:
|
|
8
|
+
* - 不接管 EvolAgent 的 merged config(agent/defaults 由 EvolAgent 持有权威副本)。
|
|
9
|
+
* - daemon-only:CLI 短命进程仍直读最新盘值,不用本缓存。
|
|
10
|
+
* - 只缓存"文件 → 解析后内容",不缓存按 vars 渲染后的结果。
|
|
11
|
+
*
|
|
12
|
+
* 策略(reload/重启永远全量失效,无视策略;策略只决定"平时每次读怎么检查"):
|
|
13
|
+
* - on-reload:平时不检查,直接用缓存(kits 文件、persona)。靠 reload/重启刷新。
|
|
14
|
+
* - manual:同 on-reload,额外支持显式 invalidate(file) 单刷。
|
|
15
|
+
* - mtime:每次读 statSync 门控 mtime,变了自动重读(working.md、preferences.json)。
|
|
16
|
+
*
|
|
17
|
+
* 容量:项数可无界增长的组(如 relation-prefs,每 peer 一文件)设 LRU 硬上限
|
|
18
|
+
* (见 GROUP_CAPS),命中/写入移到末尾、超限驱逐同组最旧项;无 timer。
|
|
19
|
+
* 项数固定的组(kits、per-agent 身份层)不设限。
|
|
20
|
+
*
|
|
21
|
+
* 监控:内置命中/读盘/驱逐/失效计数(总计 + 按 group + 按 policy),stats() 导出
|
|
22
|
+
* 快照供 watch web 的 Cache 页展示(经 IPC 'cache-stats')。计数是整数自增,热路径无感。
|
|
23
|
+
*/
|
|
24
|
+
import fs from 'fs';
|
|
25
|
+
const GROUP_NONE = '(none)'; // group 未指定时的归类键
|
|
26
|
+
function newCounters() {
|
|
27
|
+
return { gets: 0, hits: 0, misses: 0, statChecks: 0, reReads: 0, evictions: 0, invalidations: 0 };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 按组的容量上限(LRU)。只有"项数可无界增长"的组才设限——典型是关系级
|
|
31
|
+
* preferences,每个 peer 一个文件,daemon 长跑接触的 peer 越来越多。
|
|
32
|
+
* kits(项数随包固定)、per-agent 身份层(agent 数量有限)不设限,不在此表即无限。
|
|
33
|
+
* 命中/写入都把 key 移到 Map 末尾(Map 保留插入序 → 头部即最久未用),
|
|
34
|
+
* 超限时从头部驱逐同组最旧项。无 timer,确定性 bound。
|
|
35
|
+
*/
|
|
36
|
+
const GROUP_CAPS = {
|
|
37
|
+
'relation-prefs': 512,
|
|
38
|
+
};
|
|
39
|
+
export class FileCache {
|
|
40
|
+
cache = new Map();
|
|
41
|
+
// 监控计数器(总计 + 按 group + 按 policy)。group 未指定归入 GROUP_NONE。
|
|
42
|
+
since = Date.now();
|
|
43
|
+
totals = newCounters();
|
|
44
|
+
byGroup = new Map();
|
|
45
|
+
byPolicy = {
|
|
46
|
+
'on-reload': newCounters(), 'manual': newCounters(), 'mtime': newCounters(),
|
|
47
|
+
};
|
|
48
|
+
/** 取(或建)某 group 的计数器。 */
|
|
49
|
+
groupCounters(group) {
|
|
50
|
+
const key = group ?? GROUP_NONE;
|
|
51
|
+
let c = this.byGroup.get(key);
|
|
52
|
+
if (!c) {
|
|
53
|
+
c = newCounters();
|
|
54
|
+
this.byGroup.set(key, c);
|
|
55
|
+
}
|
|
56
|
+
return c;
|
|
57
|
+
}
|
|
58
|
+
/** 同一事件按 总计/group/policy 三维各加一次。delta 缺省 1。 */
|
|
59
|
+
bump(group, policy, field, delta = 1) {
|
|
60
|
+
this.totals[field] += delta;
|
|
61
|
+
this.groupCounters(group)[field] += delta;
|
|
62
|
+
this.byPolicy[policy][field] += delta;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 读取并缓存文件。loader 把原始内容(UTF-8 字符串;文件不存在时为 null)转成目标值。
|
|
66
|
+
* 同一 file 的 policy/group 以首次注册为准。
|
|
67
|
+
* opts.read 可注入自定义读取器(如 atomicRead 保留崩溃恢复);缺省 readFileOrNull。
|
|
68
|
+
*/
|
|
69
|
+
get(file, loader, opts) {
|
|
70
|
+
const existing = this.cache.get(file);
|
|
71
|
+
const read = opts.read ?? readFileOrNull;
|
|
72
|
+
this.bump(opts.group, opts.policy, 'gets');
|
|
73
|
+
if (opts.policy === 'mtime') {
|
|
74
|
+
const mtimeMs = statMtime(file);
|
|
75
|
+
this.bump(opts.group, opts.policy, 'statChecks');
|
|
76
|
+
if (existing && existing.mtimeMs === mtimeMs) {
|
|
77
|
+
this.bump(opts.group, opts.policy, 'hits');
|
|
78
|
+
this.touch(file, existing);
|
|
79
|
+
return existing.value;
|
|
80
|
+
}
|
|
81
|
+
// 已有条目但 mtime 变了 → 带外改后重读(区别于首次 miss)
|
|
82
|
+
if (existing)
|
|
83
|
+
this.bump(opts.group, opts.policy, 'reReads');
|
|
84
|
+
this.bump(opts.group, opts.policy, 'misses');
|
|
85
|
+
const raw = read(file);
|
|
86
|
+
const value = loader(raw);
|
|
87
|
+
this.store(file, { policy: 'mtime', group: opts.group, mtimeMs, value, bytes: approxBytes(raw) });
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
// on-reload / manual:命中即用,不检查文件
|
|
91
|
+
if (existing) {
|
|
92
|
+
this.bump(opts.group, opts.policy, 'hits');
|
|
93
|
+
this.touch(file, existing);
|
|
94
|
+
return existing.value;
|
|
95
|
+
}
|
|
96
|
+
this.bump(opts.group, opts.policy, 'misses');
|
|
97
|
+
const raw = read(file);
|
|
98
|
+
const value = loader(raw);
|
|
99
|
+
this.store(file, { policy: opts.policy, group: opts.group, value, bytes: approxBytes(raw) });
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
/** 命中时把 key 移到 Map 末尾,维持 LRU 顺序(仅对设了容量上限的组有意义)。 */
|
|
103
|
+
touch(file, entry) {
|
|
104
|
+
if (entry.group === undefined || GROUP_CAPS[entry.group] === undefined)
|
|
105
|
+
return;
|
|
106
|
+
this.cache.delete(file);
|
|
107
|
+
this.cache.set(file, entry);
|
|
108
|
+
}
|
|
109
|
+
/** 写入并对设限组做 LRU 驱逐(踢同组最久未用项)。 */
|
|
110
|
+
store(file, entry) {
|
|
111
|
+
this.cache.delete(file); // 确保重写时移到末尾
|
|
112
|
+
this.cache.set(file, entry);
|
|
113
|
+
const cap = entry.group !== undefined ? GROUP_CAPS[entry.group] : undefined;
|
|
114
|
+
if (cap === undefined)
|
|
115
|
+
return;
|
|
116
|
+
// Map 按插入序遍历,头部即最久未用;驱逐同组超出容量的最旧项。
|
|
117
|
+
let count = 0;
|
|
118
|
+
for (const e of this.cache.values())
|
|
119
|
+
if (e.group === entry.group)
|
|
120
|
+
count++;
|
|
121
|
+
if (count <= cap)
|
|
122
|
+
return;
|
|
123
|
+
for (const [k, e] of this.cache) {
|
|
124
|
+
if (count <= cap)
|
|
125
|
+
break;
|
|
126
|
+
if (e.group === entry.group) {
|
|
127
|
+
this.cache.delete(k);
|
|
128
|
+
count--;
|
|
129
|
+
this.bump(e.group, e.policy, 'evictions');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** 读纯文本的便捷封装(文件不存在返回 null)。 */
|
|
134
|
+
getText(file, opts) {
|
|
135
|
+
return this.get(file, (raw) => raw, opts);
|
|
136
|
+
}
|
|
137
|
+
/** 单文件失效(manual 策略单刷 / 精确失效)。 */
|
|
138
|
+
invalidate(file) {
|
|
139
|
+
const entry = this.cache.get(file);
|
|
140
|
+
if (this.cache.delete(file) && entry)
|
|
141
|
+
this.bump(entry.group, entry.policy, 'invalidations');
|
|
142
|
+
}
|
|
143
|
+
/** 按组失效(reload 钩子失效一组,如 'kits' / 'agent-files:<aid>')。 */
|
|
144
|
+
invalidateGroup(group) {
|
|
145
|
+
for (const [file, entry] of this.cache) {
|
|
146
|
+
if (entry.group === group) {
|
|
147
|
+
this.cache.delete(file);
|
|
148
|
+
this.bump(entry.group, entry.policy, 'invalidations');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** 全量失效(reload / 升级兜底)。 */
|
|
153
|
+
invalidateAll() {
|
|
154
|
+
for (const entry of this.cache.values())
|
|
155
|
+
this.bump(entry.group, entry.policy, 'invalidations');
|
|
156
|
+
this.cache.clear();
|
|
157
|
+
}
|
|
158
|
+
/** 当前缓存条目数(诊断用)。 */
|
|
159
|
+
size() {
|
|
160
|
+
return this.cache.size;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* 运行时统计快照(监控用)。累计计数 O(1) 取出;占用(size/内存/容量)即时遍历
|
|
164
|
+
* 当前 entries 算出——条目数有限(kits 固定、relation-prefs 有 LRU 上限),便宜。
|
|
165
|
+
*/
|
|
166
|
+
stats() {
|
|
167
|
+
const occupancy = {};
|
|
168
|
+
for (const entry of this.cache.values()) {
|
|
169
|
+
const key = entry.group ?? GROUP_NONE;
|
|
170
|
+
let occ = occupancy[key];
|
|
171
|
+
if (!occ) {
|
|
172
|
+
occ = { size: 0, bytes: 0, cap: GROUP_CAPS[key] ?? null };
|
|
173
|
+
occupancy[key] = occ;
|
|
174
|
+
}
|
|
175
|
+
occ.size++;
|
|
176
|
+
occ.bytes += entry.bytes;
|
|
177
|
+
}
|
|
178
|
+
const byGroup = {};
|
|
179
|
+
for (const [k, c] of this.byGroup)
|
|
180
|
+
byGroup[k] = { ...c };
|
|
181
|
+
return {
|
|
182
|
+
since: this.since,
|
|
183
|
+
size: this.cache.size,
|
|
184
|
+
totals: { ...this.totals },
|
|
185
|
+
byGroup,
|
|
186
|
+
byPolicy: {
|
|
187
|
+
'on-reload': { ...this.byPolicy['on-reload'] },
|
|
188
|
+
'manual': { ...this.byPolicy['manual'] },
|
|
189
|
+
'mtime': { ...this.byPolicy['mtime'] },
|
|
190
|
+
},
|
|
191
|
+
occupancy,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function statMtime(file) {
|
|
196
|
+
try {
|
|
197
|
+
return fs.statSync(file).mtimeMs;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return null; // 文件不存在
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function readFileOrNull(file) {
|
|
204
|
+
try {
|
|
205
|
+
return fs.readFileSync(file, 'utf-8');
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** 缓存值内存占用近似:用读入的原始字符串长度(字节级近似),null 记 0。 */
|
|
212
|
+
function approxBytes(raw) {
|
|
213
|
+
return raw === null ? 0 : raw.length;
|
|
214
|
+
}
|
|
215
|
+
/** daemon 单例。CLI 进程不应使用本实例。 */
|
|
216
|
+
export const fileCache = new FileCache();
|
|
@@ -13,6 +13,9 @@ import { parseTriggerSet, parseTriggerUpdate } from './trigger/parser.js';
|
|
|
13
13
|
import { calcNextFireAt } from './trigger/scheduler.js';
|
|
14
14
|
import { checkLatestVersion, getLocalVersion, isLinkedInstall, compareVersions } from '../utils/npm-ops.js';
|
|
15
15
|
import { tryParseChannelKey } from './channel-loader.js';
|
|
16
|
+
import { loadDefaults } from '../config-store.js';
|
|
17
|
+
import { loadEvolclawConfig } from '../evolclaw-config.js';
|
|
18
|
+
import { execAgentAction, execAgentQuery, execAgentOptions, resolveProjectPath } from './message/command-handler-agent-control.js';
|
|
16
19
|
const allEfforts = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
17
20
|
const nonMaxEfforts = allEfforts.filter(e => e !== 'max' && e !== 'xhigh');
|
|
18
21
|
// ── CLI 透传(menu.action name=cli action=exec)─────────────────────────
|
|
@@ -573,8 +576,37 @@ export class CommandHandler {
|
|
|
573
576
|
return items;
|
|
574
577
|
}
|
|
575
578
|
/** 动态子菜单:根据 cmd 路径返回选项列表(供 menu.query + cmd 使用) */
|
|
576
|
-
async getSubMenuItems(cmd, channel, channelId, userId) {
|
|
579
|
+
async getSubMenuItems(cmd, channel, channelId, userId, args) {
|
|
577
580
|
const session = await this.sessionManager.getActiveSession(channel, channelId);
|
|
581
|
+
// ── 进程级 /agent list(owners 鉴权) ──
|
|
582
|
+
if (cmd === '/agent') {
|
|
583
|
+
if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
|
|
584
|
+
throw { code: 'FORBIDDEN', message: '操作需要 owner 权限' };
|
|
585
|
+
}
|
|
586
|
+
const res = await execAgentOptions(args);
|
|
587
|
+
if ('error' in res)
|
|
588
|
+
throw { code: res.code, message: res.error };
|
|
589
|
+
return res.data.agents.map(ag => ({ value: ag.aid, label: ag.name || ag.aid, desc: ag.status }));
|
|
590
|
+
}
|
|
591
|
+
// ── 关系级 /trigger list(每个 trigger 一个 MenuItem) ──
|
|
592
|
+
if (cmd === '/trigger') {
|
|
593
|
+
const owningAgent = this.getOwningAgent(channel);
|
|
594
|
+
const manager = (owningAgent?.triggerManager ?? this.triggerManager);
|
|
595
|
+
if (!manager)
|
|
596
|
+
return [];
|
|
597
|
+
const scope = args?.options === 'all' ? 'all' : 'enabled';
|
|
598
|
+
const role = this.sessionManager.resolveIdentity(channel, userId).role;
|
|
599
|
+
const isAdmin = role === 'owner' || role === 'admin';
|
|
600
|
+
const all = manager.listAll();
|
|
601
|
+
const list = scope === 'all' ? all.active.concat(all.history) : manager.listActive();
|
|
602
|
+
const visible = isAdmin ? list
|
|
603
|
+
: list.filter((t) => t.createdByPeerId === (userId ?? '') && t.createdByChannel === channel);
|
|
604
|
+
return visible.map((t) => ({
|
|
605
|
+
value: t.id,
|
|
606
|
+
label: t.name,
|
|
607
|
+
desc: `${t.scheduleType}${t.nextFireAt ? ` | 下次 ${new Date(t.nextFireAt).toLocaleString()}` : ''}`,
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
578
610
|
if (cmd === '/s' || cmd === '/session' || cmd === '/del') {
|
|
579
611
|
const sessions = await this.sessionManager.listSessions(channel, channelId);
|
|
580
612
|
const active = cmd === '/del' ? await this.sessionManager.getActiveSession(channel, channelId) : null;
|
|
@@ -698,12 +730,18 @@ export class CommandHandler {
|
|
|
698
730
|
return s ? null : { error: '当前无活跃会话', code: 'NO_ACTIVE_SESSION' };
|
|
699
731
|
}
|
|
700
732
|
/** menu.query — 查询当前值。 */
|
|
701
|
-
async execMenuQuery(cmd, channel, channelId, userId) {
|
|
702
|
-
void userId;
|
|
733
|
+
async execMenuQuery(cmd, channel, channelId, userId, args) {
|
|
703
734
|
const cmdBase = cmd.trim().split(' ')[0];
|
|
704
735
|
if (!cmdBase)
|
|
705
736
|
return { error: '缺少命令', code: 'MISSING_CMD' };
|
|
706
737
|
const { session, evolagent } = await this.loadMenuContext(channel, channelId);
|
|
738
|
+
// ── 进程级 /agent(owners 鉴权) ──
|
|
739
|
+
if (cmdBase === '/agent') {
|
|
740
|
+
if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
|
|
741
|
+
return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
|
|
742
|
+
}
|
|
743
|
+
return await execAgentQuery(args);
|
|
744
|
+
}
|
|
707
745
|
if (cmdBase === '/pwd') {
|
|
708
746
|
const sessPath = session?.projectPath;
|
|
709
747
|
const fallbackPath = evolagent?.config?.projects?.defaultPath;
|
|
@@ -792,6 +830,9 @@ export class CommandHandler {
|
|
|
792
830
|
const fallback = evolagent?.config?.dispatch;
|
|
793
831
|
return { data: { mode: sessionMode ?? fallback ?? null } };
|
|
794
832
|
}
|
|
833
|
+
if (cmdBase === '/observable') {
|
|
834
|
+
return { data: { observable: evolagent?.getObservable() ?? false } };
|
|
835
|
+
}
|
|
795
836
|
if (cmdBase === '/perm') {
|
|
796
837
|
const need = this.requireSession(session);
|
|
797
838
|
if (need)
|
|
@@ -804,6 +845,9 @@ export class CommandHandler {
|
|
|
804
845
|
return { data: { mode: currentMode } };
|
|
805
846
|
}
|
|
806
847
|
if (cmdBase === '/system') {
|
|
848
|
+
if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
|
|
849
|
+
return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
|
|
850
|
+
}
|
|
807
851
|
const owningAgent = this.getOwningAgent(channel);
|
|
808
852
|
const data = {
|
|
809
853
|
agent: owningAgent?.name ?? 'DefaultAgent',
|
|
@@ -836,6 +880,56 @@ export class CommandHandler {
|
|
|
836
880
|
return { error: '缺少 value 参数', code: 'MISSING_VALUE' };
|
|
837
881
|
const { session, evolagent } = await this.loadMenuContext(channel, channelId);
|
|
838
882
|
const identity = this.sessionManager.resolveIdentity(channel, userId);
|
|
883
|
+
// ── 关系级 /trigger update(调度参数,value 为 JSON 字符串) ──
|
|
884
|
+
if (cmdBase === '/trigger') {
|
|
885
|
+
const owningAgent = this.getOwningAgent(channel);
|
|
886
|
+
const manager = (owningAgent?.triggerManager ?? this.triggerManager);
|
|
887
|
+
const scheduler = (owningAgent?.triggerScheduler ?? this.triggerScheduler);
|
|
888
|
+
if (!manager || !scheduler)
|
|
889
|
+
return { error: '触发器功能未启用', code: 'NOT_SUPPORTED' };
|
|
890
|
+
let patch;
|
|
891
|
+
try {
|
|
892
|
+
patch = JSON.parse(arg);
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
return { error: 'value 需为 JSON', code: 'INVALID_ARGS' };
|
|
896
|
+
}
|
|
897
|
+
if (!patch?.nameOrId)
|
|
898
|
+
return { error: '缺少 nameOrId', code: 'INVALID_ARGS' };
|
|
899
|
+
const isAdmin = identity.role === 'owner' || identity.role === 'admin';
|
|
900
|
+
if (!isAdmin && !userId)
|
|
901
|
+
return { error: '无法确认身份,请确保渠道提供发送者 ID', code: 'FORBIDDEN' };
|
|
902
|
+
const trigger = isAdmin
|
|
903
|
+
? (manager.getByName(patch.nameOrId) ?? manager.getById(patch.nameOrId))
|
|
904
|
+
: (manager.getByNameScoped(patch.nameOrId, userId ?? '', channel) ?? manager.getByIdScoped(patch.nameOrId, userId ?? '', channel));
|
|
905
|
+
if (!trigger)
|
|
906
|
+
return { error: '触发器不存在或无权限', code: 'NOT_FOUND' };
|
|
907
|
+
const fields = {};
|
|
908
|
+
if (patch.scheduleType !== undefined)
|
|
909
|
+
fields.scheduleType = patch.scheduleType;
|
|
910
|
+
if (patch.scheduleValue !== undefined)
|
|
911
|
+
fields.scheduleValue = String(patch.scheduleValue);
|
|
912
|
+
if (patch.prompt !== undefined)
|
|
913
|
+
fields.prompt = String(patch.prompt);
|
|
914
|
+
// 调度参数变化时重算 nextFireAt——先校验避免 NaN 污染 scheduler heap
|
|
915
|
+
if (fields.scheduleType !== undefined || fields.scheduleValue !== undefined) {
|
|
916
|
+
const effType = fields.scheduleType ?? trigger.scheduleType;
|
|
917
|
+
const effValue = fields.scheduleValue ?? trigger.scheduleValue;
|
|
918
|
+
const schedErr = validateScheduleParams(effType, effValue);
|
|
919
|
+
if (schedErr)
|
|
920
|
+
return { error: schedErr, code: 'INVALID_ARGS' };
|
|
921
|
+
fields.nextFireAt = calcNextFireAt(effType, effValue, Date.now());
|
|
922
|
+
}
|
|
923
|
+
let updated;
|
|
924
|
+
try {
|
|
925
|
+
updated = manager.update(trigger.id, fields);
|
|
926
|
+
}
|
|
927
|
+
catch (err) {
|
|
928
|
+
return { error: `更新失败:${err?.message || err}`, code: 'INVALID_ARGS' };
|
|
929
|
+
}
|
|
930
|
+
scheduler.update(updated);
|
|
931
|
+
return { data: { id: updated.id, nextFireAt: updated.nextFireAt } };
|
|
932
|
+
}
|
|
839
933
|
if (cmdBase === '/baseagent') {
|
|
840
934
|
const valid = this.getAvailableBaseagents(channel);
|
|
841
935
|
if (valid.length && !valid.includes(arg)) {
|
|
@@ -945,6 +1039,16 @@ export class CommandHandler {
|
|
|
945
1039
|
this.agentRegistry.setShowActivities(channel, newMode);
|
|
946
1040
|
return { data: { mode: newMode } };
|
|
947
1041
|
}
|
|
1042
|
+
if (cmdBase === '/observable') {
|
|
1043
|
+
if (identity.role !== 'owner')
|
|
1044
|
+
return { error: '观察者模式仅限 owner 开关', code: 'NO_PERMISSION' };
|
|
1045
|
+
if (arg !== 'true' && arg !== 'false')
|
|
1046
|
+
return { error: `无效值: ${arg},可选: true / false`, code: 'INVALID_VALUE' };
|
|
1047
|
+
if (!evolagent)
|
|
1048
|
+
return { error: '找不到通道所属 agent,无法持久化', code: 'EXEC_FAILED' };
|
|
1049
|
+
evolagent.setObservable(arg === 'true');
|
|
1050
|
+
return { data: { observable: arg === 'true' } };
|
|
1051
|
+
}
|
|
948
1052
|
return { error: `不支持 update: ${cmdBase}`, code: 'NOT_SUPPORTED' };
|
|
949
1053
|
}
|
|
950
1054
|
/** menu.action — 触发动词。 */
|
|
@@ -956,6 +1060,77 @@ export class CommandHandler {
|
|
|
956
1060
|
return { error: '缺少 action', code: 'MISSING_VALUE' };
|
|
957
1061
|
const { session } = await this.loadMenuContext(channel, channelId);
|
|
958
1062
|
const identity = this.sessionManager.resolveIdentity(channel, userId);
|
|
1063
|
+
// ── 进程级 /agent(owners 鉴权,不依赖 session/channel) ──
|
|
1064
|
+
// NOTE(D5): 本次进程级 /agent 仅按 evolclaw.json owners 鉴权,任意 evolagent 的 AUN
|
|
1065
|
+
// channel 均可作为入口。part1(daemon 控制 AID)落地后,应叠加 isControlChannel(channelId)
|
|
1066
|
+
// 闸:仅控制 AID channel 上的 /agent /system 生效。见 part1 计划。
|
|
1067
|
+
if (cmdBase === '/agent') {
|
|
1068
|
+
if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
|
|
1069
|
+
return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
|
|
1070
|
+
}
|
|
1071
|
+
const a = { ...(args ?? {}) };
|
|
1072
|
+
if (action === 'create') {
|
|
1073
|
+
a.project = resolveProjectPath(a.project, a.aid ?? '', loadDefaults());
|
|
1074
|
+
}
|
|
1075
|
+
return await execAgentAction(action, a, userId ?? '');
|
|
1076
|
+
}
|
|
1077
|
+
// ── 关系级 /trigger(不走 owners;复用 isAdmin + scoped 逻辑,D4 直调底层) ──
|
|
1078
|
+
if (cmdBase === '/trigger') {
|
|
1079
|
+
const role = identity.role;
|
|
1080
|
+
const isAdmin = role === 'owner' || role === 'admin';
|
|
1081
|
+
const owningAgent = this.getOwningAgent(channel);
|
|
1082
|
+
const manager = (owningAgent?.triggerManager ?? this.triggerManager);
|
|
1083
|
+
const scheduler = (owningAgent?.triggerScheduler ?? this.triggerScheduler);
|
|
1084
|
+
if (!manager || !scheduler)
|
|
1085
|
+
return { error: '触发器功能未启用', code: 'NOT_SUPPORTED' };
|
|
1086
|
+
if (action === 'set') {
|
|
1087
|
+
// args 结构化 → 直接组装 ParsedTriggerSet(绕过 parseTriggerSet 文本解析,无注入风险)
|
|
1088
|
+
if (!args?.scheduleType || !args?.scheduleValue || !args?.prompt) {
|
|
1089
|
+
return { error: '缺少必填参数:scheduleType / scheduleValue / prompt', code: 'INVALID_ARGS' };
|
|
1090
|
+
}
|
|
1091
|
+
// menu 路径绕过了 parseTriggerSet 的校验,必须自行校验枚举/数值,
|
|
1092
|
+
// 否则非法值会传到 calcNextFireAt 产出 NaN nextFireAt,污染 scheduler heap。
|
|
1093
|
+
const schedErr = validateScheduleParams(args.scheduleType, String(args.scheduleValue));
|
|
1094
|
+
if (schedErr)
|
|
1095
|
+
return { error: schedErr, code: 'INVALID_ARGS' };
|
|
1096
|
+
const strategy = args.targetSessionStrategy ?? 'latest';
|
|
1097
|
+
if (!['latest', 'current', 'thread'].includes(strategy)) {
|
|
1098
|
+
return { error: `无效 targetSessionStrategy: ${strategy}`, code: 'INVALID_ARGS' };
|
|
1099
|
+
}
|
|
1100
|
+
const parsed = {
|
|
1101
|
+
scheduleType: args.scheduleType,
|
|
1102
|
+
scheduleValue: String(args.scheduleValue),
|
|
1103
|
+
prompt: String(args.prompt),
|
|
1104
|
+
name: args.name,
|
|
1105
|
+
targetChannel: args.targetChannel,
|
|
1106
|
+
targetChannelId: args.targetChannelId,
|
|
1107
|
+
targetThreadId: args.targetThreadId,
|
|
1108
|
+
targetSessionStrategy: strategy,
|
|
1109
|
+
agentId: args.agentId,
|
|
1110
|
+
};
|
|
1111
|
+
const r = await this.registerTriggerFromParsed(parsed, channel, channelId, userId ?? '', undefined);
|
|
1112
|
+
if (!r.ok)
|
|
1113
|
+
return { error: r.error, code: /已存在|exists|重复/.test(r.error) ? 'CONFLICT' : 'INVALID_ARGS' };
|
|
1114
|
+
return { data: { id: r.trigger.id, name: r.trigger.name, nextFireAt: r.trigger.nextFireAt } };
|
|
1115
|
+
}
|
|
1116
|
+
if (action === 'cancel') {
|
|
1117
|
+
const nameOrId = args?.nameOrId;
|
|
1118
|
+
if (!nameOrId)
|
|
1119
|
+
return { error: '缺少 nameOrId', code: 'INVALID_ARGS' };
|
|
1120
|
+
if (!isAdmin && !userId)
|
|
1121
|
+
return { error: '无法确认身份,请确保渠道提供发送者 ID', code: 'FORBIDDEN' };
|
|
1122
|
+
const trigger = isAdmin
|
|
1123
|
+
? (manager.getByName(nameOrId) ?? manager.getById(nameOrId))
|
|
1124
|
+
: (manager.getByNameScoped(nameOrId, userId ?? '', channel) ?? manager.getByIdScoped(nameOrId, userId ?? '', channel));
|
|
1125
|
+
if (!trigger)
|
|
1126
|
+
return { error: '触发器不存在或无权限', code: 'NOT_FOUND' };
|
|
1127
|
+
manager.moveToDone(trigger.id, 'cancelled');
|
|
1128
|
+
scheduler.cancel(trigger.id);
|
|
1129
|
+
this.eventBus.publish({ type: 'trigger:cancelled', triggerId: trigger.id, by: userId ?? '' });
|
|
1130
|
+
return { data: { id: trigger.id, cancelled: true } };
|
|
1131
|
+
}
|
|
1132
|
+
return { error: `不支持的 trigger action: ${action}`, code: 'INVALID_ARGS' };
|
|
1133
|
+
}
|
|
959
1134
|
// ── /session 系列 ──
|
|
960
1135
|
if (cmdBase === '/session' || cmdBase === '/s') {
|
|
961
1136
|
if (action === 'stop') {
|
|
@@ -1011,9 +1186,11 @@ export class CommandHandler {
|
|
|
1011
1186
|
}
|
|
1012
1187
|
// ── /system 系列 ──
|
|
1013
1188
|
if (cmdBase === '/system') {
|
|
1189
|
+
// D1 迁移:进程级鉴权统一查 evolclaw.json owners,替代各 action 内联的 identity.role 判断
|
|
1190
|
+
if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
|
|
1191
|
+
return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
|
|
1192
|
+
}
|
|
1014
1193
|
if (action === 'restart') {
|
|
1015
|
-
if (identity.role !== 'owner')
|
|
1016
|
-
return { error: '无权限:服务重启仅限 owner 使用', code: 'NO_PERMISSION' };
|
|
1017
1194
|
const restartInfo = { channel, channelId, timestamp: Date.now() };
|
|
1018
1195
|
fs.writeFileSync(path.join(resolvePaths().dataDir, 'restart-pending.json'), JSON.stringify(restartInfo));
|
|
1019
1196
|
const { spawn } = await import('child_process');
|
|
@@ -1030,8 +1207,6 @@ export class CommandHandler {
|
|
|
1030
1207
|
return await this.delegateAsAction(action, '/check', channel, channelId, userId);
|
|
1031
1208
|
}
|
|
1032
1209
|
if (action === 'upgrade') {
|
|
1033
|
-
if (identity.role !== 'owner')
|
|
1034
|
-
return { error: '无权限:升级仅限 owner 使用', code: 'NO_PERMISSION' };
|
|
1035
1210
|
return await this.delegateAsAction(action, '/upgrade', channel, channelId, userId);
|
|
1036
1211
|
}
|
|
1037
1212
|
return { error: `不支持的 system action: ${action}`, code: 'NOT_SUPPORTED' };
|
|
@@ -3254,70 +3429,83 @@ export class CommandHandler {
|
|
|
3254
3429
|
const result = parseTriggerSet(args);
|
|
3255
3430
|
if (!result.ok)
|
|
3256
3431
|
return `❌ ${result.error}`;
|
|
3257
|
-
const
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3432
|
+
const reg = await this.registerTriggerFromParsed(result.value, channel, channelId, peerId, messageId);
|
|
3433
|
+
if (!reg.ok)
|
|
3434
|
+
return `❌ ${reg.error}`;
|
|
3435
|
+
const nextStr = new Date(reg.trigger.nextFireAt).toLocaleString();
|
|
3436
|
+
return `✅ 触发器已注册:**${reg.trigger.name}**\n下次触发:${nextStr}`;
|
|
3437
|
+
}
|
|
3438
|
+
return `❌ 未知子命令。用法:\n/trigger — 查看活跃触发器\n/trigger list — 查看所有触发器\n/trigger set <参数> — 注册触发器\n/trigger update <名称|ID> <参数> — 修改触发器\n/trigger cancel <名称> — 取消触发器`;
|
|
3439
|
+
}
|
|
3440
|
+
/** 从已解析的 trigger 参数组装 Trigger 并注册。文本路径(handleTrigger)与 menu 路径共用。
|
|
3441
|
+
* parsed 形状 = parseTriggerSet 的 result.value(ParsedTriggerSet)。
|
|
3442
|
+
* 失败 return { ok:false, error };成功 return { ok:true, trigger }。本方法不改变原文本路径行为。 */
|
|
3443
|
+
async registerTriggerFromParsed(parsed, channel, channelId, peerId, messageId) {
|
|
3444
|
+
const owningAgent = this.getOwningAgent(channel);
|
|
3445
|
+
const scheduler = (owningAgent?.triggerScheduler ?? this.triggerScheduler);
|
|
3446
|
+
const manager = (owningAgent?.triggerManager ?? this.triggerManager);
|
|
3447
|
+
if (!manager || !scheduler)
|
|
3448
|
+
return { ok: false, error: '触发器功能未启用' };
|
|
3449
|
+
const now = Date.now();
|
|
3450
|
+
const nextFireAt = calcNextFireAt(parsed.scheduleType, parsed.scheduleValue, now);
|
|
3451
|
+
// Auto-generate name if not provided
|
|
3452
|
+
const name = parsed.name ?? `trigger-${Date.now().toString(36)}`;
|
|
3453
|
+
const trigger = {
|
|
3454
|
+
id: crypto.randomUUID(),
|
|
3455
|
+
name,
|
|
3456
|
+
scheduleType: parsed.scheduleType,
|
|
3457
|
+
scheduleValue: parsed.scheduleValue,
|
|
3458
|
+
nextFireAt,
|
|
3459
|
+
targetChannel: parsed.targetChannel ?? channel,
|
|
3460
|
+
targetChannelId: parsed.targetChannelId ?? channelId,
|
|
3461
|
+
targetChannelType: this.resolveChannelType(parsed.targetChannel ?? channel),
|
|
3462
|
+
targetThreadId: parsed.targetThreadId,
|
|
3463
|
+
targetSessionStrategy: parsed.targetSessionStrategy,
|
|
3464
|
+
agentId: parsed.agentId,
|
|
3465
|
+
prompt: parsed.prompt,
|
|
3466
|
+
createdByPeerId: peerId,
|
|
3467
|
+
createdByChannel: channel,
|
|
3468
|
+
fireCount: 0,
|
|
3469
|
+
createdAt: now,
|
|
3470
|
+
updatedAt: now,
|
|
3471
|
+
};
|
|
3472
|
+
try {
|
|
3473
|
+
// Strategy-based session binding
|
|
3474
|
+
if (parsed.targetSessionStrategy === 'current') {
|
|
3475
|
+
const active = await this.sessionManager.getActiveSession(channel, channelId);
|
|
3476
|
+
if (!active)
|
|
3477
|
+
return { ok: false, error: '当前没有活跃会话,改用 --session latest 或 thread' };
|
|
3478
|
+
trigger.boundSessionId = active.id;
|
|
3479
|
+
}
|
|
3480
|
+
else if (parsed.targetSessionStrategy === 'thread') {
|
|
3481
|
+
const targetAdapterName = parsed.targetChannel ?? channel;
|
|
3482
|
+
const adapter = this.adapters.get(targetAdapterName);
|
|
3483
|
+
if (!adapter?.capabilities.thread)
|
|
3484
|
+
return { ok: false, error: '目标渠道不支持 thread 会话' };
|
|
3485
|
+
const channelType = adapter.channelKey.split('#')[0];
|
|
3486
|
+
trigger.targetChannelType = channelType;
|
|
3487
|
+
if (channelType === 'aun') {
|
|
3488
|
+
trigger.threadKind = 'aun';
|
|
3489
|
+
trigger.targetThreadId = `trigger-${trigger.id}`;
|
|
3490
|
+
}
|
|
3491
|
+
else {
|
|
3492
|
+
if (!messageId)
|
|
3493
|
+
return { ok: false, error: '飞书 thread 模式需要消息 ID,请重新发送命令' };
|
|
3494
|
+
trigger.threadKind = 'feishu';
|
|
3495
|
+
trigger.rootMessageId = messageId;
|
|
3496
|
+
trigger.pendingThread = true;
|
|
3307
3497
|
}
|
|
3308
|
-
// Validate name uniqueness before persisting (manager.register writes to disk)
|
|
3309
|
-
// scheduler.register is in-memory only and cannot fail, so order is safe here.
|
|
3310
|
-
// If manager.register throws (duplicate name/ID), nothing is persisted.
|
|
3311
|
-
manager.register(trigger);
|
|
3312
|
-
scheduler.register(trigger);
|
|
3313
|
-
}
|
|
3314
|
-
catch (err) {
|
|
3315
|
-
return `❌ 注册失败:${err.message}`;
|
|
3316
3498
|
}
|
|
3317
|
-
|
|
3318
|
-
|
|
3499
|
+
// Validate name uniqueness before persisting (manager.register writes to disk)
|
|
3500
|
+
// scheduler.register is in-memory only and cannot fail, so order is safe here.
|
|
3501
|
+
// If manager.register throws (duplicate name/ID), nothing is persisted.
|
|
3502
|
+
manager.register(trigger);
|
|
3503
|
+
scheduler.register(trigger);
|
|
3319
3504
|
}
|
|
3320
|
-
|
|
3505
|
+
catch (err) {
|
|
3506
|
+
return { ok: false, error: `注册失败:${err.message}` };
|
|
3507
|
+
}
|
|
3508
|
+
return { ok: true, trigger };
|
|
3321
3509
|
}
|
|
3322
3510
|
// ── /rewind helpers ──
|
|
3323
3511
|
async handleRewindList(session, agent) {
|
|
@@ -3651,3 +3839,38 @@ export class CommandHandler {
|
|
|
3651
3839
|
return text.length > 50 ? text.substring(0, 50) + '…' : text;
|
|
3652
3840
|
}
|
|
3653
3841
|
}
|
|
3842
|
+
/** 进程级 menu 操作(/agent、/system)鉴权:发送方 AID 必须在 owners 名单中。
|
|
3843
|
+
* owners 来自 evolclaw.json 顶层(进程级控制面配置)。纯静态名单比对。 */
|
|
3844
|
+
export function isProcessLevelOwner(peerId, owners) {
|
|
3845
|
+
if (!peerId)
|
|
3846
|
+
return false;
|
|
3847
|
+
return (owners ?? []).includes(peerId);
|
|
3848
|
+
}
|
|
3849
|
+
/** 校验 menu 路径直传的 trigger 调度参数(绕过 parseTriggerSet 文本解析后必须自校验)。
|
|
3850
|
+
* 返回错误字符串表示非法;返回 null 表示通过。
|
|
3851
|
+
* 防止非法 scheduleType/scheduleValue 传到 calcNextFireAt 产出 NaN/throw,污染 scheduler heap。 */
|
|
3852
|
+
export function validateScheduleParams(scheduleType, scheduleValue) {
|
|
3853
|
+
if (!['delay', 'at', 'cron'].includes(scheduleType)) {
|
|
3854
|
+
return `无效 scheduleType: ${scheduleType}(可选: delay / at / cron)`;
|
|
3855
|
+
}
|
|
3856
|
+
if (scheduleType === 'delay') {
|
|
3857
|
+
const ms = Number(scheduleValue);
|
|
3858
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
3859
|
+
return `delay 的 scheduleValue 需为正整数毫秒: ${scheduleValue}`;
|
|
3860
|
+
}
|
|
3861
|
+
else if (scheduleType === 'at') {
|
|
3862
|
+
const ts = new Date(scheduleValue).getTime();
|
|
3863
|
+
if (!Number.isFinite(ts))
|
|
3864
|
+
return `at 的 scheduleValue 需为合法时间: ${scheduleValue}`;
|
|
3865
|
+
}
|
|
3866
|
+
else {
|
|
3867
|
+
// cron:交给 calcNextFireAt 内部的 CronExpressionParser 校验(会 throw,被上层 catch)
|
|
3868
|
+
try {
|
|
3869
|
+
calcNextFireAt('cron', scheduleValue, Date.now());
|
|
3870
|
+
}
|
|
3871
|
+
catch {
|
|
3872
|
+
return `无效 cron 表达式: ${scheduleValue}`;
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3875
|
+
return null;
|
|
3876
|
+
}
|