evolclaw 3.1.6 → 3.1.7

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.
@@ -0,0 +1,243 @@
1
+ // 声明式 manifest 渲染引擎(共享)。
2
+ // 系统提示词渲染(kit-renderer)与消息渲染(message-renderer)共用同一套
3
+ // when 求值、模板渲染、路径解析、manifest 加载/缓存原语,避免两套实现漂移。
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { kitsDir, resolveRoot, getPackageRoot } from '../paths.js';
7
+ import { logger } from '../utils/logger.js';
8
+ // ── Manifest loading / cache (keyed by filename) ──
9
+ const _manifestCache = new Map();
10
+ /** 清空所有 manifest 缓存(manifest 结构变更后调用)。 */
11
+ export function invalidateManifestCache() {
12
+ _manifestCache.clear();
13
+ }
14
+ /**
15
+ * 加载并合并 manifest。基础文件在 $KITS/<filename>,
16
+ * 覆盖文件在 $EVOLCLAW_HOME/eck/<filename>(可选)。结果按 order 升序缓存。
17
+ */
18
+ 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;
26
+ }
27
+ function loadAndMergeManifest(filename) {
28
+ const kitsPath = path.join(kitsDir(), filename);
29
+ const eckPath = path.join(resolveRoot(), 'eck', filename);
30
+ let base;
31
+ try {
32
+ base = JSON.parse(fs.readFileSync(kitsPath, 'utf-8'));
33
+ }
34
+ catch (err) {
35
+ logger.error(`[ManifestEngine] Failed to load kits/${filename}: ${err}`);
36
+ return [];
37
+ }
38
+ if (!fs.existsSync(eckPath))
39
+ return sortSections(base.sections);
40
+ try {
41
+ const override = JSON.parse(fs.readFileSync(eckPath, 'utf-8'));
42
+ if (override.mode === 'replace')
43
+ return sortSections(override.sections);
44
+ const merged = new Map();
45
+ for (const s of base.sections)
46
+ merged.set(s.id, { ...s });
47
+ for (const s of override.sections) {
48
+ const existing = merged.get(s.id);
49
+ merged.set(s.id, existing ? { ...existing, ...s } : s);
50
+ }
51
+ return sortSections([...merged.values()]);
52
+ }
53
+ catch (err) {
54
+ logger.warn(`[ManifestEngine] Failed to load eck override for ${filename}, using kits only: ${err}`);
55
+ return sortSections(base.sections);
56
+ }
57
+ }
58
+ function sortSections(sections) {
59
+ return sections.slice().sort((a, b) => a.order - b.order);
60
+ }
61
+ // ── When condition evaluation ──
62
+ export function evaluateWhen(when, vars) {
63
+ if (when === 'always')
64
+ return true;
65
+ if (when.var !== undefined) {
66
+ const val = vars[when.var];
67
+ if (when.eq !== undefined) {
68
+ if (when.eq === null)
69
+ return val === null || val === undefined;
70
+ return val === when.eq;
71
+ }
72
+ if (when.neq !== undefined) {
73
+ if (when.neq === null)
74
+ return val !== null && val !== undefined;
75
+ return val !== when.neq;
76
+ }
77
+ if (when.in !== undefined)
78
+ return when.in.includes(val);
79
+ if (when.nin !== undefined)
80
+ return !when.nin.includes(val);
81
+ }
82
+ if (when.any)
83
+ return when.any.some(k => isTruthy(vars[k]));
84
+ if (when.all)
85
+ return when.all.every(k => isTruthy(vars[k]));
86
+ return true;
87
+ }
88
+ export function isTruthy(val) {
89
+ return val !== undefined && val !== null && val !== false && val !== '' && val !== 0;
90
+ }
91
+ // ── Template rendering ──
92
+ function resolveConditions(template, vars) {
93
+ // 只匹配**最内层** {{?...}}...{{/}} 块(逐字符负向前瞻排除嵌套),do/while 由内向外消解。
94
+ const inner = /\{\{\?(\w+)(?:(!=|=)([^}]*))?\}\}((?:(?!\{\{\?)[^])*?)\{\{\/\}\}/;
95
+ let result = template;
96
+ let prev;
97
+ do {
98
+ prev = result;
99
+ result = result.replace(inner, (_match, key, op, value, body) => {
100
+ if (op === '=')
101
+ return String(vars[key]) === value ? body : '';
102
+ if (op === '!=')
103
+ return String(vars[key]) !== value ? body : '';
104
+ return isTruthy(vars[key]) ? body : '';
105
+ });
106
+ } while (result !== prev);
107
+ return result;
108
+ }
109
+ /**
110
+ * 渲染模板:条件块 + 变量替换。stripBlankLines=true 时删除空行(系统提示词用,
111
+ * 紧凑);false 时保留空行(消息正文用,正文多段结构不能被压扁)。
112
+ */
113
+ export function renderTemplate(template, vars, stripBlankLines = true) {
114
+ let result = resolveConditions(template, vars);
115
+ result = result.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
116
+ const val = vars[key];
117
+ if (!isTruthy(val))
118
+ return '';
119
+ return String(val);
120
+ });
121
+ if (stripBlankLines)
122
+ return result.split('\n').filter(line => line.trim() !== '').join('\n');
123
+ return result;
124
+ }
125
+ export function resolvePathWithDiag(rawPath, vars) {
126
+ const unresolved = [];
127
+ let resolved = rawPath.replace(/\$([A-Z_]+)/g, (_m, name) => {
128
+ const val = vars[name];
129
+ if (val === undefined || val === null || val === false || val === '') {
130
+ unresolved.push(`$${name}`);
131
+ return '';
132
+ }
133
+ return String(val);
134
+ });
135
+ resolved = resolved.replace(/\{\{(\w+)\}\}/g, (_m, key) => {
136
+ const val = vars[key];
137
+ if (val === undefined || val === null || val === false || val === '') {
138
+ unresolved.push(`{{${key}}}`);
139
+ return '';
140
+ }
141
+ return String(val);
142
+ });
143
+ if (!resolved || resolved.includes('$') || resolved.includes('{{')) {
144
+ return { resolved: resolved || null, status: 'unresolved-vars', unresolvedTokens: unresolved };
145
+ }
146
+ if (unresolved.length > 0) {
147
+ return { resolved, status: 'unresolved-vars', unresolvedTokens: unresolved };
148
+ }
149
+ // 路径规范化:模板里 ../ 等相对片段折叠成真实路径
150
+ resolved = path.normalize(resolved);
151
+ if (!fs.existsSync(resolved)) {
152
+ return { resolved, status: 'not-exist', unresolvedTokens: unresolved };
153
+ }
154
+ return { resolved, status: 'ok', unresolvedTokens: unresolved };
155
+ }
156
+ function resolvePath(rawPath, vars) {
157
+ const r = resolvePathWithDiag(rawPath, vars);
158
+ return r.status === 'ok' ? r.resolved : null;
159
+ }
160
+ // ── Section content loading ──
161
+ /** 返回 [filePath, rawContent][];按 sessionId 缓存已读文件内容。 */
162
+ export function loadSectionFiles(section, vars, sessionCache) {
163
+ if (section.type === 'file' && section.file) {
164
+ const result = loadFileSection(section.file, vars, sessionCache);
165
+ return result ? [result] : [];
166
+ }
167
+ if (section.type === 'directory' && section.path) {
168
+ const resolved = resolvePath(section.path, vars);
169
+ if (!resolved)
170
+ return [];
171
+ return readDirectoryFiles(resolved, section.pattern)
172
+ .map(([name, content]) => [path.join(resolved, name), content]);
173
+ }
174
+ return [];
175
+ }
176
+ function loadFileSection(filePath, vars, sessionCache) {
177
+ const resolved = resolvePath(filePath, vars);
178
+ if (!resolved)
179
+ 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
+ }
190
+ }
191
+ function readDirectoryFiles(dirPath, pattern) {
192
+ 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 [];
199
+ }
200
+ }
201
+ function matchGlob(filename, pattern) {
202
+ const regex = pattern
203
+ .replace(/\./g, '\\.')
204
+ .replace(/\*/g, '.*')
205
+ .replace(/\{([^}]+)\}/g, (_, alts) => `(${alts.split(',').join('|')})`);
206
+ return new RegExp(`^${regex}$`).test(filename);
207
+ }
208
+ export function buildPathMappings(vars) {
209
+ const pkgRoot = getPackageRoot();
210
+ const evolHome = String(vars['EVOLCLAW_HOME'] || resolveRoot());
211
+ const selfAid = vars['selfAid'] ? String(vars['selfAid']) : '';
212
+ const currentProject = vars['CURRENT_PROJECT'] ? String(vars['CURRENT_PROJECT']) : '';
213
+ const mappings = [
214
+ { prefix: path.join(pkgRoot, 'kits', 'rules'), alias: '$KITS_RULES' },
215
+ { prefix: path.join(pkgRoot, 'kits', 'templates', 'system-fragments'), alias: '$KITS_FRAGMENTS' },
216
+ { prefix: path.join(pkgRoot, 'kits', 'templates', 'message-fragments'), alias: '$KITS_MESSAGE_FRAGMENTS' },
217
+ { prefix: path.join(pkgRoot, 'kits', 'templates'), alias: '$KITS_TEMPLATES' },
218
+ { prefix: path.join(pkgRoot, 'kits', 'docs'), alias: '$KITS_DOCS' },
219
+ { prefix: path.join(pkgRoot, 'kits'), alias: '$KITS' },
220
+ { prefix: pkgRoot, alias: '$PACKAGE_ROOT' },
221
+ ];
222
+ if (selfAid) {
223
+ mappings.push({ prefix: path.join(evolHome, 'agents', selfAid, 'personal'), alias: '$PERSONAL_DIR' });
224
+ mappings.push({ prefix: path.join(evolHome, 'agents', selfAid, 'relations'), alias: '$RELATIONS_DIR' });
225
+ mappings.push({ prefix: path.join(evolHome, 'agents', selfAid, 'venues'), alias: '$VENUES_DIR' });
226
+ mappings.push({ prefix: path.join(evolHome, 'agents', selfAid), alias: '$AGENT_DIR' });
227
+ }
228
+ mappings.push({ prefix: evolHome, alias: '$EVOLCLAW_HOME' });
229
+ if (currentProject)
230
+ mappings.push({ prefix: currentProject, alias: '$CURRENT_PROJECT' });
231
+ mappings.sort((a, b) => b.prefix.length - a.prefix.length);
232
+ return mappings;
233
+ }
234
+ export function shortenPath(filePath, mappings) {
235
+ const normalized = filePath.replace(/\\/g, '/');
236
+ for (const { prefix, alias } of mappings) {
237
+ const normalizedPrefix = prefix.replace(/\\/g, '/');
238
+ if (normalized.startsWith(normalizedPrefix)) {
239
+ return alias + normalized.slice(normalizedPrefix.length);
240
+ }
241
+ }
242
+ return filePath;
243
+ }
@@ -0,0 +1,112 @@
1
+ // Message rendering layer: render a batch of sub-messages one-by-one through the
2
+ // message manifest, then assemble the final body fed to the base agent. Shares the
3
+ // manifest-engine primitives with system-prompt rendering, with two key differences:
4
+ // 1. The raw message text {{content}} is injected as a LITERAL in the final step,
5
+ // never going through template parsing again (otherwise {{...}} inside a user
6
+ // message would be treated as a template -- garbled at best, injection at worst).
7
+ // 2. Blank lines are preserved (multi-paragraph message bodies must not be squashed).
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { randomUUID } from 'crypto';
11
+ import { eckDebugDir } from '../paths.js';
12
+ import { logger } from '../utils/logger.js';
13
+ import { loadManifest, evaluateWhen, renderTemplate, loadSectionFiles, } from './manifest-engine.js';
14
+ const MESSAGE_MANIFEST_FILE = 'eck_message_manifest.json';
15
+ // ── time formatting (per IANA timezone) ──
16
+ function timeParts(epochMs, timeZone, opts) {
17
+ const d = new Date(epochMs);
18
+ const p = new Intl.DateTimeFormat('en-US', { ...(timeZone ? { timeZone } : {}), ...opts }).formatToParts(d);
19
+ const out = {};
20
+ for (const part of p)
21
+ out[part.type] = part.value;
22
+ return out;
23
+ }
24
+ /** "2026-06-04 21:33:07 +08:00" */
25
+ export function formatLocalTime(epochMs, timeZone) {
26
+ const g = timeParts(epochMs, timeZone, {
27
+ year: 'numeric', month: '2-digit', day: '2-digit',
28
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
29
+ });
30
+ const hour = g.hour === '24' ? '00' : g.hour; // hour12:false may yield "24" at midnight
31
+ const off = timeParts(epochMs, timeZone, { timeZoneName: 'longOffset' }).timeZoneName || '';
32
+ const offset = off.replace(/^GMT/, '') || '+00:00';
33
+ return `${g.year}-${g.month}-${g.day} ${hour}:${g.minute}:${g.second} ${offset}`;
34
+ }
35
+ // ── single item render ──
36
+ function renderOneItem(item, sessionVars, sessionCache, contentSentinel) {
37
+ const sections = loadManifest(MESSAGE_MANIFEST_FILE);
38
+ // item-level vars: session vars overlaid with this message's own sender/timestamp.
39
+ const itemVars = {
40
+ ...sessionVars,
41
+ peerId: item.peerId ?? sessionVars.peerId,
42
+ peerName: item.peerName ?? sessionVars.peerName,
43
+ peerType: item.peerType ?? sessionVars.peerType,
44
+ now: formatLocalTime(item.timestamp ?? Date.now(), sessionVars.timezone ? String(sessionVars.timezone) : undefined),
45
+ // content held as a per-call random sentinel, swapped back post-render.
46
+ // Using a UUID means no real message can collide with it.
47
+ content: contentSentinel,
48
+ };
49
+ const out = [];
50
+ for (const section of sections) {
51
+ if (section.enabled === false)
52
+ continue;
53
+ if (!evaluateWhen(section.when, itemVars))
54
+ continue;
55
+ const files = loadSectionFiles(section, itemVars, sessionCache);
56
+ for (const [, rawContent] of files) {
57
+ const rendered = section.needsInjection
58
+ ? renderTemplate(rawContent, itemVars, /* stripBlankLines */ false)
59
+ : rawContent;
60
+ // swap the sentinel back to the real message text (literal replace, no parsing).
61
+ const withContent = rendered.split(contentSentinel).join(item.content);
62
+ if (withContent.trim())
63
+ out.push(withContent.replace(/\s+$/, ''));
64
+ }
65
+ }
66
+ // if the manifest produced nothing, fall back to raw text -- never drop a message.
67
+ return out.length > 0 ? out.join('\n') : item.content;
68
+ }
69
+ /**
70
+ * Render each sub-message then assemble the final message body.
71
+ * Also collects all images across items in order, preserving per-item attribution.
72
+ *
73
+ * One item -> single render; many (group batch / same-peer merge) -> each carries
74
+ * its own sender and timestamp.
75
+ */
76
+ export function renderMessageBody(items, sessionVars, sessionId) {
77
+ if (!items || items.length === 0)
78
+ return { body: '', images: [] };
79
+ // One random sentinel per renderMessageBody call -- impossible for user text to match.
80
+ const contentSentinel = `\x00ECMSG-${randomUUID()}\x00`;
81
+ const sessionCache = new Map(); // render-local cache (template files are small & fixed)
82
+ const renderedParts = [];
83
+ const allImages = [];
84
+ for (const item of items) {
85
+ renderedParts.push(renderOneItem(item, sessionVars, sessionCache, contentSentinel));
86
+ if (item.images && item.images.length > 0)
87
+ allImages.push(...item.images);
88
+ }
89
+ const body = renderedParts.join('\n\n');
90
+ writeMessageDebug(sessionId, items, body);
91
+ return { body, images: allImages };
92
+ }
93
+ // ── Debug ──
94
+ function writeMessageDebug(sessionId, items, body) {
95
+ try {
96
+ const ts = new Date().toISOString().replace(/[T:.]/g, '-').slice(0, 19);
97
+ const out = [
98
+ `# Message Render`,
99
+ `- sessionId: ${sessionId}`,
100
+ `- items: ${items.length}`,
101
+ `- images: ${items.reduce((n, i) => n + (i.images?.length ?? 0), 0)}`,
102
+ ``,
103
+ `## Rendered body`,
104
+ ``,
105
+ body,
106
+ ].join('\n');
107
+ fs.writeFile(path.join(eckDebugDir(), `msg-render-${ts}.md`), out, () => { });
108
+ }
109
+ catch (e) {
110
+ logger.debug(`[MessageRenderer] debug write failed: ${e}`);
111
+ }
112
+ }
@@ -1352,6 +1352,44 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1352
1352
  }).catch(err => {
1353
1353
  logger.error(`${this.logPrefix()} Message handler error:`, err);
1354
1354
  });
1355
+ // Observer forward: inbound
1356
+ this.forwardToOwners('inbound', {
1357
+ from: event.userId || event.channelId || '',
1358
+ to: this.config.aid,
1359
+ seq: event.seq,
1360
+ payload: { type: 'text', text: event.text },
1361
+ });
1362
+ }
1363
+ /**
1364
+ * 观察者模式转发:将消息副本以 observer.forward 格式发给所有 owners。
1365
+ * 仅在 AgentConfig.observable === true 时执行;owners 为空或无法加载配置时静默跳过。
1366
+ */
1367
+ forwardToOwners(direction, original) {
1368
+ if (!this.connected || !this.client)
1369
+ return;
1370
+ const agentConfig = loadAgent(this.config.aid);
1371
+ if (!agentConfig?.observable)
1372
+ return;
1373
+ const owners = agentConfig.owners ?? [];
1374
+ if (owners.length === 0)
1375
+ return;
1376
+ const forwardPayload = {
1377
+ type: 'observer.forward',
1378
+ direction,
1379
+ agent_aid: this.config.aid,
1380
+ original: {
1381
+ from: original.from,
1382
+ to: original.to,
1383
+ ...(original.seq != null ? { seq: original.seq } : {}),
1384
+ timestamp: Date.now(),
1385
+ payload: original.payload,
1386
+ },
1387
+ };
1388
+ for (const ownerAid of owners) {
1389
+ const encrypt = this.shouldEncrypt(ownerAid);
1390
+ this.callAndTrace('message.send', { to: ownerAid, payload: forwardPayload, encrypt })
1391
+ .catch(e => logger.debug(`${this.logPrefix()} observer.forward to ${ownerAid} failed: ${e}`));
1392
+ }
1355
1393
  }
1356
1394
  handleEcho(event) {
1357
1395
  const ts = () => {
@@ -1828,6 +1866,12 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1828
1866
  appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: channelId, msgId: mid, kind: 'text', len: finalText.length, groupId: channelId });
1829
1867
  this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, Buffer.byteLength(finalText, 'utf-8'), finalText, false, encrypt, context?.metadata?.chatmode);
1830
1868
  this.appendOutboundJsonl(channelId, finalText, mid, encrypt, context, true, 'text', source);
1869
+ // Observer forward: outbound (group)
1870
+ this.forwardToOwners('outbound', {
1871
+ from: this.config.aid,
1872
+ to: channelId,
1873
+ payload: { type: 'text', text: finalText },
1874
+ });
1831
1875
  }
1832
1876
  }
1833
1877
  else {
@@ -1841,6 +1885,12 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1841
1885
  appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: targetAid, msgId: result.message_id, kind: 'text', len: finalText.length });
1842
1886
  this.aidStatsCollector?.recordOutbound(this.config.aid, targetAid, Buffer.byteLength(finalText, 'utf-8'), finalText, false, encrypt, context?.metadata?.chatmode);
1843
1887
  this.appendOutboundJsonl(targetAid, finalText, result.message_id, encrypt, context, false, 'text', source);
1888
+ // Observer forward: outbound (private)
1889
+ this.forwardToOwners('outbound', {
1890
+ from: this.config.aid,
1891
+ to: targetAid,
1892
+ payload: { type: 'text', text: finalText },
1893
+ });
1844
1894
  }
1845
1895
  }
1846
1896
  return true;
@@ -2582,22 +2632,22 @@ export class AUNChannelPlugin {
2582
2632
  channel.sendProcessingStatus(channelId, 'progress', envelope.taskId, envelope.taskId, ctx, payload.metadata);
2583
2633
  return;
2584
2634
  case 'status.started':
2585
- channel.sendProcessingStatus(channelId, 'start', envelope.taskId, envelope.taskId, ctx);
2635
+ channel.sendProcessingStatus(channelId, 'start', envelope.taskId, envelope.taskId, ctx, payload.metadata);
2586
2636
  return;
2587
2637
  case 'status.queued':
2588
- channel.sendProcessingStatus(channelId, 'queued', envelope.taskId, envelope.taskId, ctx);
2638
+ channel.sendProcessingStatus(channelId, 'queued', envelope.taskId, envelope.taskId, ctx, payload.metadata);
2589
2639
  return;
2590
2640
  case 'status.completed':
2591
- channel.sendProcessingStatus(channelId, 'done', envelope.taskId, envelope.taskId, ctx);
2641
+ channel.sendProcessingStatus(channelId, 'done', envelope.taskId, envelope.taskId, ctx, payload.metadata);
2592
2642
  return;
2593
2643
  case 'status.interrupted':
2594
- channel.sendProcessingStatus(channelId, 'interrupted', envelope.taskId, envelope.taskId, ctx);
2644
+ channel.sendProcessingStatus(channelId, 'interrupted', envelope.taskId, envelope.taskId, ctx, payload.metadata);
2595
2645
  return;
2596
2646
  case 'status.error':
2597
- channel.sendProcessingStatus(channelId, 'error', envelope.taskId, envelope.taskId, ctx);
2647
+ channel.sendProcessingStatus(channelId, 'error', envelope.taskId, envelope.taskId, ctx, payload.metadata);
2598
2648
  return;
2599
2649
  case 'status.timeout':
2600
- channel.sendProcessingStatus(channelId, 'timeout', envelope.taskId, envelope.taskId, ctx);
2650
+ channel.sendProcessingStatus(channelId, 'timeout', envelope.taskId, envelope.taskId, ctx, payload.metadata);
2601
2651
  return;
2602
2652
  case 'interaction': {
2603
2653
  const req = payload.interaction;
package/dist/cli/index.js CHANGED
@@ -2102,22 +2102,22 @@ async function cmdWatchAid() {
2102
2102
  platform.onShutdown(cleanup);
2103
2103
  }
2104
2104
  async function cmdWatchWeb() {
2105
- // ecweb 是独立插件包(可执行命令),按需安装。
2105
+ // evolclaw-web 是独立插件包(可执行命令),按需安装。
2106
2106
  // 复用 npm-ops.npmInstallGlobal(含 EACCES→sudo 回退、Windows npm.cmd、超时)。
2107
2107
  const { execFileSync } = await import('child_process');
2108
2108
  const home = resolvePaths().root;
2109
- if (!platform.commandExists('ecweb')) {
2110
- process.stdout.write('📦 ecweb 未安装,正在从 npm 安装...\n');
2109
+ if (!platform.commandExists('evolclaw-web')) {
2110
+ process.stdout.write('📦 evolclaw-web 未安装,正在从 npm 安装...\n');
2111
2111
  const { npmInstallGlobal } = await import('../utils/npm-ops.js');
2112
2112
  try {
2113
- await npmInstallGlobal('ecweb');
2113
+ await npmInstallGlobal('evolclaw-web');
2114
2114
  }
2115
2115
  catch (e) {
2116
- process.stderr.write(`❌ 安装 ecweb 失败: ${e?.stderr || e?.message || e}\n 可手动安装: npm install -g ecweb\n`);
2116
+ process.stderr.write(`❌ 安装 evolclaw-web 失败: ${e?.stderr || e?.message || e}\n 可手动安装: npm install -g evolclaw-web\n`);
2117
2117
  process.exit(1);
2118
2118
  }
2119
2119
  }
2120
- execFileSync('ecweb', ['--home', home], { stdio: 'inherit' });
2120
+ execFileSync('evolclaw-web', ['--home', home], { stdio: 'inherit' });
2121
2121
  }
2122
2122
  async function cmdRestartMonitor() {
2123
2123
  const p = resolvePaths();
@@ -135,13 +135,8 @@ export class MessageBridge {
135
135
  const effectiveProjectPath = owningAgent?.projectPath
136
136
  ?? this.defaultProjectPath;
137
137
  const session = await this.sessionManager.getOrCreateSession(channelName, msg.channelId, effectiveProjectPath, msg.threadId, Object.keys(metadata).length ? metadata : undefined, undefined, msg.peerId, chatType, undefined, msg.selfAID, msg.channelType || effectiveChannelType, msg.peerType);
138
- // 4. 消息前缀(由 policy 决定)
139
- const channelInfo = this.processor.getChannelInfo?.(channelName);
140
- if (channelInfo?.policy) {
141
- const prefix = channelInfo.policy.messagePrefix(chatType, msg.peerName);
142
- if (prefix)
143
- content = prefix + content;
144
- }
138
+ // 4. 群聊发送者标注由消息渲染层(message-renderer)逐条承担,不再在此硬编码前缀,
139
+ // 消息日志因此保存干净原文。policy.messagePrefix 暂保留(未来清理)。
145
140
  // 5. 构造完整消息(channel 字段存实例名,用于 session 精确匹配)
146
141
  const fullMessage = {
147
142
  channel: channelName,
@@ -11,6 +11,7 @@ import { summarizeToolInput } from '../permission.js';
11
11
  import { DEFAULT_PERMISSION_MODE } from '../../types.js';
12
12
  import { getPackageRoot, resolveRoot } from '../../paths.js';
13
13
  import { renderKitSections } from '../../agents/kit-renderer.js';
14
+ import { renderMessageBody } from '../../agents/message-renderer.js';
14
15
  import { normalizeBaseagent } from '../../agents/baseagent-normalize.js';
15
16
  import { renderActionAsText, renderCommandCardAsText } from '../interaction-router.js';
16
17
  import { formatPeerKey } from '../relation/peer-key.js';
@@ -31,6 +32,20 @@ function currentTzOffset() {
31
32
  const abs = Math.abs(off);
32
33
  return `${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}`;
33
34
  }
35
+ /** 当前本地日期 YYYY-MM-DD(按运行环境时区)。系统提示词用,一天才变一次(缓存友好)。 */
36
+ function currentLocalDate() {
37
+ const d = new Date();
38
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
39
+ }
40
+ /** 当前本地星期几(中文,如「星期四」)。 */
41
+ function currentWeekday() {
42
+ try {
43
+ return new Intl.DateTimeFormat('zh-CN', { weekday: 'long' }).format(new Date());
44
+ }
45
+ catch {
46
+ return ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][new Date().getDay()];
47
+ }
48
+ }
34
49
  function getContextTooLongHint(agent) {
35
50
  if (canCompactAgent(agent)) {
36
51
  return '上下文过长,请精简提问或使用 /compact 压缩上下文';
@@ -560,9 +575,12 @@ export class MessageProcessor {
560
575
  // 检查是否因新消息自动中断 — 包装 prompt 让 Agent 知道上下文
561
576
  const prevInterruptReason = this.interruptedSessions.get(session.id);
562
577
  this.interruptedSessions.delete(session.id);
563
- const effectivePrompt = prevInterruptReason === 'new_message' && session.agentSessionId
564
- ? `【新消息插入】\n\n${message.content}\n\n【请无视之前中断继续处理】`
565
- : message.content;
578
+ const wasInterrupted = prevInterruptReason === 'new_message' && !!session.agentSessionId;
579
+ const wrapPrompt = (body) => wasInterrupted
580
+ ? `【新消息插入】\n\n${body}\n\n【请无视之前中断继续处理】`
581
+ : body;
582
+ // 先用裸文本兜底;vars 构造完成后用消息渲染层重算(见下方 effectivePrompt 重赋值)。
583
+ let effectivePrompt = wrapPrompt(message.content);
566
584
  let streamResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
567
585
  let effectiveSystemPrompt;
568
586
  let modelOverride;
@@ -644,6 +662,7 @@ export class MessageProcessor {
644
662
  KITS_DOCS: path.join(pkgRoot, 'kits', 'docs'),
645
663
  KITS_TEMPLATES: path.join(pkgRoot, 'kits', 'templates'),
646
664
  KITS_FRAGMENTS: path.join(pkgRoot, 'kits', 'templates', 'system-fragments'),
665
+ KITS_MESSAGE_FRAGMENTS: path.join(pkgRoot, 'kits', 'templates', 'message-fragments'),
647
666
  // evolclaw 运行模式:dev=源码仓库 | install=全局安装包
648
667
  evolclawMode: fs.existsSync(path.join(pkgRoot, 'src', 'index.ts')) ? 'dev' : 'install',
649
668
  // 路径变量(用于 manifest 路径展开,resolvePath 用 ctx.vars 取真值)
@@ -678,6 +697,8 @@ export class MessageProcessor {
678
697
  // 时区(把 ISO 时间戳转本地时间用)+ OS 环境
679
698
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || undefined,
680
699
  tzOffset: currentTzOffset(),
700
+ localDate: currentLocalDate(),
701
+ weekday: currentWeekday(),
681
702
  osInfo: OS_INFO,
682
703
  threadId: session.threadId || undefined,
683
704
  // Stage 3: sessionKey 持久化字段
@@ -698,13 +719,38 @@ export class MessageProcessor {
698
719
  if (kitContext)
699
720
  contextParts.push(kitContext);
700
721
  effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
722
+ // 消息渲染层:用 message manifest 逐条渲染(时间 + 群聊发送者),组装成最终正文。
723
+ // 单条消息构造单元素 items;批量合并的消息 message.items 已由队列填充。
724
+ let renderResult;
725
+ const hasContent = message.content.trim() || (message.items && message.items.length > 0);
726
+ if (hasContent) {
727
+ try {
728
+ const renderItems = message.items && message.items.length > 0
729
+ ? message.items
730
+ : [{
731
+ peerId: message.peerId, peerName: peerName || undefined,
732
+ peerType: message.peerType, content: message.content,
733
+ timestamp: message.timestamp,
734
+ images: message.images,
735
+ }];
736
+ renderResult = renderMessageBody(renderItems, kitCtx.vars, session.id);
737
+ if (renderResult.body.trim())
738
+ effectivePrompt = wrapPrompt(renderResult.body);
739
+ else
740
+ effectivePrompt = wrapPrompt(message.content);
741
+ }
742
+ catch (e) {
743
+ logger.warn(`[MessageProcessor] renderMessageBody failed, using raw content: ${e instanceof Error ? e.message : String(e)}`);
744
+ effectivePrompt = wrapPrompt(message.content);
745
+ }
746
+ }
701
747
  // 可重试错误(403/429/5xx)指数退避重试,最多 3 次
702
748
  const MAX_RETRIES = 3;
703
749
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
704
750
  let streamRegistered = false;
705
751
  try {
706
752
  logger.info(`[MessageProcessor] agent.runQuery start: agent=${agent.name} session=${session.id} task=${taskId} attempt=${attempt}/${MAX_RETRIES} agentSessionId=${session.agentSessionId ?? 'none'}`);
707
- const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, session.agentSessionId, message.images, effectiveSystemPrompt, this.sessionManager, modelOverride);
753
+ const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, session.agentSessionId, renderResult?.images.length ? renderResult.images : message.images, effectiveSystemPrompt, this.sessionManager, modelOverride);
708
754
  agent.registerStream(streamKey, stream);
709
755
  streamRegistered = true;
710
756
  streamResult = await this.processEventStream(stream, session, agent, renderer, resetTimer, shouldSuppress);
@@ -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,17 @@ 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
+ content: m.content, timestamp: m.timestamp,
213
+ images: m.images && m.images.length > 0 ? m.images : undefined,
214
+ });
215
+ }
203
216
  }
204
217
  const last = items[items.length - 1];
205
218
  // 保留最新一条的 messageId(若最后一条无 ID 则回退到前面已有的 ID)
@@ -213,6 +226,7 @@ export class MessageQueue {
213
226
  const merged = {
214
227
  ...last.message,
215
228
  content: contents.join('\n'),
229
+ items: subMessages,
216
230
  images: allImages.length > 0 ? allImages : undefined,
217
231
  mentions: allMentions.length > 0 ? allMentions : undefined,
218
232
  messageId: latestMessageId,