evolclaw 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +10 -3
  2. package/data/evolclaw.sample.json +9 -1
  3. package/dist/agents/claude-runner.js +612 -0
  4. package/dist/agents/codex-runner.js +310 -0
  5. package/dist/channels/aun.js +416 -9
  6. package/dist/channels/feishu.js +397 -104
  7. package/dist/channels/wechat.js +84 -2
  8. package/dist/cli.js +427 -126
  9. package/dist/config.js +102 -4
  10. package/dist/core/adapters/claude-session-file-adapter.js +144 -0
  11. package/dist/core/adapters/codex-session-file-adapter.js +196 -0
  12. package/dist/core/agent-loader.js +39 -0
  13. package/dist/core/channel-loader.js +60 -0
  14. package/dist/core/command-handler.js +908 -304
  15. package/dist/core/event-bus.js +32 -0
  16. package/dist/core/ipc-server.js +71 -0
  17. package/dist/core/message-bridge.js +187 -0
  18. package/dist/core/message-processor.js +370 -227
  19. package/dist/core/message-queue.js +153 -29
  20. package/dist/core/permission.js +58 -0
  21. package/dist/core/session-file-adapter.js +7 -0
  22. package/dist/core/session-manager.js +571 -223
  23. package/dist/core/stats-collector.js +86 -0
  24. package/dist/index.js +309 -243
  25. package/dist/paths.js +1 -0
  26. package/dist/utils/error-utils.js +4 -2
  27. package/dist/utils/init-feishu.js +2 -0
  28. package/dist/utils/init-wechat.js +2 -0
  29. package/dist/utils/init.js +285 -53
  30. package/dist/utils/ipc-client.js +36 -0
  31. package/dist/utils/migrate-project.js +122 -0
  32. package/dist/utils/{permission.js → permission-utils.js} +31 -3
  33. package/dist/utils/rich-content-renderer.js +228 -0
  34. package/dist/utils/session-file-health.js +11 -34
  35. package/dist/utils/stream-debouncer.js +122 -0
  36. package/dist/utils/stream-idle-monitor.js +1 -1
  37. package/package.json +3 -1
  38. package/dist/core/agent-runner.js +0 -348
  39. package/dist/core/message-stream.js +0 -59
  40. package/dist/index.js.bak +0 -340
  41. package/dist/utils/markdown-to-feishu.js +0 -94
  42. /package/dist/utils/{platform.js → cross-platform.js} +0 -0
  43. /package/dist/{core → utils}/message-cache.js +0 -0
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Rich Content Renderer
3
+ *
4
+ * Renders LaTeX formulas and Mermaid diagrams to PNG images using Playwright.
5
+ * KaTeX CSS/JS/fonts and Mermaid JS are loaded from node_modules and injected
6
+ * inline via setContent — no network, CDN, or local server needed.
7
+ */
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { getPackageRoot } from '../paths.js';
11
+ import { logger } from './logger.js';
12
+ // Lazy-loaded Playwright
13
+ let chromium;
14
+ let browserInstance;
15
+ let browserContext;
16
+ // Cached inline resources (loaded once, reused)
17
+ let katexCSS = null;
18
+ let katexJS = null;
19
+ let mermaidJS = null;
20
+ // Dependency availability check (cached)
21
+ let dependenciesAvailable = null;
22
+ function getKatexCSS() {
23
+ if (katexCSS)
24
+ return katexCSS;
25
+ const katexDir = path.join(getPackageRoot(), 'node_modules', 'katex', 'dist');
26
+ let css = fs.readFileSync(path.join(katexDir, 'katex.min.css'), 'utf8');
27
+ // Embed woff2 fonts as data URIs so they work without file:// or http
28
+ css = css.replace(/url\(fonts\/([\w.-]+\.woff2)\)/g, (_m, fontFile) => {
29
+ const fontPath = path.join(katexDir, 'fonts', fontFile);
30
+ if (fs.existsSync(fontPath)) {
31
+ const b64 = fs.readFileSync(fontPath).toString('base64');
32
+ return `url(data:font/woff2;base64,${b64})`;
33
+ }
34
+ return _m;
35
+ });
36
+ katexCSS = css;
37
+ return css;
38
+ }
39
+ function getKatexJS() {
40
+ if (katexJS)
41
+ return katexJS;
42
+ const katexDir = path.join(getPackageRoot(), 'node_modules', 'katex', 'dist');
43
+ katexJS = fs.readFileSync(path.join(katexDir, 'katex.min.js'), 'utf8');
44
+ return katexJS;
45
+ }
46
+ function getMermaidJS() {
47
+ if (mermaidJS)
48
+ return mermaidJS;
49
+ const mermaidDir = path.join(getPackageRoot(), 'node_modules', 'mermaid', 'dist');
50
+ mermaidJS = fs.readFileSync(path.join(mermaidDir, 'mermaid.min.js'), 'utf8');
51
+ return mermaidJS;
52
+ }
53
+ async function getBrowser() {
54
+ if (browserInstance?.isConnected?.())
55
+ return browserInstance;
56
+ try {
57
+ // @ts-ignore - runtime path, not a declared module
58
+ const pw = await import('/root/.claude/node_modules/playwright/index.mjs');
59
+ chromium = pw.chromium;
60
+ }
61
+ catch {
62
+ // @ts-ignore - optional dependency
63
+ const pw = await import('playwright');
64
+ chromium = pw.chromium;
65
+ }
66
+ browserInstance = await chromium.launch({
67
+ headless: false,
68
+ args: ['--headless=new', '--no-sandbox', '--disable-dev-shm-usage'],
69
+ });
70
+ browserContext = await browserInstance.newContext({
71
+ viewport: { width: 1280, height: 800 },
72
+ });
73
+ return browserInstance;
74
+ }
75
+ async function getPage() {
76
+ await getBrowser();
77
+ return browserContext.newPage();
78
+ }
79
+ export async function renderLatex(formula, displayMode = true) {
80
+ const page = await getPage();
81
+ try {
82
+ const css = getKatexCSS();
83
+ const js = getKatexJS();
84
+ const escaped = formula.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
85
+ const html = '<!DOCTYPE html><html><head><style>' + css + '</style>'
86
+ + '<style>body{background:white;margin:0;padding:16px;display:inline-block;}#f{font-size:1.2em;}</style>'
87
+ + '</head><body><div id="f"></div>'
88
+ + '<scr' + 'ipt>' + js + '</scr' + 'ipt>'
89
+ + '<scr' + 'ipt>'
90
+ + 'try{katex.render("' + escaped + '",document.getElementById("f"),'
91
+ + '{displayMode:' + displayMode + ',throwOnError:false});'
92
+ + '}catch(e){document.getElementById("f").textContent="Error: "+e.message;}'
93
+ + '</scr' + 'ipt></body></html>';
94
+ await page.setContent(html, { waitUntil: 'load' });
95
+ await page.waitForTimeout(500);
96
+ const el = page.locator('#f');
97
+ const box = await el.boundingBox();
98
+ if (!box || box.width === 0 || box.height === 0) {
99
+ throw new Error('LaTeX element has zero size');
100
+ }
101
+ return Buffer.from(await el.screenshot({ type: 'png' }));
102
+ }
103
+ finally {
104
+ await page.close();
105
+ }
106
+ }
107
+ export async function renderMermaid(code) {
108
+ const page = await getPage();
109
+ try {
110
+ const js = getMermaidJS();
111
+ const escaped = code.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
112
+ const html = '<!DOCTYPE html><html><head>'
113
+ + '<style>body{background:white;margin:0;padding:16px;display:inline-block;}</style>'
114
+ + '</head><body><div id="m"></div>'
115
+ + '<scr' + 'ipt>' + js + '</scr' + 'ipt>'
116
+ + '<scr' + 'ipt>'
117
+ + '(async()=>{'
118
+ + 'mermaid.initialize({startOnLoad:false,theme:"default"});'
119
+ + 'try{const{svg}=await mermaid.render("md",`' + escaped + '`);'
120
+ + 'document.getElementById("m").innerHTML=svg;'
121
+ + '}catch(e){document.getElementById("m").textContent="Error: "+e.message;}'
122
+ + '})();'
123
+ + '</scr' + 'ipt></body></html>';
124
+ await page.setContent(html, { waitUntil: 'load' });
125
+ await page.waitForTimeout(1500);
126
+ const el = page.locator('#m');
127
+ const box = await el.boundingBox();
128
+ if (!box || box.width === 0 || box.height === 0) {
129
+ throw new Error('Mermaid element has zero size');
130
+ }
131
+ return Buffer.from(await el.screenshot({ type: 'png' }));
132
+ }
133
+ finally {
134
+ await page.close();
135
+ }
136
+ }
137
+ export function extractLatex(text) {
138
+ const results = [];
139
+ const blockRe = /\$\$([\s\S]+?)\$\$/g;
140
+ let m;
141
+ while ((m = blockRe.exec(text)) !== null) {
142
+ results.push({
143
+ match: m[0], formula: m[1].trim(), displayMode: true,
144
+ start: m.index, end: m.index + m[0].length,
145
+ });
146
+ }
147
+ const inlineRe = /(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)/g;
148
+ while ((m = inlineRe.exec(text)) !== null) {
149
+ const overlaps = results.some(r => m.index >= r.start && m.index < r.end);
150
+ if (!overlaps) {
151
+ results.push({
152
+ match: m[0], formula: m[1].trim(), displayMode: false,
153
+ start: m.index, end: m.index + m[0].length,
154
+ });
155
+ }
156
+ }
157
+ results.sort((a, b) => a.start - b.start);
158
+ return results;
159
+ }
160
+ export function extractMermaid(text) {
161
+ const results = [];
162
+ const re = /```mermaid\s*\n([\s\S]+?)```/g;
163
+ let m;
164
+ while ((m = re.exec(text)) !== null) {
165
+ results.push({ match: m[0], code: m[1].trim(), start: m.index, end: m.index + m[0].length });
166
+ }
167
+ return results;
168
+ }
169
+ export async function renderAllRichContent(text) {
170
+ const latexItems = extractLatex(text);
171
+ const mermaidItems = extractMermaid(text);
172
+ const results = [];
173
+ for (const item of latexItems) {
174
+ try {
175
+ const png = await renderLatex(item.formula, item.displayMode);
176
+ results.push({ match: item.match, start: item.start, end: item.end, type: 'latex', png });
177
+ }
178
+ catch (err) {
179
+ logger.warn('[RichContent] LaTeX render failed: ' + item.formula, err);
180
+ }
181
+ }
182
+ for (const item of mermaidItems) {
183
+ try {
184
+ const png = await renderMermaid(item.code);
185
+ results.push({ match: item.match, start: item.start, end: item.end, type: 'mermaid', png });
186
+ }
187
+ catch (err) {
188
+ logger.warn('[RichContent] Mermaid render failed', err);
189
+ }
190
+ }
191
+ return results;
192
+ }
193
+ /**
194
+ * 检查富内容渲染依赖是否可用(playwright、katex、mermaid)
195
+ */
196
+ export function checkDependencies() {
197
+ if (dependenciesAvailable !== null)
198
+ return dependenciesAvailable;
199
+ try {
200
+ const pkgRoot = getPackageRoot();
201
+ const playwrightPath = path.join(pkgRoot, 'node_modules', 'playwright');
202
+ const katexPath = path.join(pkgRoot, 'node_modules', 'katex', 'dist', 'katex.min.js');
203
+ const mermaidPath = path.join(pkgRoot, 'node_modules', 'mermaid', 'dist', 'mermaid.min.js');
204
+ dependenciesAvailable = fs.existsSync(playwrightPath) && fs.existsSync(katexPath) && fs.existsSync(mermaidPath);
205
+ if (!dependenciesAvailable) {
206
+ logger.warn('[RichContent] Dependencies not installed (playwright/katex/mermaid), rich content rendering disabled');
207
+ }
208
+ return dependenciesAvailable;
209
+ }
210
+ catch (err) {
211
+ logger.warn('[RichContent] Failed to check dependencies:', err);
212
+ dependenciesAvailable = false;
213
+ return false;
214
+ }
215
+ }
216
+ export function hasRichContent(text) {
217
+ return extractLatex(text).length > 0 || extractMermaid(text).length > 0;
218
+ }
219
+ export async function closeBrowser() {
220
+ if (browserContext) {
221
+ await browserContext.close().catch(() => { });
222
+ browserContext = null;
223
+ }
224
+ if (browserInstance) {
225
+ await browserInstance.close().catch(() => { });
226
+ browserInstance = null;
227
+ }
228
+ }
@@ -1,38 +1,17 @@
1
- import fs from 'fs/promises';
2
- import path from 'path';
1
+ import fsPromises from 'fs/promises';
3
2
  import { logger } from './logger.js';
4
3
  /**
5
- * 检查会话文件是否存在
4
+ * 检查会话文件健康度(接收完整文件路径)
6
5
  */
7
- async function fileExists(filePath) {
8
- try {
9
- await fs.access(filePath);
10
- return true;
11
- }
12
- catch {
13
- return false;
14
- }
15
- }
16
- /**
17
- * 检查会话文件健康度
18
- */
19
- export async function checkSessionFileHealth(projectPath, agentSessionId) {
6
+ export async function checkSessionFile(sessionFile) {
20
7
  const issues = [];
21
- const sessionFile = path.join(projectPath, '.claude', `${agentSessionId}.jsonl`);
22
- // 检查文件是否存在
23
- if (!(await fileExists(sessionFile))) {
24
- // 新会话没有文件是正常的
25
- return { healthy: true, issues: [] };
26
- }
27
8
  try {
28
- // 检查文件大小
29
- const stats = await fs.stat(sessionFile);
9
+ const stats = await fsPromises.stat(sessionFile);
30
10
  const sizeMB = stats.size / (1024 * 1024);
31
11
  if (stats.size > 50 * 1024 * 1024) {
32
12
  issues.push(`会话文件过大: ${sizeMB.toFixed(1)}MB`);
33
13
  }
34
- // 检查 JSON 格式
35
- const content = await fs.readFile(sessionFile, 'utf-8');
14
+ const content = await fsPromises.readFile(sessionFile, 'utf-8');
36
15
  const lines = content.split('\n').filter(l => l.trim());
37
16
  for (let i = 0; i < lines.length; i++) {
38
17
  try {
@@ -56,13 +35,11 @@ export async function checkSessionFileHealth(projectPath, agentSessionId) {
56
35
  }
57
36
  }
58
37
  /**
59
- * 备份会话目录
38
+ * 备份单个会话文件(在同目录下创建 .bak 副本)
60
39
  */
61
- export async function backupClaudeDir(projectPath) {
62
- const claudeDir = path.join(projectPath, '.claude');
63
- const dirName = path.basename(claudeDir);
64
- const backupDir = path.join(path.dirname(claudeDir), `${dirName}-backup-${Date.now()}`);
65
- await fs.cp(claudeDir, backupDir, { recursive: true });
66
- logger.info(`[SessionFileHealth] Backup created: ${backupDir}`);
67
- return backupDir;
40
+ export async function backupSessionFile(sessionFile) {
41
+ const backupPath = `${sessionFile}.bak-${Date.now()}`;
42
+ await fsPromises.copyFile(sessionFile, backupPath);
43
+ logger.info(`[SessionFileHealth] Backup created: ${backupPath}`);
44
+ return backupPath;
68
45
  }
@@ -0,0 +1,122 @@
1
+ import { logger } from './logger.js';
2
+ /**
3
+ * 入站消息去抖器
4
+ *
5
+ * 在 debounceMs 窗口内收到的同一 session 的多条消息合并为一次 enqueue:
6
+ * - content 用 \n 连接
7
+ * - images / mentions 合并
8
+ * - replyContext / 其余字段取最后一条
9
+ *
10
+ * cancel(messageId) 可精确移除窗口中的某条消息,不影响其余消息。
11
+ */
12
+ export class StreamDebouncer {
13
+ pending = new Map();
14
+ delayMs;
15
+ maxWaitMs;
16
+ maxMessages;
17
+ constructor(debounceSeconds, maxMessages = 5) {
18
+ this.delayMs = debounceSeconds * 1000;
19
+ this.maxWaitMs = this.delayMs * 3;
20
+ this.maxMessages = maxMessages;
21
+ }
22
+ get enabled() {
23
+ return this.delayMs > 0;
24
+ }
25
+ /**
26
+ * 提交一条消息。如果窗口内已有消息则追加并重置 timer;
27
+ * 否则新建窗口。timer 到期后自动 flush。
28
+ */
29
+ submit(key, message, enqueue) {
30
+ const { content, images, mentions, messageId, replyContext, ...rest } = message;
31
+ return new Promise((resolve, reject) => {
32
+ const entry = { messageId, content, images, mentions, replyContext, rest, resolve, reject };
33
+ const win = this.pending.get(key);
34
+ if (win) {
35
+ clearTimeout(win.timer);
36
+ win.entries.push(entry);
37
+ if (win.entries.length >= this.maxMessages) {
38
+ logger.debug(`[Debounce] Max messages (${this.maxMessages}) reached for ${key}, flushing immediately`);
39
+ clearTimeout(win.maxWaitTimer);
40
+ this.flush(key, enqueue);
41
+ return;
42
+ }
43
+ win.timer = setTimeout(() => this.flush(key, enqueue), this.delayMs);
44
+ logger.debug(`[Debounce] Appended message for ${key}, ${win.entries.length} pending`);
45
+ }
46
+ else {
47
+ const timer = setTimeout(() => this.flush(key, enqueue), this.delayMs);
48
+ const maxWaitTimer = setTimeout(() => this.flush(key, enqueue), this.maxWaitMs);
49
+ this.pending.set(key, { entries: [entry], timer, maxWaitTimer });
50
+ logger.debug(`[Debounce] New window for ${key}, debounce=${this.delayMs}ms, maxWait=${this.maxWaitMs}ms`);
51
+ }
52
+ });
53
+ }
54
+ /**
55
+ * 从 debounce 窗口中撤回指定 messageId 的消息。
56
+ * 如果窗口只剩这一条,整个窗口取消。
57
+ * @returns true 如果找到并移除
58
+ */
59
+ cancel(messageId) {
60
+ for (const [key, win] of this.pending) {
61
+ const idx = win.entries.findIndex(e => e.messageId === messageId);
62
+ if (idx === -1)
63
+ continue;
64
+ // resolve 被撤回的那条(静默完成,不报错)
65
+ win.entries.splice(idx, 1)[0].resolve();
66
+ logger.info(`[Debounce] Cancelled message ${messageId} from window ${key}`);
67
+ // 窗口空了 → 整个取消
68
+ if (win.entries.length === 0) {
69
+ clearTimeout(win.timer);
70
+ clearTimeout(win.maxWaitTimer);
71
+ this.pending.delete(key);
72
+ logger.info(`[Debounce] Window ${key} empty after cancel, removed`);
73
+ }
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ flush(key, enqueue) {
79
+ const win = this.pending.get(key);
80
+ if (!win)
81
+ return;
82
+ this.pending.delete(key);
83
+ clearTimeout(win.timer);
84
+ clearTimeout(win.maxWaitTimer);
85
+ const { entries } = win;
86
+ // 合并:content 用 \n 连接,images/mentions 扁平合并,其余取最后一条
87
+ const allImages = [];
88
+ const allMentions = [];
89
+ const contents = [];
90
+ for (const e of entries) {
91
+ contents.push(e.content);
92
+ if (e.images)
93
+ allImages.push(...e.images);
94
+ if (e.mentions)
95
+ allMentions.push(...e.mentions);
96
+ }
97
+ const last = entries[entries.length - 1];
98
+ const merged = {
99
+ ...last.rest,
100
+ content: contents.join('\n'),
101
+ images: allImages.length > 0 ? allImages : undefined,
102
+ mentions: allMentions.length > 0 ? allMentions : undefined,
103
+ replyContext: last.replyContext,
104
+ messageId: entries.length > 1 ? undefined : last.messageId,
105
+ };
106
+ const resolves = entries.map(e => e.resolve);
107
+ const rejects = entries.map(e => e.reject);
108
+ enqueue(merged).then(() => resolves.forEach(r => r()), (e) => rejects.forEach(r => r(e)));
109
+ }
110
+ /** 当前挂起的 key 数量(用于测试/调试) */
111
+ get pendingCount() {
112
+ return this.pending.size;
113
+ }
114
+ /** 清理所有挂起的 timer(用于 shutdown) */
115
+ dispose() {
116
+ for (const win of this.pending.values()) {
117
+ clearTimeout(win.timer);
118
+ clearTimeout(win.maxWaitTimer);
119
+ }
120
+ this.pending.clear();
121
+ }
122
+ }
@@ -34,7 +34,7 @@ export class StreamIdleMonitor {
34
34
  this.state.lastToolStartTime = Date.now();
35
35
  this.state.totalToolCalls++;
36
36
  }
37
- if (type === 'text_delta' || type === 'result') {
37
+ if (type === 'text' || type === 'text_delta' || type === 'complete' || type === 'result') {
38
38
  this.state.hasReceivedText = true;
39
39
  }
40
40
  // 收到新事件,重置已触发级别
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolclaw",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
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",
@@ -23,7 +23,9 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@anthropic-ai/claude-agent-sdk": "^0.2.75",
26
+ "@aun/core-node": "file:aun/aun-sdk-core/ts",
26
27
  "@larksuiteoapi/node-sdk": "^1.59.0",
28
+ "@openai/codex-sdk": "^0.118.0",
27
29
  "image-type": "^6.0.0",
28
30
  "qrcode-terminal": "^0.12.0"
29
31
  },