evolclaw 3.1.6 → 3.1.8

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.
@@ -184,7 +184,8 @@ export class MessageQueue {
184
184
  }
185
185
  /**
186
186
  * 合并多条同 peerId 消息:
187
- * - content: \n 连接
187
+ * - content: \n 连接(兜底用,渲染层优先用 items)
188
+ * - items: 保留每条子消息(含各自 peer/timestamp),供消息渲染层逐条渲染
188
189
  * - images / mentions: 扁平合并
189
190
  * - messageId: 取最新一条的 messageId(用于 thought 锚定与中断追踪)
190
191
  * - replyContext / peerName / 其余字段: 取最后一条
@@ -193,6 +194,7 @@ export class MessageQueue {
193
194
  const contents = [];
194
195
  const allImages = [];
195
196
  const allMentions = [];
197
+ const subMessages = [];
196
198
  for (const item of items) {
197
199
  const m = item.message;
198
200
  contents.push(m.content);
@@ -200,6 +202,18 @@ export class MessageQueue {
200
202
  allImages.push(...m.images);
201
203
  if (m.mentions)
202
204
  allMentions.push(...m.mentions);
205
+ // 逐条保留发送者、时刻、图片;若该条已自带 items(罕见),展开保留细粒度
206
+ if (m.items && m.items.length > 0) {
207
+ subMessages.push(...m.items);
208
+ }
209
+ else {
210
+ subMessages.push({
211
+ peerId: m.peerId, peerName: m.peerName, peerType: m.peerType,
212
+ sameDevice: m.sameDevice, sameNetwork: m.sameNetwork, sameEgressIp: m.sameEgressIp,
213
+ content: m.content, timestamp: m.timestamp,
214
+ images: m.images && m.images.length > 0 ? m.images : undefined,
215
+ });
216
+ }
203
217
  }
204
218
  const last = items[items.length - 1];
205
219
  // 保留最新一条的 messageId(若最后一条无 ID 则回退到前面已有的 ID)
@@ -213,6 +227,7 @@ export class MessageQueue {
213
227
  const merged = {
214
228
  ...last.message,
215
229
  content: contents.join('\n'),
230
+ items: subMessages,
216
231
  images: allImages.length > 0 ? allImages : undefined,
217
232
  mentions: allMentions.length > 0 ? allMentions : undefined,
218
233
  messageId: latestMessageId,
@@ -164,15 +164,16 @@ export class TriggerScheduler {
164
164
  onFire() {
165
165
  this.timer = null;
166
166
  const now = Date.now();
167
- // Fire all triggers that are due
168
- while (this.heap.peek() && this.heap.peek().nextFireAt <= now + 50) {
167
+ // Loop guard reads a LIVE clock (not the frozen `now`): if rescheduling ever regresses
168
+ // and pushes an occurrence back inside the window, re-reading the clock each iteration
169
+ // still lets time advance past it so the loop drains and terminates.
170
+ while (this.heap.peek() && this.heap.peek().nextFireAt <= Date.now() + 50) {
169
171
  const trigger = this.heap.pop();
170
172
  if (trigger.scheduleType === 'cron' && this.inflightCron.has(trigger.id)) {
171
- // Previous run still in flight — skip
173
+ // Previous run still in flight — skip this occurrence, reschedule the next one.
172
174
  logger.warn(`[${this.aid}] Cron trigger ${trigger.name} still running, skipping`);
173
175
  this.eventBus.publish({ type: 'trigger:skipped', triggerId: trigger.id, reason: 'overlap' });
174
- // Re-schedule next cron occurrence
175
- const next = calcNextFireAt('cron', trigger.scheduleValue, now);
176
+ const next = this.nextCronFireAt(trigger.scheduleValue, Date.now());
176
177
  this.manager.updateNextFireAt(trigger.id, next);
177
178
  this.heap.push({ ...trigger, nextFireAt: next });
178
179
  continue;
@@ -181,6 +182,21 @@ export class TriggerScheduler {
181
182
  }
182
183
  this.resetTimer();
183
184
  }
185
+ /**
186
+ * Next cron occurrence strictly outside the firing window.
187
+ *
188
+ * cron-parser returns the next match at-or-after the reference instant. When the timer
189
+ * wakes a hair early — e.g. 08:59:59.999 for a `0 9 * * *` trigger — the "next" occurrence
190
+ * computes to 09:00:00.000, only ~1ms ahead and inside onFire's `<= now + 50` window. That
191
+ * occurrence gets popped and re-fired in the same pass, producing a tight loop. Recomputing
192
+ * from past the window forces the genuine next occurrence (e.g. tomorrow 09:00).
193
+ */
194
+ nextCronFireAt(scheduleValue, ref) {
195
+ let next = calcNextFireAt('cron', scheduleValue, ref);
196
+ if (next <= ref + 50)
197
+ next = calcNextFireAt('cron', scheduleValue, ref + 51);
198
+ return next;
199
+ }
184
200
  fireTrigger(trigger, now) {
185
201
  const messageId = `trigger:${trigger.id}:${now}`;
186
202
  const msg = this.buildSyntheticMessage(trigger, messageId);
@@ -189,8 +205,8 @@ export class TriggerScheduler {
189
205
  this.manager.updateFireStats(trigger.id, now);
190
206
  if (trigger.scheduleType === 'cron') {
191
207
  this.inflightCron.add(trigger.id);
192
- // Re-schedule next occurrence
193
- const next = calcNextFireAt('cron', trigger.scheduleValue, now);
208
+ // Re-schedule next occurrence (outside the firing window — see nextCronFireAt)
209
+ const next = this.nextCronFireAt(trigger.scheduleValue, now);
194
210
  this.manager.updateNextFireAt(trigger.id, next);
195
211
  this.heap.push({ ...trigger, nextFireAt: next });
196
212
  }
package/dist/index.js CHANGED
@@ -522,54 +522,6 @@ async function main() {
522
522
  logger.error(`[Trigger] Scheduler init failed for ${agent.aid}: ${err}`);
523
523
  }
524
524
  }
525
- // Inject primary agent's trigger scheduler as fallback (used when owning agent has no scheduler)
526
- const primaryAgentForTrigger = agentRegistry.runnableAgents()[0];
527
- if (primaryAgentForTrigger?.triggerScheduler && primaryAgentForTrigger?.triggerManager) {
528
- cmdHandler.setTriggerScheduler(primaryAgentForTrigger.triggerScheduler, primaryAgentForTrigger.triggerManager);
529
- }
530
- // Seed default __upgrade-check trigger (daily at random time 3:00~3:59)
531
- // 用户可通过 /trigger cancel __upgrade-check 永久禁用(不会再自动重建)
532
- if (!isLinkedInstall() && primaryAgentForTrigger?.triggerManager && primaryAgentForTrigger?.triggerScheduler) {
533
- const mgr = primaryAgentForTrigger.triggerManager;
534
- const sched = primaryAgentForTrigger.triggerScheduler;
535
- const UPGRADE_TRIGGER_NAME = '__upgrade-check';
536
- if (!mgr.getByName(UPGRADE_TRIGGER_NAME)) {
537
- // Check history: if user cancelled it before, respect that decision
538
- const { history } = mgr.listAll();
539
- const wasCancelled = history.some(h => h.name === UPGRADE_TRIGGER_NAME && h.doneReason === 'cancelled');
540
- if (!wasCancelled) {
541
- // Random minute in 3:00~3:59 to avoid all instances hitting registry simultaneously
542
- const randomMinute = Math.floor(Math.random() * 60);
543
- const cronExpr = `${randomMinute} 3 * * *`;
544
- // Use first channel instance as target (command doesn't need real channelId)
545
- const firstChannel = channelInstances[0]?.adapter?.channelName || 'system';
546
- const trigger = {
547
- id: crypto.randomUUID(),
548
- name: UPGRADE_TRIGGER_NAME,
549
- scheduleType: 'cron',
550
- scheduleValue: cronExpr,
551
- nextFireAt: calcNextFireAt('cron', cronExpr),
552
- targetChannel: firstChannel,
553
- targetChannelId: '__system__',
554
- targetSessionStrategy: 'latest',
555
- prompt: '检查 evolclaw 是否有新版本可用。执行 `npm view evolclaw version` 获取最新版本,与当前版本(执行 `evolclaw --version`)对比。如果有新版本,执行 /restart 进行升级。如果已是最新版本,无需任何操作。',
556
- createdByPeerId: '__system__',
557
- createdByChannel: '__system__',
558
- fireCount: 0,
559
- createdAt: Date.now(),
560
- updatedAt: Date.now(),
561
- };
562
- try {
563
- mgr.register(trigger);
564
- sched.register(trigger);
565
- logger.info(`[Trigger] Seeded default trigger: ${UPGRADE_TRIGGER_NAME} (cron ${cronExpr})`);
566
- }
567
- catch (e) {
568
- logger.warn(`[Trigger] Failed to seed ${UPGRADE_TRIGGER_NAME}: ${e}`);
569
- }
570
- }
571
- }
572
- }
573
525
  // 默认策略
574
526
  const defaultPolicy = {
575
527
  canSwitchProject: (chatType, role) => chatType === 'private' ? (role === 'owner' || role === 'admin') : role === 'owner',
@@ -641,6 +593,54 @@ async function main() {
641
593
  for (const inst of channelInstances) {
642
594
  registerChannelInstance(inst);
643
595
  }
596
+ // Inject primary agent's trigger scheduler after all channels are registered so
597
+ // channelTypeMap is fully populated when setTriggerScheduler backfills old triggers.
598
+ // Seed __upgrade-check here too — needs channelInstances[0].channelType to be resolved.
599
+ const primaryAgentForTrigger = agentRegistry.runnableAgents()[0];
600
+ if (primaryAgentForTrigger?.triggerScheduler && primaryAgentForTrigger?.triggerManager) {
601
+ cmdHandler.setTriggerScheduler(primaryAgentForTrigger.triggerScheduler, primaryAgentForTrigger.triggerManager);
602
+ }
603
+ // Seed default __upgrade-check trigger (daily at random time 3:00~3:59)
604
+ // 用户可通过 /trigger cancel __upgrade-check 永久禁用(不会再自动重建)
605
+ if (!isLinkedInstall() && primaryAgentForTrigger?.triggerManager && primaryAgentForTrigger?.triggerScheduler) {
606
+ const mgr = primaryAgentForTrigger.triggerManager;
607
+ const sched = primaryAgentForTrigger.triggerScheduler;
608
+ const UPGRADE_TRIGGER_NAME = '__upgrade-check';
609
+ if (!mgr.getByName(UPGRADE_TRIGGER_NAME)) {
610
+ const { history } = mgr.listAll();
611
+ const wasCancelled = history.some(h => h.name === UPGRADE_TRIGGER_NAME && h.doneReason === 'cancelled');
612
+ if (!wasCancelled) {
613
+ const randomMinute = Math.floor(Math.random() * 60);
614
+ const cronExpr = `${randomMinute} 3 * * *`;
615
+ const firstChannelInst = channelInstances[0];
616
+ const firstChannel = firstChannelInst?.adapter?.channelName || 'system';
617
+ const trigger = {
618
+ id: crypto.randomUUID(),
619
+ name: UPGRADE_TRIGGER_NAME,
620
+ scheduleType: 'cron',
621
+ scheduleValue: cronExpr,
622
+ nextFireAt: calcNextFireAt('cron', cronExpr),
623
+ targetChannel: firstChannel,
624
+ targetChannelId: '__system__',
625
+ targetSessionStrategy: 'latest',
626
+ prompt: '检查 evolclaw 是否有新版本可用。执行 `npm view evolclaw version` 获取最新版本,与当前版本(执行 `evolclaw --version`)对比。如果有新版本,执行 /restart 进行升级。如果已是最新版本,无需任何操作。',
627
+ createdByPeerId: '__system__',
628
+ createdByChannel: '__system__',
629
+ fireCount: 0,
630
+ createdAt: Date.now(),
631
+ updatedAt: Date.now(),
632
+ };
633
+ try {
634
+ mgr.register(trigger);
635
+ sched.register(trigger);
636
+ logger.info(`[Trigger] Seeded default trigger: ${UPGRADE_TRIGGER_NAME} (cron ${cronExpr})`);
637
+ }
638
+ catch (e) {
639
+ logger.warn(`[Trigger] Failed to seed ${UPGRADE_TRIGGER_NAME}: ${e}`);
640
+ }
641
+ }
642
+ }
643
+ }
644
644
  // Bind adapters to their owning agents and mark running
645
645
  for (const inst of channelInstances) {
646
646
  const agent = agentRegistry.resolveByChannel(inst.adapter.channelKey);
@@ -67,6 +67,44 @@ manifest 是 `{ "$schema_version": 1, "sections": [...] }`。每个 section:
67
67
 
68
68
  合并后统一按 `order` 升序排序。改某段行为(如关掉某层、调顺序、换条件)优先用覆盖文件,不动基础 manifest。
69
69
 
70
+ ### 覆盖示例
71
+
72
+ 禁用某段:
73
+
74
+ ```json
75
+ { "sections": [ { "id": "venue-client", "enabled": false } ] }
76
+ ```
77
+
78
+ 改加载条件(仅 owner/admin 才载入对端档案):
79
+
80
+ ```json
81
+ { "sections": [ { "id": "peer-profile", "when": { "var": "peerRole", "in": ["owner", "admin"] } } ] }
82
+ ```
83
+
84
+ 新增自定义段:
85
+
86
+ ```json
87
+ {
88
+ "sections": [
89
+ {
90
+ "id": "my-custom-context",
91
+ "type": "file",
92
+ "file": "$AGENT_DIR/custom/my-rules.md",
93
+ "order": 25,
94
+ "needsInjection": false,
95
+ "when": "always",
96
+ "description": "我的自定义规则"
97
+ }
98
+ ]
99
+ }
100
+ ```
101
+
102
+ 完全替换(忽略基础 manifest):
103
+
104
+ ```json
105
+ { "$schema_version": 1, "mode": "replace", "sections": [ ... ] }
106
+ ```
107
+
70
108
  ## 路径与模板渲染
71
109
 
72
110
  ### 路径占位符(所有 section 的 file/path)
@@ -74,6 +112,23 @@ manifest 是 `{ "$schema_version": 1, "sections": [...] }`。每个 section:
74
112
  - `$NAME`(大写)→ 从 vars 取真值,如 `$KITS_DOCS` → 包内文档目录。
75
113
  - `{{key}}` → 从 vars 取真值,如 `{{chatType}}` → `private`,`{{peerKey}}` → `aun#alice.aid.pub`。
76
114
 
115
+ manifest 中常用路径变量的展开值:
116
+
117
+ | 变量 | 展开为 |
118
+ |------|--------|
119
+ | `$PACKAGE_ROOT` | evolclaw 包根 |
120
+ | `$EVOLCLAW_HOME` | 用户数据根(默认 `~/.evolclaw`) |
121
+ | `$KITS` | `$PACKAGE_ROOT/kits` |
122
+ | `$KITS_RULES` | `$KITS/rules` |
123
+ | `$KITS_DOCS` | `$KITS/docs` |
124
+ | `$KITS_TEMPLATES` | `$KITS/templates` |
125
+ | `$KITS_FRAGMENTS` | `$KITS_TEMPLATES/system-fragments` |
126
+ | `$ECK` | `$EVOLCLAW_HOME/eck` |
127
+ | `$AGENT_DIR` | `$EVOLCLAW_HOME/agents/<selfAid>` |
128
+ | `$PERSONAL_DIR` | `$AGENT_DIR/personal` |
129
+ | `$RELATIONS_DIR` | `$AGENT_DIR/relations` |
130
+ | `$VENUES_DIR` | `$AGENT_DIR/venues` |
131
+
77
132
  任一占位符解析为空 → 该 section 视为"未解析",跳过(调试输出标 `unresolved-vars`)。文件不存在 → 标 `not-exist`,也跳过。这是**正常机制**:很多 section 靠"路径解析不出来"自然落选(如 coding 场景没有 `$PERSONAL_DIR`)。
78
133
 
79
134
  ### 模板渲染(仅 needsInjection:true 的文件)
@@ -118,6 +173,16 @@ vars 由 evolclaw 在 `message-processor.ts` 按当前会话构造。分两类
118
173
 
119
174
  coding 场景(无 channel/无身份)下,`chatType`、`channel`、`selfAid`、`peer*` 等均为空——这正是身份/关系/环境/渠道层落选的原因。
120
175
 
176
+ ## 三种加载方式
177
+
178
+ | 加载方式 | 决策者 | 驱动方式 | 例子 |
179
+ |---------|-------|---------|------|
180
+ | **全量加载** | manifest | `when: "always"` | rules/ 核心规则、session 参数 |
181
+ | **按条件自动加载** | manifest | `when` 条件 + 路径存在性 | 身份层(chatType 非空时)、对端档案(peerKey 非空时) |
182
+ | **按需加载** | agent 自主 | agent 在对话中主动 Read 文件 | 查阅 `$KITS_DOCS/` 下的详细参考文档 |
183
+
184
+ 前两种由 manifest 控制,第三种由 agent 根据各层文档中的"按需加载指引"自主决定。
185
+
121
186
  ## 默认 manifest 的段(按 order)
122
187
 
123
188
  | order | id | 类型 | 加载条件(when) | inject |
@@ -140,6 +205,27 @@ coding 场景(无 channel/无身份)下,`chatType`、`channel`、`selfAid`
140
205
 
141
206
  > 注意:`session`(60) 是 `always`,`baseagent`(70) 只看 `baseAgent` 是否注入——这两段**与 chatType 无关**,coding 场景也会加载。所谓"coding 仅 rules"是近似说法:精确地说 coding 场景命中的是 rules + session + baseagent(其余因 chatType/channel 为空而落选)。
142
207
 
208
+ ## 环境层文档目录结构
209
+
210
+ venue-* 段从两棵目录树取文件——随包发布的通用文档(只读)和 agent 私有的具体环境文档(按需创建):
211
+
212
+ ```
213
+ $KITS_DOCS/venues/ 通用环境文档(随包发布,只读)
214
+ ├── private.md 单聊场景通用指引
215
+ ├── group.md 群聊场景通用指引
216
+ ├── aun-private.md AUN 单聊特有
217
+ ├── aun-group.md AUN 群聊特有
218
+ ├── feishu-private.md 飞书单聊特有
219
+ ├── feishu-group.md 飞书群聊特有
220
+ ├── client-desktop.md 桌面端环境
221
+ ├── client-mobile.md 移动端环境
222
+ └── ...
223
+
224
+ $AGENT_DIR/venues/ agent 私有环境文档(按需创建)
225
+ └── <channel>#<urlEncode(groupId)>/
226
+ └── profile.md 具体群的特别内容
227
+ ```
228
+
143
229
  ## 输出结构
144
230
 
145
231
  所有命中 section 的文件内容拼成一个块,注入 system prompt:
@@ -0,0 +1,234 @@
1
+ # EvolClaw 提示词装载全景(Prompt Loading Architecture)
2
+
3
+ > 文档范围:消息从渠道到 base agent 的完整提示词装载流程,涵盖系统提示词渲染层与消息渲染层两套机制。
4
+ > 最后更新:2026-06-04
5
+
6
+ ---
7
+
8
+ ## 总体数据流
9
+
10
+ ```
11
+ 收到一条消息
12
+
13
+
14
+ ┌──────────────────────────────────────────────────────────┐
15
+ │ MessageBridge │
16
+ │ • 消息预处理(去重/拦截/chatType 填充) │
17
+ │ • messagePrefix 硬编码已移除(归消息渲染层) │
18
+ │ • 构造 Message(含 items=undefined 此时) │
19
+ └──────────────────────┬───────────────────────────────────┘
20
+
21
+
22
+ ┌──────────────────────────────────────────────────────────┐
23
+ │ MessageQueue │
24
+ │ • 去重 / 单聊 interrupt / 群聊 FIFO │
25
+ │ • dequeueGreedy:弹出连续同 peerId 消息 │
26
+ │ • mergeItems(多条时): │
27
+ │ - content = join('\n')(兜底) │
28
+ │ - items[] = 每条 SubMessage{peer,time,images,...} │
29
+ │ - images/mentions 扁平合并(给 runQuery 的总量) │
30
+ └──────────────────────┬───────────────────────────────────┘
31
+ │ Message(可能带 items[])
32
+
33
+ ┌──────────────────────────────────────────────────────────┐
34
+ │ MessageProcessor.processMessage() │
35
+ │ │
36
+ │ ① wrapPrompt 准备(中断包装函数) │
37
+ │ effectivePrompt = wrapPrompt(message.content) ← 兜底 │
38
+ │ │
39
+ │ ② 构造 kitCtx.vars(~47 个变量,见下节) │
40
+ │ │
41
+ │ ③ 系统提示词渲染层 │
42
+ │ renderKitSections(kitCtx) │
43
+ │ effectiveSystemPrompt = persona + kitContext │
44
+ │ │
45
+ │ ④ 消息渲染层 │
46
+ │ renderMessageBody(items, kitCtx.vars, sessionId) │
47
+ │ effectivePrompt = wrapPrompt(body) ← 覆盖兜底 │
48
+ │ renderImages = result.images │
49
+ │ │
50
+ │ ⑤ agent.runQuery( │
51
+ │ effectivePrompt, │
52
+ │ renderImages ?? message.images, │
53
+ │ effectiveSystemPrompt, │
54
+ │ modelOverride │
55
+ │ ) │
56
+ └──────────────────────────────────────────────────────────┘
57
+ ```
58
+
59
+ ---
60
+
61
+ ## 两个渲染层对比
62
+
63
+ | | 系统提示词渲染层 | 消息渲染层 |
64
+ |--|--|--|
65
+ | **驱动文件** | `kits/eck_manifest.json` | `kits/eck_message_manifest.json` |
66
+ | **覆盖文件** | `$EVOLCLAW_HOME/eck/eck_manifest.json` | `$EVOLCLAW_HOME/eck/eck_message_manifest.json` |
67
+ | **模板目录** | `kits/templates/system-fragments/` | `kits/templates/message-fragments/` |
68
+ | **输出去向** | `systemPrompt.append`(每轮覆盖,不进 transcript) | `effectivePrompt`(进 transcript,成永久历史) |
69
+ | **vars 粒度** | 会话级(一次构造,整批复用) | item 级(每条叠加 peerName/now/images) |
70
+ | **模板空行** | 删除(stripBlankLines=true,紧凑) | 保留(stripBlankLines=false,消息多段) |
71
+ | **content 注入** | N/A | 哨兵末步字面量注入(防二次解析) |
72
+ | **缓存** | per-sessionId(跨消息复用文件内容) | per-renderMessageBody 调用(局部) |
73
+ | **debug 输出** | `eck-debug/context-*.md` `fragments-*.md` `manifest-*.md` `vars-*.json` | `eck-debug/msg-render-*.md` |
74
+
75
+ ---
76
+
77
+ ## manifest 共享引擎(manifest-engine.ts)
78
+
79
+ 两个渲染层共用同一套原语,由 `src/agents/manifest-engine.ts` 提供:
80
+
81
+ | 函数 | 作用 |
82
+ |------|------|
83
+ | `loadManifest(filename)` | 加载并合并 manifest,按文件名缓存 |
84
+ | `evaluateWhen(when, vars)` | 求值 section 加载条件 |
85
+ | `renderTemplate(tpl, vars, stripBlankLines)` | 条件块 + 变量替换,可选删空行 |
86
+ | `resolvePathWithDiag(rawPath, vars)` | `$VAR` / `{{key}}` 路径展开 + 诊断 |
87
+ | `loadSectionFiles(section, vars, cache)` | 按 section 类型加载文件内容 |
88
+ | `buildPathMappings(vars)` / `shortenPath` | debug 输出路径别名化 |
89
+ | `invalidateManifestCache()` | 清全部 manifest 缓存 |
90
+
91
+ ---
92
+
93
+ ## 系统提示词渲染层详解
94
+
95
+ ### 执行位置
96
+
97
+ `src/core/message/message-processor.ts` → `renderKitSections(kitCtx)` → `src/agents/kit-renderer.ts`
98
+
99
+ ### manifest 默认段(按 order)
100
+
101
+ | order | id | 加载条件 | needsInjection | 内容 |
102
+ |-------|----|----|--------|------|
103
+ | 10 | rules | always | ✗ | `$KITS_RULES/` 目录(ECK 核心规则) |
104
+ | 20 | identity-layer | chatType≠null | ✓ | 身份层 fragment |
105
+ | 21 | persona | chatType≠null | ✗ | `$PERSONAL_DIR/persona.md` |
106
+ | 22 | working-memory | chatType≠null | ✗ | `$PERSONAL_DIR/memory/working.md` |
107
+ | 30 | relation-layer | chatType∈{private,group} | ✓ | 关系层 fragment |
108
+ | 35 | peer-profile | peerKey≠null | ✗ | 对端 profile.md |
109
+ | 40 | venue-fragment | chatType≠null | ✓ | 环境层 fragment |
110
+ | 41 | venue-chattype | chatType≠null | ✗ | `venues/{{chatType}}.md` |
111
+ | 42 | venue-channel-chattype | chatType≠null | ✗ | `venues/{{channel}}-{{chatType}}.md` |
112
+ | 43 | venue-group-profile | groupId≠null | ✗ | 群 venue profile.md |
113
+ | 44 | venue-client | clientType≠null | ✗ | `venues/client-{{clientType}}.md` |
114
+ | 50 | channel-layer | channel≠null | ✓ | 渠道层 fragment |
115
+ | 55 | commands | channel≠null | ✓ | 命令集能力卡 |
116
+ | 60 | session | always | ✓ | 会话层 fragment(含 localDate/weekday) |
117
+ | 70 | baseagent | baseAgent≠null | ✓ | base agent 配置 fragment |
118
+
119
+ ### 输出结构
120
+
121
+ ```
122
+ <system-reminder>
123
+ EvolClaw Context Kit documents are shown below.
124
+
125
+ Contenu de $KITS_RULES/01-overview.md (rules — ECK 核心规则):
126
+ ...(各 section 内容,按 order)
127
+
128
+ IMPORTANT: Use this context when it affects the current interaction.
129
+ </system-reminder>
130
+ ```
131
+
132
+ ---
133
+
134
+ ## 消息渲染层详解
135
+
136
+ ### 执行位置
137
+
138
+ `src/core/message/message-processor.ts` → `renderMessageBody(items, vars, sessionId)` → `src/agents/message-renderer.ts`
139
+
140
+ ### 渲染流程
141
+
142
+ ```
143
+ for each SubMessage in items:
144
+ sentinel = '\x00ECMSG-<UUID>\x00' ← 每次调用独立,null 字节在渠道消息中不可能出现
145
+ itemVars = {
146
+ ...sessionVars,
147
+ peerId / peerName / peerType, ← 本条消息自己的发送者
148
+ now = formatLocalTime(timestamp), ← 本条消息自己的时刻
149
+ content = sentinel, ← 占位,末步换回
150
+ }
151
+ loadManifest(eck_message_manifest) → 选段 → renderTemplate(stripBlankLines=false)
152
+ rendered.split(sentinel).join(item.content) ← 字面量注入,不二次解析
153
+ 收集 item.images
154
+
155
+ join('\n\n') → { body: string, images: ImageData[] }
156
+ ```
157
+
158
+ ### 初始 manifest(一个段)
159
+
160
+ ```json
161
+ {
162
+ "id": "msg-item",
163
+ "file": "$KITS_MESSAGE_FRAGMENTS/item.md",
164
+ "when": "always",
165
+ "needsInjection": true
166
+ }
167
+ ```
168
+
169
+ ### 紧凑模板(item.md)
170
+
171
+ ```
172
+ ‹{{now}}{{?chatType=group}} · {{peerName}}{{/}}›
173
+ {{content}}
174
+ ```
175
+
176
+ - 私聊:`{{?chatType=group}}` 块收敛,只剩时间
177
+ - 群聊:时间 + 发送者名
178
+ - 改格式只动这一个文件,不动代码
179
+
180
+ ---
181
+
182
+ ## vars 时变性归属
183
+
184
+ ### 进系统提示词(每轮覆盖,缓存友好)
185
+
186
+ | 类别 | 变量 |
187
+ |------|------|
188
+ | 静态(进程级) | `PACKAGE_ROOT` `KITS*` `evolclawMode` `osInfo` `baseAgent` |
189
+ | 会话稳定 | `selfAid` `sessionId` `sessionKey` `chatType` `channel` `timezone` `tzOffset` `capabilities` `CURRENT_PROJECT` |
190
+ | 慢变(配置驱动) | `effectiveModel` `permissionMode` `peerRole` `readonly` `chatMode` `modelFallback*` |
191
+ | 新增(日期级) | `localDate`(YYYY-MM-DD)`weekday`(星期四),一天才变一次,缓存暖 ~24h |
192
+
193
+ ### 进消息渲染层(每条独立,进 transcript 永久保真)
194
+
195
+ | 变量 | 说明 |
196
+ |------|------|
197
+ | `now` | 精确到秒的本地时间(含时区偏移),每条自己的发生时刻 |
198
+ | `peerName` / `peerId` / `peerType` | 群聊每条消息的发送者,单聊复用会话值 |
199
+ | `content` | 原始消息文本(字面量注入,不参与模板解析) |
200
+ | `images` | 按条归属的图片(SubMessage.images) |
201
+
202
+ ---
203
+
204
+ ## 关键保证
205
+
206
+ | 保证 | 实现方式 |
207
+ |------|---------|
208
+ | **模板注入防护** | 哨兵 `\x00ECMSG-<UUID>\x00`,per-call 独立,null 字节在任何渠道消息中不可能出现 |
209
+ | **消息不丢失** | manifest 无产出或渲染抛异常均显式 fallback 到 `wrapPrompt(message.content)` |
210
+ | **系统提示词不累积** | `systemPrompt.append` 是 SDK 每次 `query()` 的独立参数,随调用重传覆盖,不进 transcript |
211
+ | **图片归属** | `SubMessage.images` 按条保留,`renderMessageBody` 返回按顺序收集的 images;`runQuery` 用此数组而非 flat-merge 的 `message.images` |
212
+ | **群聊发送者保真** | 每条 SubMessage 携带自己的 peerName/peerId,进 transcript 成永久历史,模型可推理"谁说的、隔了多久" |
213
+ | **空消息安全** | `hasContent` guard,空消息不进渲染层,不传 `runQuery` |
214
+
215
+ ---
216
+
217
+ ## 文件索引
218
+
219
+ | 文件 | 角色 |
220
+ |------|------|
221
+ | `src/agents/manifest-engine.ts` | 共享渲染引擎原语 |
222
+ | `src/agents/kit-renderer.ts` | 系统提示词渲染,输出 `<system-reminder>` |
223
+ | `src/agents/message-renderer.ts` | 消息渲染层,输出 `{ body, images }` |
224
+ | `src/core/message/message-processor.ts` | 装配点:构造 vars,调两层渲染,调 runQuery |
225
+ | `src/core/message/message-queue.ts` | 队列合并:mergeItems 保留 items[] 和 per-item images |
226
+ | `src/core/message/message-bridge.ts` | 消息入口:已移除 messagePrefix 硬编码 |
227
+ | `src/types.ts` | SubMessage / Message.items 类型定义 |
228
+ | `kits/eck_manifest.json` | 系统提示词 manifest |
229
+ | `kits/eck_message_manifest.json` | 消息渲染 manifest |
230
+ | `kits/templates/system-fragments/session.md` | 含 localDate/weekday |
231
+ | `kits/templates/message-fragments/item.md` | 消息渲染紧凑模板 |
232
+ | `$EVOLCLAW_HOME/eck/eck_manifest.json` | 系统提示词 manifest 用户覆盖(可选) |
233
+ | `$EVOLCLAW_HOME/eck/eck_message_manifest.json` | 消息渲染 manifest 用户覆盖(可选) |
234
+ | `$EVOLCLAW_HOME/data/eck-debug/` | 调试输出目录(保留 24h) |
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema_version": 1,
3
+ "sections": [
4
+ {
5
+ "id": "msg-item",
6
+ "type": "file",
7
+ "file": "$KITS_MESSAGE_FRAGMENTS/item.md",
8
+ "order": 10,
9
+ "needsInjection": true,
10
+ "when": "always",
11
+ "description": "单条消息渲染(逐条)"
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,2 @@
1
+ ‹{{now}}{{?chatType=group}} · {{peerName}}{{/}}{{?sameDevice}} · 📍同设备{{/}}{{?sameNetwork}} · 🌐同网络{{/}}{{?sameEgressIp}} · 🔀同出口IP{{/}}›
2
+ {{content}}
@@ -9,6 +9,9 @@ sessionKey: {{sessionKey}} # 会话路由键(channelType#urlEncode(channelId)#
9
9
  sessionName: {{sessionName}}
10
10
  {{/}}
11
11
  sessionCreatedAt: {{sessionCreatedAt}}
12
+ {{?localDate}}
13
+ localDate: {{localDate}} {{weekday}} # 当前日期与星期(每条消息的精确时刻见消息正文前缀 ‹…›)
14
+ {{/}}
12
15
  {{?timezone}}
13
16
  timezone: {{timezone}} # 时区 IANA 名:把消息/记忆里的 ISO 时间戳转成本地时间字符串时按此换算
14
17
  {{/}}
@@ -21,10 +21,10 @@ clientType: {{clientType}} # 客户端类型:desktop / web / mobile
21
21
  groupId: {{groupId}}
22
22
  {{/}}
23
23
  {{?sameDevice}}
24
- sameDevice: true # 对端与你运行在同一台设备上(E2EE 消息 proximity)
24
+ sameDevice: true # 对端与你运行在同一台设备上
25
25
  {{/}}
26
26
  {{?sameNetwork}}
27
- sameNetwork: true # 对端与你在同一网络内
27
+ sameNetwork: true # 对端与你在同一网络内(同域)
28
28
  {{/}}
29
29
  {{?sameEgressIp}}
30
30
  sameEgressIp: true # 对端与你共享同一出口 IP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolclaw",
3
- "version": "3.1.6",
3
+ "version": "3.1.8",
4
4
  "description": "Lightweight AI Agent gateway connecting Claude Agent SDK to messaging channels (Feishu, ACP) with multi-project session management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -12,6 +12,7 @@
12
12
  "dist/",
13
13
  "bin/",
14
14
  "assets/",
15
+ "!assets/wechat-group-qr.jpeg",
15
16
  "!dist/experimental/",
16
17
  "kits/",
17
18
  "!kits/.kits-version",
@@ -22,7 +23,7 @@
22
23
  ],
23
24
  "scripts": {
24
25
  "dev": "tsx watch src/index.ts",
25
- "build": "tsc && node -e \"const f='dist/cli/index.js',c=require('fs').readFileSync(f,'utf8');if(!c.startsWith('#!'))require('fs').writeFileSync(f,'#!/usr/bin/env node\\n'+c)\" && node -e \"try{require('child_process').execFileSync('chmod',['+x','dist/cli/index.js'])}catch{}\" && node -e \"require('fs').mkdirSync('dist/data',{recursive:true});require('fs').copyFileSync('src/data/error-dict.json','dist/data/error-dict.json')\"",
26
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc && node -e \"const f='dist/cli/index.js',c=require('fs').readFileSync(f,'utf8');if(!c.startsWith('#!'))require('fs').writeFileSync(f,'#!/usr/bin/env node\\n'+c)\" && node -e \"try{require('child_process').execFileSync('chmod',['+x','dist/cli/index.js'])}catch{}\" && node -e \"require('fs').mkdirSync('dist/data',{recursive:true});require('fs').copyFileSync('src/data/error-dict.json','dist/data/error-dict.json')\"",
26
27
  "start": "node dist/index.js",
27
28
  "test": "vitest run",
28
29
  "test:watch": "vitest",
Binary file
@@ -1,18 +0,0 @@
1
- /**
2
- * watch-web 调试日志 — 写入 $EVOLCLAW_HOME/logs/watch-web.log。
3
- *
4
- * cmdWatchWeb 启动时清空该文件并调用 setDebugLog 注入 writer,
5
- * 各 source / server 通过 dlog() 写调试信息,建立「运行→看日志→定位」的闭环。
6
- */
7
- let _writer = null;
8
- export function setDebugLog(writer) {
9
- _writer = writer;
10
- }
11
- export function dlog(line) {
12
- if (_writer) {
13
- try {
14
- _writer(line);
15
- }
16
- catch { /* ignore */ }
17
- }
18
- }