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.
Files changed (89) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +27 -2
  3. package/dist/agents/{resolve.js → baseagent.js} +34 -5
  4. package/dist/agents/claude-runner.js +120 -27
  5. package/dist/agents/codex-app-server-client.js +364 -0
  6. package/dist/agents/codex-runner.js +1069 -141
  7. package/dist/agents/gemini-runner.js +2 -2
  8. package/dist/agents/runner-types.js +28 -0
  9. package/dist/aun/aid/control-aid.js +67 -0
  10. package/dist/aun/aid/identity.js +20 -7
  11. package/dist/aun/aid/store.js +2 -2
  12. package/dist/aun/storage/download.js +1 -1
  13. package/dist/aun/storage/upload.js +13 -1
  14. package/dist/channels/aun.js +538 -325
  15. package/dist/channels/dingtalk.js +77 -140
  16. package/dist/channels/feishu.js +98 -151
  17. package/dist/channels/qqbot.js +75 -138
  18. package/dist/channels/wechat.js +75 -136
  19. package/dist/channels/wecom.js +75 -138
  20. package/dist/cli/agent.js +44 -13
  21. package/dist/cli/index.js +207 -46
  22. package/dist/cli/init-channel.js +38 -148
  23. package/dist/cli/init.js +192 -85
  24. package/dist/cli/model.js +1 -1
  25. package/dist/cli/stats.js +558 -0
  26. package/dist/cli/version.js +87 -0
  27. package/dist/cli/watch-msg.js +5 -2
  28. package/dist/config-store.js +48 -11
  29. package/dist/core/channel-loader.js +84 -82
  30. package/dist/core/command-handler.js +754 -172
  31. package/dist/core/daemon-file-cache.js +216 -0
  32. package/dist/core/evolagent-registry.js +4 -0
  33. package/dist/core/evolagent.js +28 -23
  34. package/dist/core/interaction-router.js +8 -0
  35. package/dist/core/message/command-handler-agent-control.js +215 -0
  36. package/dist/core/message/create-status.js +67 -0
  37. package/dist/core/message/im-renderer.js +35 -13
  38. package/dist/core/message/items-formatter.js +9 -1
  39. package/dist/core/message/message-bridge.js +52 -22
  40. package/dist/core/message/message-log.js +1 -0
  41. package/dist/core/message/message-processor.js +336 -68
  42. package/dist/core/message/message-queue.js +15 -8
  43. package/dist/core/message/pending-hints.js +232 -0
  44. package/dist/core/message/response-depth.js +56 -0
  45. package/dist/core/model/model-catalog.js +1 -1
  46. package/dist/core/model/model-scope.js +40 -7
  47. package/dist/core/permission.js +9 -12
  48. package/dist/core/relation/peer-identity.js +16 -1
  49. package/dist/core/session/adapters/claude-session-file-adapter.js +48 -5
  50. package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
  51. package/dist/core/session/session-manager.js +27 -13
  52. package/dist/core/session/session-title.js +26 -0
  53. package/dist/core/stats/billing.js +151 -0
  54. package/dist/core/stats/budget.js +93 -0
  55. package/dist/core/stats/db.js +314 -0
  56. package/dist/core/stats/eck-vars.js +84 -0
  57. package/dist/core/stats/index.js +10 -0
  58. package/dist/core/stats/normalizer.js +78 -0
  59. package/dist/core/stats/query.js +760 -0
  60. package/dist/core/stats/writer.js +115 -0
  61. package/dist/core/trigger/manager.js +34 -0
  62. package/dist/core/trigger/parser.js +9 -3
  63. package/dist/core/trigger/scheduler.js +20 -17
  64. package/dist/{agents → eck}/kit-renderer.js +5 -1
  65. package/dist/{agents → eck}/manifest-engine.js +127 -35
  66. package/dist/{agents → eck}/message-renderer.js +26 -1
  67. package/dist/index.js +185 -8
  68. package/dist/ipc.js +22 -0
  69. package/dist/paths.js +7 -3
  70. package/dist/utils/cross-platform.js +23 -5
  71. package/dist/utils/ecweb-pair.js +20 -0
  72. package/dist/utils/stats.js +14 -0
  73. package/kits/docs/evolclaw/INDEX.md +3 -1
  74. package/kits/docs/evolclaw/fs-architecture.md +1215 -0
  75. package/kits/docs/evolclaw/fs.md +131 -0
  76. package/kits/docs/evolclaw/group-fs.md +209 -0
  77. package/kits/docs/evolclaw/stats.md +70 -0
  78. package/kits/docs/venues/aun-group.md +29 -6
  79. package/kits/docs/venues/group.md +5 -4
  80. package/kits/eck_manifest.json +12 -0
  81. package/kits/eck_message_manifest.json +30 -3
  82. package/kits/rules/05-venue.md +1 -1
  83. package/kits/templates/message-fragments/inject-default.md +2 -0
  84. package/kits/templates/message-fragments/item.md +1 -1
  85. package/kits/templates/system-fragments/response-depth.md +16 -0
  86. package/package.json +4 -4
  87. package/dist/agents/baseagent-normalize.js +0 -19
  88. package/dist/core/relation/peer-key.js +0 -16
  89. 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
- return { ok: false, error: `无效的 cron 表达式:"${raw}"` };
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
- return { ok: false, error: `无效的 cron 表达式:"${raw}"` };
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
- const messageId = `trigger:${trigger.id}:${now}`;
202
- const msg = this.buildSyntheticMessage(trigger, messageId);
203
- logger.info(`[${this.aid}] Firing trigger: ${trigger.name} (${trigger.id})`);
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(trigger.id, now);
206
- if (trigger.scheduleType === 'cron') {
207
- this.inflightCron.add(trigger.id);
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(trigger.scheduleValue, now);
210
- this.manager.updateNextFireAt(trigger.id, next);
211
- this.heap.push({ ...trigger, nextFireAt: next });
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(trigger.id, 'fired');
218
+ this.manager.moveToDone(fresh.id, 'fired');
216
219
  }
217
- this.eventBus.publish({ type: 'trigger:fired', triggerId: trigger.id, name: trigger.name, fireTime: now });
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, trigger);
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, _outcome) {
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: '对端与本端同一物理设备(E2EE 消息 proximity,仅加密消息有值)',
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
- // ── Manifest loading / cache (keyed by filename) ──
9
- const _manifestCache = new Map();
10
- /** 清空所有 manifest 缓存(manifest 结构变更后调用)。 */
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
- _manifestCache.clear();
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 cached = _manifestCache.get(filename);
20
- if (cached)
21
- return cached;
22
- const sections = loadAndMergeManifest(filename);
23
- _manifestCache.set(filename, sections);
24
- logger.info(`[ManifestEngine] Loaded ${filename}: ${sections.length} sections`);
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 = resolveConditions(template, vars);
115
- result = result.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
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
- if (sessionCache.has(resolved))
181
- return [resolved, sessionCache.get(resolved)];
182
- try {
183
- const content = fs.readFileSync(resolved, 'utf-8');
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
- try {
194
- const files = fs.readdirSync(dirPath).filter(f => matchGlob(f, glob)).sort();
195
- return files.map(f => [f, fs.readFileSync(path.join(dirPath, f), 'utf-8')]);
196
- }
197
- catch {
198
- return [];
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,