evolclaw 3.1.11 → 3.3.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 +41 -0
- package/README.md +27 -2
- package/dist/agents/{resolve.js → baseagent.js} +34 -5
- package/dist/agents/claude-runner.js +120 -27
- package/dist/agents/codex-app-server-client.js +364 -0
- package/dist/agents/codex-runner.js +1069 -141
- package/dist/agents/gemini-runner.js +2 -2
- package/dist/agents/runner-types.js +28 -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/aun/storage/download.js +1 -1
- package/dist/aun/storage/upload.js +13 -1
- package/dist/channels/aun.js +538 -325
- package/dist/channels/dingtalk.js +77 -140
- package/dist/channels/feishu.js +98 -151
- 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.js +44 -13
- package/dist/cli/index.js +207 -46
- package/dist/cli/init-channel.js +38 -148
- package/dist/cli/init.js +192 -85
- package/dist/cli/model.js +1 -1
- package/dist/cli/stats.js +558 -0
- package/dist/cli/version.js +87 -0
- package/dist/cli/watch-msg.js +5 -2
- package/dist/config-store.js +48 -11
- package/dist/core/channel-loader.js +84 -82
- package/dist/core/command-handler.js +754 -172
- package/dist/core/daemon-file-cache.js +216 -0
- package/dist/core/evolagent-registry.js +4 -0
- package/dist/core/evolagent.js +28 -23
- package/dist/core/interaction-router.js +8 -0
- package/dist/core/message/command-handler-agent-control.js +215 -0
- package/dist/core/message/create-status.js +67 -0
- package/dist/core/message/im-renderer.js +35 -13
- package/dist/core/message/items-formatter.js +9 -1
- package/dist/core/message/message-bridge.js +52 -22
- package/dist/core/message/message-log.js +1 -0
- package/dist/core/message/message-processor.js +336 -68
- package/dist/core/message/message-queue.js +15 -8
- package/dist/core/message/pending-hints.js +232 -0
- package/dist/core/message/response-depth.js +56 -0
- package/dist/core/model/model-catalog.js +1 -1
- package/dist/core/model/model-scope.js +40 -7
- package/dist/core/permission.js +9 -12
- package/dist/core/relation/peer-identity.js +16 -1
- package/dist/core/session/adapters/claude-session-file-adapter.js +48 -5
- package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
- package/dist/core/session/session-manager.js +27 -13
- 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 +314 -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/{agents → eck}/kit-renderer.js +5 -1
- package/dist/{agents → eck}/manifest-engine.js +127 -35
- package/dist/{agents → eck}/message-renderer.js +26 -1
- package/dist/index.js +185 -8
- package/dist/ipc.js +22 -0
- package/dist/paths.js +7 -3
- package/dist/utils/cross-platform.js +23 -5
- package/dist/utils/ecweb-pair.js +20 -0
- package/dist/utils/stats.js +14 -0
- 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_manifest.json +12 -0
- 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/kits/templates/message-fragments/item.md +1 -1
- package/kits/templates/system-fragments/response-depth.md +16 -0
- package/package.json +4 -4
- package/dist/agents/baseagent-normalize.js +0 -19
- package/dist/core/relation/peer-key.js +0 -16
- package/dist/utils/channel-helpers.js +0 -46
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* writer.ts — 写入 usage_events 和 context_breakdown。
|
|
3
|
+
*/
|
|
4
|
+
import { getDb } from './db.js';
|
|
5
|
+
export function insertUsageEvent(evolclawHome, event) {
|
|
6
|
+
const db = getDb(evolclawHome);
|
|
7
|
+
if (!db)
|
|
8
|
+
return;
|
|
9
|
+
try {
|
|
10
|
+
// 明细 INSERT + rollup UPSERT 包进同一事务:进程在两者间崩溃也不会让 rollup 与明细漂移。
|
|
11
|
+
db.exec('BEGIN');
|
|
12
|
+
db.prepare(`
|
|
13
|
+
INSERT INTO usage_events
|
|
14
|
+
(ts, agent_aid, peer_key, peer_type, session_id, model, billing_fn,
|
|
15
|
+
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
|
|
16
|
+
cache_hit_tokens, cache_miss_tokens, image_tokens, total_context_tokens,
|
|
17
|
+
turns, duration_ms, context_window_pct)
|
|
18
|
+
VALUES
|
|
19
|
+
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
20
|
+
`).run(event.ts, event.agent_aid, event.peer_key, event.peer_type ?? null, event.session_id ?? null, event.model, event.billing_fn, event.input_tokens, event.output_tokens, event.cache_creation_tokens, event.cache_read_tokens, event.cache_hit_tokens ?? null, event.cache_miss_tokens ?? null, event.image_tokens ?? null, event.total_context_tokens ?? null, event.turns, event.duration_ms ?? null, event.context_window_pct ?? null);
|
|
21
|
+
// 写时增量:累加到日级预聚合表(grain 与 db.ts rebuildDailyRollup 一致)。
|
|
22
|
+
db.prepare(`
|
|
23
|
+
INSERT INTO usage_daily
|
|
24
|
+
(day, agent_aid, peer_key, peer_type, session_id, model, billing_fn,
|
|
25
|
+
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
|
|
26
|
+
cache_hit_tokens, cache_miss_tokens, image_tokens, total_context_tokens,
|
|
27
|
+
turns, calls)
|
|
28
|
+
VALUES
|
|
29
|
+
(strftime('%Y-%m-%d', ?/1000, 'unixepoch', 'localtime'),
|
|
30
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
31
|
+
ON CONFLICT(day, agent_aid, peer_key, session_id, model, billing_fn) DO UPDATE SET
|
|
32
|
+
peer_type = excluded.peer_type,
|
|
33
|
+
input_tokens = input_tokens + excluded.input_tokens,
|
|
34
|
+
output_tokens = output_tokens + excluded.output_tokens,
|
|
35
|
+
cache_creation_tokens = cache_creation_tokens + excluded.cache_creation_tokens,
|
|
36
|
+
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
|
|
37
|
+
cache_hit_tokens = cache_hit_tokens + excluded.cache_hit_tokens,
|
|
38
|
+
cache_miss_tokens = cache_miss_tokens + excluded.cache_miss_tokens,
|
|
39
|
+
image_tokens = image_tokens + excluded.image_tokens,
|
|
40
|
+
total_context_tokens = total_context_tokens + excluded.total_context_tokens,
|
|
41
|
+
turns = turns + excluded.turns,
|
|
42
|
+
calls = calls + 1
|
|
43
|
+
`).run(event.ts, event.agent_aid, event.peer_key, event.peer_type ?? '', event.session_id ?? '', event.model, event.billing_fn, event.input_tokens, event.output_tokens, event.cache_creation_tokens, event.cache_read_tokens, event.cache_hit_tokens ?? 0, event.cache_miss_tokens ?? 0, event.image_tokens ?? 0, event.total_context_tokens ?? 0, event.turns);
|
|
44
|
+
db.exec('COMMIT');
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
// 写入失败不影响主流程
|
|
48
|
+
try {
|
|
49
|
+
db.exec('ROLLBACK');
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
import('../../utils/logger.js').then(({ logger }) => logger.warn(`[StatsWriter] insertUsageEvent failed: ${e}`));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function insertContextBreakdown(evolclawHome, bd) {
|
|
56
|
+
const db = getDb(evolclawHome);
|
|
57
|
+
if (!db)
|
|
58
|
+
return;
|
|
59
|
+
try {
|
|
60
|
+
db.prepare(`
|
|
61
|
+
INSERT INTO context_breakdown
|
|
62
|
+
(ts, agent_aid, session_id, turn_count, model, max_tokens,
|
|
63
|
+
system_prompt, system_tools, mcp_tools, custom_agents,
|
|
64
|
+
memory_files, skills, messages, free_space, total_estimated)
|
|
65
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
66
|
+
`).run(bd.ts, bd.agent_aid, bd.session_id, bd.turn_count, bd.model, bd.max_tokens, bd.system_prompt ?? null, bd.system_tools ?? null, bd.mcp_tools ?? null, bd.custom_agents ?? null, bd.memory_files ?? null, bd.skills ?? null, bd.messages ?? null, bd.free_space ?? null, bd.total_estimated ?? null);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
import('../../utils/logger.js').then(({ logger }) => logger.warn(`[StatsWriter] insertContextBreakdown failed: ${e}`));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function insertMessageEvent(evolclawHome, event) {
|
|
73
|
+
const db = getDb(evolclawHome);
|
|
74
|
+
if (!db)
|
|
75
|
+
return;
|
|
76
|
+
try {
|
|
77
|
+
db.prepare(`
|
|
78
|
+
INSERT INTO message_events
|
|
79
|
+
(ts, agent_aid, peer_key, direction, msg_type, bytes, encrypted, chatmode)
|
|
80
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
81
|
+
`).run(event.ts, event.agent_aid, event.peer_key, event.direction, event.msg_type ?? null, event.bytes, event.encrypted ? 1 : 0, event.chatmode ?? null);
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
import('../../utils/logger.js').then(({ logger }) => logger.warn(`[StatsWriter] insertMessageEvent failed: ${e}`));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** 批量写入大模型调用明细。单事务;失败不影响主流程。 */
|
|
88
|
+
export function insertModelCalls(evolclawHome, rows) {
|
|
89
|
+
if (!rows.length)
|
|
90
|
+
return;
|
|
91
|
+
const db = getDb(evolclawHome);
|
|
92
|
+
if (!db)
|
|
93
|
+
return;
|
|
94
|
+
try {
|
|
95
|
+
const stmt = db.prepare(`
|
|
96
|
+
INSERT INTO model_calls
|
|
97
|
+
(ts, task_id, session_id, agent_session_id, agent_aid, peer_key, call_index, model,
|
|
98
|
+
request_id, message_id, input_tokens, output_tokens, cache_creation_tokens,
|
|
99
|
+
cache_read_tokens, degraded)
|
|
100
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
101
|
+
`);
|
|
102
|
+
db.exec('BEGIN');
|
|
103
|
+
for (const r of rows) {
|
|
104
|
+
stmt.run(r.ts, r.task_id, r.session_id ?? null, r.agent_session_id ?? null, r.agent_aid, r.peer_key, r.call_index, r.model, r.request_id ?? null, r.message_id ?? null, r.input_tokens, r.output_tokens, r.cache_creation_tokens, r.cache_read_tokens, r.degraded);
|
|
105
|
+
}
|
|
106
|
+
db.exec('COMMIT');
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
try {
|
|
110
|
+
db.exec('ROLLBACK');
|
|
111
|
+
}
|
|
112
|
+
catch { }
|
|
113
|
+
import('../../utils/logger.js').then(({ logger }) => logger.warn(`[StatsWriter] insertModelCalls failed: ${e}`));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -90,6 +90,30 @@ export class TriggerManager {
|
|
|
90
90
|
getById(id) {
|
|
91
91
|
return this.triggers.get(id);
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* 按 id 从磁盘重读单条 trigger(不走内存缓存)。
|
|
95
|
+
* 用于触发时刻取最新数据,消除内存/磁盘漂移。
|
|
96
|
+
* 读盘失败或不存在时返回 undefined(调用方回退到内存副本)。
|
|
97
|
+
*/
|
|
98
|
+
getByIdFresh(id) {
|
|
99
|
+
if (!fs.existsSync(this.triggersPath))
|
|
100
|
+
return undefined;
|
|
101
|
+
try {
|
|
102
|
+
const raw = fs.readFileSync(this.triggersPath, 'utf8');
|
|
103
|
+
const data = JSON.parse(raw);
|
|
104
|
+
return data.triggers?.[id];
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 从磁盘重新加载到内存 Map(外部编辑 triggers.json 后调用)。
|
|
112
|
+
* 等价于 load(),语义上强调"丢弃内存副本、以磁盘为准"。
|
|
113
|
+
*/
|
|
114
|
+
reloadFromDisk() {
|
|
115
|
+
return this.load();
|
|
116
|
+
}
|
|
93
117
|
getByName(name) {
|
|
94
118
|
for (const t of this.triggers.values()) {
|
|
95
119
|
if (t.name === name)
|
|
@@ -154,6 +178,16 @@ export class TriggerManager {
|
|
|
154
178
|
t.updatedAt = Date.now();
|
|
155
179
|
this.save();
|
|
156
180
|
}
|
|
181
|
+
updateResult(id, outcome) {
|
|
182
|
+
const t = this.triggers.get(id);
|
|
183
|
+
if (!t)
|
|
184
|
+
return;
|
|
185
|
+
t.lastResult = outcome;
|
|
186
|
+
if (outcome === 'failed')
|
|
187
|
+
t.failCount = (t.failCount ?? 0) + 1;
|
|
188
|
+
t.updatedAt = Date.now();
|
|
189
|
+
this.save();
|
|
190
|
+
}
|
|
157
191
|
updateNextFireAt(id, nextFireAt) {
|
|
158
192
|
const t = this.triggers.get(id);
|
|
159
193
|
if (!t)
|
|
@@ -105,8 +105,11 @@ export function parseTriggerUpdate(args) {
|
|
|
105
105
|
}
|
|
106
106
|
else if (hasCron) {
|
|
107
107
|
const raw = flags.get('cron');
|
|
108
|
-
if (!validateCronExpr(raw))
|
|
109
|
-
|
|
108
|
+
if (!validateCronExpr(raw)) {
|
|
109
|
+
const truncated = /^[\d*/,-]+$/.test(raw) && /--cron\s+\S+\s+[*\d]/.test(args);
|
|
110
|
+
const hint = truncated ? '(看起来 cron 表达式被空格截断了,请用引号包裹,如 --cron \'*/15 * * * *\')' : '(需 5 段:分 时 日 月 周,如 */15 * * * *)';
|
|
111
|
+
return { ok: false, error: `无效的 cron 表达式:"${raw}" ${hint}` };
|
|
112
|
+
}
|
|
110
113
|
result.scheduleType = 'cron';
|
|
111
114
|
result.scheduleValue = raw;
|
|
112
115
|
}
|
|
@@ -191,7 +194,10 @@ export function parseTriggerSet(args) {
|
|
|
191
194
|
else {
|
|
192
195
|
const raw = flags.get('cron');
|
|
193
196
|
if (!validateCronExpr(raw)) {
|
|
194
|
-
|
|
197
|
+
// Detect likely space-truncation: raw looks like one cron segment and args contains space-separated * or digits after it
|
|
198
|
+
const truncated = /^[\d*/,-]+$/.test(raw) && /--cron\s+\S+\s+[*\d]/.test(args);
|
|
199
|
+
const hint = truncated ? '(看起来 cron 表达式被空格截断了,请用引号包裹,如 --cron \'*/15 * * * *\')' : '(需 5 段:分 时 日 月 周,如 */15 * * * *)';
|
|
200
|
+
return { ok: false, error: `无效的 cron 表达式:"${raw}" ${hint}` };
|
|
195
201
|
}
|
|
196
202
|
scheduleType = 'cron';
|
|
197
203
|
scheduleValue = raw;
|
|
@@ -130,7 +130,7 @@ export class TriggerScheduler {
|
|
|
130
130
|
register(trigger) {
|
|
131
131
|
this.heap.push(trigger);
|
|
132
132
|
this.resetTimer();
|
|
133
|
-
this.eventBus.publish({ type: 'trigger:registered', triggerId: trigger.id, name: trigger.name, peerId: trigger.createdByPeerId });
|
|
133
|
+
this.eventBus.publish({ type: 'trigger:registered', triggerId: trigger.id, name: trigger.name, peerId: trigger.createdByPeerId, targetChannel: trigger.targetChannel, targetChannelId: trigger.targetChannelId, scheduleType: trigger.scheduleType, scheduleValue: trigger.scheduleValue });
|
|
134
134
|
}
|
|
135
135
|
cancel(id) {
|
|
136
136
|
this.heap.remove(id);
|
|
@@ -141,7 +141,7 @@ export class TriggerScheduler {
|
|
|
141
141
|
this.heap.remove(trigger.id);
|
|
142
142
|
this.heap.push(trigger);
|
|
143
143
|
this.resetTimer();
|
|
144
|
-
this.eventBus.publish({ type: 'trigger:updated', triggerId: trigger.id, name: trigger.name, peerId: trigger.createdByPeerId });
|
|
144
|
+
this.eventBus.publish({ type: 'trigger:updated', triggerId: trigger.id, name: trigger.name, peerId: trigger.createdByPeerId, scheduleType: trigger.scheduleType, scheduleValue: trigger.scheduleValue });
|
|
145
145
|
}
|
|
146
146
|
stop() {
|
|
147
147
|
if (this.timer) {
|
|
@@ -172,7 +172,7 @@ export class TriggerScheduler {
|
|
|
172
172
|
if (trigger.scheduleType === 'cron' && this.inflightCron.has(trigger.id)) {
|
|
173
173
|
// Previous run still in flight — skip this occurrence, reschedule the next one.
|
|
174
174
|
logger.warn(`[${this.aid}] Cron trigger ${trigger.name} still running, skipping`);
|
|
175
|
-
this.eventBus.publish({ type: 'trigger:skipped', triggerId: trigger.id, reason: 'overlap' });
|
|
175
|
+
this.eventBus.publish({ type: 'trigger:skipped', triggerId: trigger.id, name: trigger.name, reason: 'overlap', targetChannel: trigger.targetChannel, targetChannelId: trigger.targetChannelId });
|
|
176
176
|
const next = this.nextCronFireAt(trigger.scheduleValue, Date.now());
|
|
177
177
|
this.manager.updateNextFireAt(trigger.id, next);
|
|
178
178
|
this.heap.push({ ...trigger, nextFireAt: next });
|
|
@@ -198,31 +198,34 @@ export class TriggerScheduler {
|
|
|
198
198
|
return next;
|
|
199
199
|
}
|
|
200
200
|
fireTrigger(trigger, now) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
// 触发时刻以磁盘为真相源:heap 里的副本可能因外部编辑而过期。
|
|
202
|
+
// 用 id 重读磁盘最新版,读盘失败则回退到 heap 副本。
|
|
203
|
+
const fresh = this.manager.getByIdFresh(trigger.id) ?? trigger;
|
|
204
|
+
const messageId = `trigger:${fresh.id}:${now}`;
|
|
205
|
+
const msg = this.buildSyntheticMessage(fresh, messageId);
|
|
206
|
+
logger.info(`[${this.aid}] Firing trigger: ${fresh.name} (${fresh.id})`);
|
|
204
207
|
// Update stats before moving to done so history captures the updated count
|
|
205
|
-
this.manager.updateFireStats(
|
|
206
|
-
if (
|
|
207
|
-
this.inflightCron.add(
|
|
208
|
+
this.manager.updateFireStats(fresh.id, now);
|
|
209
|
+
if (fresh.scheduleType === 'cron') {
|
|
210
|
+
this.inflightCron.add(fresh.id);
|
|
208
211
|
// Re-schedule next occurrence (outside the firing window — see nextCronFireAt)
|
|
209
|
-
const next = this.nextCronFireAt(
|
|
210
|
-
this.manager.updateNextFireAt(
|
|
211
|
-
this.heap.push({ ...
|
|
212
|
+
const next = this.nextCronFireAt(fresh.scheduleValue, now);
|
|
213
|
+
this.manager.updateNextFireAt(fresh.id, next);
|
|
214
|
+
this.heap.push({ ...fresh, nextFireAt: next });
|
|
212
215
|
}
|
|
213
216
|
else {
|
|
214
217
|
// delay/at: one-shot, move to done
|
|
215
|
-
this.manager.moveToDone(
|
|
218
|
+
this.manager.moveToDone(fresh.id, 'fired');
|
|
216
219
|
}
|
|
217
|
-
this.eventBus.publish({ type: 'trigger:fired', triggerId:
|
|
220
|
+
this.eventBus.publish({ type: 'trigger:fired', triggerId: fresh.id, name: fresh.name, fireTime: now, targetChannel: fresh.targetChannel, targetChannelId: fresh.targetChannelId, scheduleType: fresh.scheduleType });
|
|
218
221
|
if (this.fireCallback) {
|
|
219
|
-
this.fireCallback(msg,
|
|
222
|
+
this.fireCallback(msg, fresh);
|
|
220
223
|
}
|
|
221
224
|
}
|
|
222
225
|
// Called by MessageProcessor when a trigger message completes/fails/is interrupted
|
|
223
|
-
onTriggerComplete(triggerId,
|
|
224
|
-
// Only clear inflight state — message-processor already published the relevant events
|
|
226
|
+
onTriggerComplete(triggerId, outcome) {
|
|
225
227
|
this.inflightCron.delete(triggerId);
|
|
228
|
+
this.manager.updateResult(triggerId, outcome);
|
|
226
229
|
}
|
|
227
230
|
buildSyntheticMessage(trigger, messageId) {
|
|
228
231
|
const base = {
|
|
@@ -61,7 +61,11 @@ export function renderKitSections(ctx) {
|
|
|
61
61
|
}
|
|
62
62
|
const files = loadSectionFiles(section, ctx.vars, sessionCache);
|
|
63
63
|
diag.fileCount = files.length;
|
|
64
|
+
// 路径解析成功但读出 0 文件 → 文件/目录不存在(存在性不再单独 syscall,
|
|
65
|
+
// 由内容读取顺带得到;详见 manifest-engine.resolvePathWithDiag)。
|
|
64
66
|
if (files.length === 0) {
|
|
67
|
+
if (diag.resolveStatus === 'ok')
|
|
68
|
+
diag.resolveStatus = 'not-exist';
|
|
65
69
|
diagnostics.push(diag);
|
|
66
70
|
continue;
|
|
67
71
|
}
|
|
@@ -125,7 +129,7 @@ const PARAM_DESCRIPTIONS = {
|
|
|
125
129
|
peerName: '对端显示名',
|
|
126
130
|
peerRole: '对端角色(owner/admin/guest/anonymous)',
|
|
127
131
|
peerType: '对端类型(human/agent)',
|
|
128
|
-
sameDevice: '对端与本端同一物理设备(
|
|
132
|
+
sameDevice: '对端与本端同一物理设备(SDK 0.4.9 起明文/密文消息均可携带,具体字段以网关下发为准)',
|
|
129
133
|
sameNetwork: '对端与本端在同一网络内',
|
|
130
134
|
sameEgressIp: '对端与本端共享同一出口 IP',
|
|
131
135
|
groupId: '群组 ID(群聊时)',
|
|
@@ -5,24 +5,25 @@ import fs from 'fs';
|
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { kitsDir, resolveRoot, getPackageRoot } from '../paths.js';
|
|
7
7
|
import { logger } from '../utils/logger.js';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
import { fileCache } from '../core/daemon-file-cache.js';
|
|
9
|
+
// ── Manifest loading / cache ──
|
|
10
|
+
// manifest 定义随包发布、运行期靠 reload/重启刷新 → on-reload(group 'kits')。
|
|
11
|
+
// base + eck override 合成结果以 base 文件路径为键缓存;loader 内读两个文件。
|
|
12
|
+
/** 清空所有 manifest 缓存(manifest 结构变更后调用,由 invalidateKitCache 串联)。 */
|
|
11
13
|
export function invalidateManifestCache() {
|
|
12
|
-
|
|
14
|
+
fileCache.invalidateGroup('kits');
|
|
13
15
|
}
|
|
14
16
|
/**
|
|
15
17
|
* 加载并合并 manifest。基础文件在 $KITS/<filename>,
|
|
16
18
|
* 覆盖文件在 $EVOLCLAW_HOME/eck/<filename>(可选)。结果按 order 升序缓存。
|
|
17
19
|
*/
|
|
18
20
|
export function loadManifest(filename) {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return sections;
|
|
21
|
+
const kitsPath = path.join(kitsDir(), filename);
|
|
22
|
+
return fileCache.get(kitsPath, () => {
|
|
23
|
+
const sections = loadAndMergeManifest(filename);
|
|
24
|
+
logger.info(`[ManifestEngine] Loaded ${filename}: ${sections.length} sections`);
|
|
25
|
+
return sections;
|
|
26
|
+
}, { policy: 'on-reload', group: 'kits' });
|
|
26
27
|
}
|
|
27
28
|
function loadAndMergeManifest(filename) {
|
|
28
29
|
const kitsPath = path.join(kitsDir(), filename);
|
|
@@ -58,10 +59,29 @@ function loadAndMergeManifest(filename) {
|
|
|
58
59
|
function sortSections(sections) {
|
|
59
60
|
return sections.slice().sort((a, b) => a.order - b.order);
|
|
60
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* 从 manifest 抽出每个 modeType 标了 isDefault 的 modeName。
|
|
64
|
+
* 供 message-renderer 在 agent config.render 未配某类型时回退。
|
|
65
|
+
* 同一 modeType 有多个 isDefault 时取 order 最小(已排序)的第一个。
|
|
66
|
+
*/
|
|
67
|
+
export function defaultModeNames(sections) {
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const s of sections) {
|
|
70
|
+
if (s.modeType && s.isDefault && s.modeName && out[s.modeType] === undefined) {
|
|
71
|
+
out[s.modeType] = s.modeName;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
61
76
|
// ── When condition evaluation ──
|
|
62
77
|
export function evaluateWhen(when, vars) {
|
|
63
78
|
if (when === 'always')
|
|
64
79
|
return true;
|
|
80
|
+
// 复合条件优先:and / or 递归求值。
|
|
81
|
+
if (when.and)
|
|
82
|
+
return when.and.every(c => evaluateWhen(c, vars));
|
|
83
|
+
if (when.or)
|
|
84
|
+
return when.or.some(c => evaluateWhen(c, vars));
|
|
65
85
|
if (when.var !== undefined) {
|
|
66
86
|
const val = vars[when.var];
|
|
67
87
|
if (when.eq !== undefined) {
|
|
@@ -86,9 +106,72 @@ export function evaluateWhen(when, vars) {
|
|
|
86
106
|
return true;
|
|
87
107
|
}
|
|
88
108
|
export function isTruthy(val) {
|
|
109
|
+
if (Array.isArray(val))
|
|
110
|
+
return val.length > 0; // 空数组视为假,使 {{?arr}} / {{#each}} 落空
|
|
89
111
|
return val !== undefined && val !== null && val !== false && val !== '' && val !== 0;
|
|
90
112
|
}
|
|
91
113
|
// ── Template rendering ──
|
|
114
|
+
/**
|
|
115
|
+
* 展开 {{#each KEY}}BODY{{/each}} 循环块(在条件/变量替换之前跑)。
|
|
116
|
+
* - vars[KEY] 为非空数组才展开;每个元素构造子作用域:
|
|
117
|
+
* 对象元素 → { ...vars, ...el }(字段可用 {{field}} 访问)
|
|
118
|
+
* 标量元素 → { ...vars, '.': el }({{.}} 访问当前元素)
|
|
119
|
+
* 另注入 {{@index}}(0 基序号)。
|
|
120
|
+
* - body 经完整 renderTemplate 递归渲染,天然支持嵌套 each / 条件。
|
|
121
|
+
* - 非数组或空数组 → 整块渲染为空串。
|
|
122
|
+
* 用深度扫描定位**最外层** each 块(正则无法平衡嵌套),从外向内展开。
|
|
123
|
+
*/
|
|
124
|
+
function resolveEach(template, vars, stripBlankLines) {
|
|
125
|
+
const OPEN = /\{\{#each\s+([A-Za-z_]\w*)\}\}/g;
|
|
126
|
+
let result = '';
|
|
127
|
+
let cursor = 0;
|
|
128
|
+
OPEN.lastIndex = 0;
|
|
129
|
+
let m;
|
|
130
|
+
while ((m = OPEN.exec(template)) !== null) {
|
|
131
|
+
const blockStart = m.index;
|
|
132
|
+
const key = m[1];
|
|
133
|
+
const bodyStart = OPEN.lastIndex;
|
|
134
|
+
// 从 bodyStart 起按深度找配对的 {{/each}}
|
|
135
|
+
const TOKEN = /\{\{#each\s+[A-Za-z_]\w*\}\}|\{\{\/each\}\}/g;
|
|
136
|
+
TOKEN.lastIndex = bodyStart;
|
|
137
|
+
let depth = 1;
|
|
138
|
+
let bodyEnd = -1;
|
|
139
|
+
let blockEnd = -1;
|
|
140
|
+
let t;
|
|
141
|
+
while ((t = TOKEN.exec(template)) !== null) {
|
|
142
|
+
if (t[0].startsWith('{{#each'))
|
|
143
|
+
depth++;
|
|
144
|
+
else {
|
|
145
|
+
depth--;
|
|
146
|
+
if (depth === 0) {
|
|
147
|
+
bodyEnd = t.index;
|
|
148
|
+
blockEnd = TOKEN.lastIndex;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (bodyEnd === -1)
|
|
154
|
+
break; // 无配对,剩余原样输出
|
|
155
|
+
// 输出块前的原文
|
|
156
|
+
result += template.slice(cursor, blockStart);
|
|
157
|
+
const body = template.slice(bodyStart, bodyEnd);
|
|
158
|
+
const arr = vars[key];
|
|
159
|
+
if (Array.isArray(arr)) {
|
|
160
|
+
for (let i = 0; i < arr.length; i++) {
|
|
161
|
+
const el = arr[i];
|
|
162
|
+
const scope = (el && typeof el === 'object' && !Array.isArray(el))
|
|
163
|
+
? { ...vars, ...el, '@index': i }
|
|
164
|
+
: { ...vars, '.': el, '@index': i };
|
|
165
|
+
result += renderTemplate(body, scope, stripBlankLines);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// 数组以外(含 undefined / 非数组)→ 整块跳过(不输出)
|
|
169
|
+
cursor = blockEnd;
|
|
170
|
+
OPEN.lastIndex = blockEnd;
|
|
171
|
+
}
|
|
172
|
+
result += template.slice(cursor);
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
92
175
|
function resolveConditions(template, vars) {
|
|
93
176
|
// 只匹配**最内层** {{?...}}...{{/}} 块(逐字符负向前瞻排除嵌套),do/while 由内向外消解。
|
|
94
177
|
const inner = /\{\{\?(\w+)(?:(!=|=)([^}]*))?\}\}((?:(?!\{\{\?)[^])*?)\{\{\/\}\}/;
|
|
@@ -111,11 +194,15 @@ function resolveConditions(template, vars) {
|
|
|
111
194
|
* 紧凑);false 时保留空行(消息正文用,正文多段结构不能被压扁)。
|
|
112
195
|
*/
|
|
113
196
|
export function renderTemplate(template, vars, stripBlankLines = true) {
|
|
114
|
-
let result =
|
|
115
|
-
result = result
|
|
197
|
+
let result = resolveEach(template, vars, stripBlankLines);
|
|
198
|
+
result = resolveConditions(result, vars);
|
|
199
|
+
// 变量替换:支持普通名、当前元素 {{.}}、循环序号 {{@index}}。
|
|
200
|
+
result = result.replace(/\{\{(\.|@index|\w+)\}\}/g, (_match, key) => {
|
|
116
201
|
const val = vars[key];
|
|
117
|
-
if (!isTruthy(val))
|
|
118
|
-
return '';
|
|
202
|
+
if (!isTruthy(val) && val !== 0)
|
|
203
|
+
return ''; // 0 是有效序号/值,保留
|
|
204
|
+
if (val === 0)
|
|
205
|
+
return '0';
|
|
119
206
|
return String(val);
|
|
120
207
|
});
|
|
121
208
|
if (stripBlankLines)
|
|
@@ -146,11 +233,10 @@ export function resolvePathWithDiag(rawPath, vars) {
|
|
|
146
233
|
if (unresolved.length > 0) {
|
|
147
234
|
return { resolved, status: 'unresolved-vars', unresolvedTokens: unresolved };
|
|
148
235
|
}
|
|
149
|
-
// 路径规范化:模板里 ../
|
|
236
|
+
// 路径规范化:模板里 ../ 等相对片段折叠成真实路径。
|
|
237
|
+
// 不再在此 existsSync——存在性由随后经 fileCache 的内容读取顺带得到(file
|
|
238
|
+
// section 读出 null 即不存在),避免每 section 每消息一次 syscall。
|
|
150
239
|
resolved = path.normalize(resolved);
|
|
151
|
-
if (!fs.existsSync(resolved)) {
|
|
152
|
-
return { resolved, status: 'not-exist', unresolvedTokens: unresolved };
|
|
153
|
-
}
|
|
154
240
|
return { resolved, status: 'ok', unresolvedTokens: unresolved };
|
|
155
241
|
}
|
|
156
242
|
function resolvePath(rawPath, vars) {
|
|
@@ -174,29 +260,35 @@ export function loadSectionFiles(section, vars, sessionCache) {
|
|
|
174
260
|
return [];
|
|
175
261
|
}
|
|
176
262
|
function loadFileSection(filePath, vars, sessionCache) {
|
|
263
|
+
void sessionCache; // 内容跨 session 共享,改走全局 fileCache(on-reload)
|
|
177
264
|
const resolved = resolvePath(filePath, vars);
|
|
178
265
|
if (!resolved)
|
|
179
266
|
return null;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
sessionCache.set(resolved, content);
|
|
185
|
-
return [resolved, content];
|
|
186
|
-
}
|
|
187
|
-
catch {
|
|
188
|
-
return null;
|
|
189
|
-
}
|
|
267
|
+
// 内容跨 session 共享:用全局 fileCache(on-reload,reload/重启时失效),
|
|
268
|
+
// 不再按 session 重复缓存同一文件内容。
|
|
269
|
+
const content = fileCache.getText(resolved, { policy: 'on-reload', group: 'kits' });
|
|
270
|
+
return content === null ? null : [resolved, content];
|
|
190
271
|
}
|
|
191
272
|
function readDirectoryFiles(dirPath, pattern) {
|
|
192
273
|
const glob = pattern || '*.md';
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
274
|
+
// 目录列表 + 各文件内容均走 fileCache(on-reload)。目录列表以 "<dir>|<glob>"
|
|
275
|
+
// 为键缓存文件名数组;各文件内容走 fileCache.getText 共享。
|
|
276
|
+
const names = fileCache.get(`${dirPath} ${glob}`, () => {
|
|
277
|
+
try {
|
|
278
|
+
return fs.readdirSync(dirPath).filter(f => matchGlob(f, glob)).sort();
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
}, { policy: 'on-reload', group: 'kits' });
|
|
284
|
+
const out = [];
|
|
285
|
+
for (const f of names) {
|
|
286
|
+
const fp = path.join(dirPath, f);
|
|
287
|
+
const content = fileCache.getText(fp, { policy: 'on-reload', group: 'kits' });
|
|
288
|
+
if (content !== null)
|
|
289
|
+
out.push([f, content]);
|
|
199
290
|
}
|
|
291
|
+
return out;
|
|
200
292
|
}
|
|
201
293
|
function matchGlob(filename, pattern) {
|
|
202
294
|
const regex = pattern
|
|
@@ -10,7 +10,7 @@ import path from 'path';
|
|
|
10
10
|
import { randomUUID } from 'crypto';
|
|
11
11
|
import { eckDebugDir } from '../paths.js';
|
|
12
12
|
import { logger } from '../utils/logger.js';
|
|
13
|
-
import { loadManifest, evaluateWhen, renderTemplate, loadSectionFiles, } from './manifest-engine.js';
|
|
13
|
+
import { loadManifest, evaluateWhen, renderTemplate, loadSectionFiles, defaultModeNames, } from './manifest-engine.js';
|
|
14
14
|
const MESSAGE_MANIFEST_FILE = 'eck_message_manifest.json';
|
|
15
15
|
// ── time formatting (per IANA timezone) ──
|
|
16
16
|
function timeParts(epochMs, timeZone, opts) {
|
|
@@ -35,6 +35,19 @@ export function formatLocalTime(epochMs, timeZone) {
|
|
|
35
35
|
// ── single item render ──
|
|
36
36
|
function renderOneItem(item, sessionVars, sessionCache, contentSentinel) {
|
|
37
37
|
const sections = loadManifest(MESSAGE_MANIFEST_FILE);
|
|
38
|
+
// 各 modeType 当前激活的 modeName:agent config.render(经 sessionVars.renderModes 透传)
|
|
39
|
+
// 覆盖 manifest 里标 isDefault 的缺省。详见 docs/observer-insert-design.md 第二部分。
|
|
40
|
+
const defaults = defaultModeNames(sections);
|
|
41
|
+
const configured = (sessionVars.renderModes && typeof sessionVars.renderModes === 'object' && !Array.isArray(sessionVars.renderModes))
|
|
42
|
+
? sessionVars.renderModes
|
|
43
|
+
: {};
|
|
44
|
+
const activeMode = (type) => {
|
|
45
|
+
const c = configured[type];
|
|
46
|
+
if (typeof c === 'string' && c)
|
|
47
|
+
return c;
|
|
48
|
+
return defaults[type];
|
|
49
|
+
};
|
|
50
|
+
const isOwnerHint = item.kind === 'owner-hint';
|
|
38
51
|
// item-level vars: session vars overlaid with this message's own sender/timestamp.
|
|
39
52
|
const itemVars = {
|
|
40
53
|
...sessionVars,
|
|
@@ -44,7 +57,19 @@ function renderOneItem(item, sessionVars, sessionCache, contentSentinel) {
|
|
|
44
57
|
sameDevice: item.sameDevice ?? sessionVars.sameDevice,
|
|
45
58
|
sameNetwork: item.sameNetwork ?? sessionVars.sameNetwork,
|
|
46
59
|
sameEgressIp: item.sameEgressIp ?? sessionVars.sameEgressIp,
|
|
60
|
+
// 模板引擎不支持数组循环:被 @ 的 AID 预先 join 成串,空则 undefined 使 {{?mentionAids}} 落空。
|
|
61
|
+
mentionAids: (item.mentionAids && item.mentionAids.length > 0) ? item.mentionAids.join(',') : undefined,
|
|
47
62
|
now: formatLocalTime(item.timestamp ?? Date.now(), sessionVars.timezone ? String(sessionVars.timezone) : undefined),
|
|
63
|
+
// 渲染模式命中变量(manifest section 的 when 用它选中唯一模式)
|
|
64
|
+
renderMode_private: activeMode('private'),
|
|
65
|
+
renderMode_group: activeMode('group'),
|
|
66
|
+
renderMode_inject: activeMode('inject'),
|
|
67
|
+
// owner 插话提示标记 + 信封头字段
|
|
68
|
+
isOwnerHint,
|
|
69
|
+
ownerAid: isOwnerHint ? (item.ownerAid ?? undefined) : undefined,
|
|
70
|
+
injectTime: isOwnerHint
|
|
71
|
+
? formatLocalTime(item.injectTime ?? item.timestamp ?? Date.now(), sessionVars.timezone ? String(sessionVars.timezone) : undefined)
|
|
72
|
+
: undefined,
|
|
48
73
|
// content held as a per-call random sentinel, swapped back post-render.
|
|
49
74
|
// Using a UUID means no real message can collide with it.
|
|
50
75
|
content: contentSentinel,
|