evolclaw 2.1.2 → 2.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 (54) hide show
  1. package/README.md +59 -30
  2. package/data/evolclaw.sample.json +15 -4
  3. package/dist/agents/claude-runner.js +685 -0
  4. package/dist/agents/codex-runner.js +315 -0
  5. package/dist/agents/gemini-runner.js +425 -0
  6. package/dist/channels/aun.js +580 -10
  7. package/dist/channels/feishu.js +888 -135
  8. package/dist/channels/wechat.js +127 -21
  9. package/dist/cli.js +519 -136
  10. package/dist/config.js +277 -25
  11. package/dist/core/agent-loader.js +39 -0
  12. package/dist/core/channel-loader.js +67 -0
  13. package/dist/core/command-handler.js +1537 -392
  14. package/dist/core/event-bus.js +32 -0
  15. package/dist/core/interaction-router.js +68 -0
  16. package/dist/core/message/message-bridge.js +216 -0
  17. package/dist/core/message/message-processor.js +1028 -0
  18. package/dist/core/message/message-queue.js +240 -0
  19. package/dist/core/message/stream-debouncer.js +122 -0
  20. package/dist/{utils → core/message}/stream-flusher.js +73 -13
  21. package/dist/{utils → core/message}/stream-idle-monitor.js +1 -1
  22. package/dist/core/permission.js +259 -0
  23. package/dist/core/session/adapters/claude-session-file-adapter.js +144 -0
  24. package/dist/core/session/adapters/codex-session-file-adapter.js +261 -0
  25. package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
  26. package/dist/core/session/session-file-adapter.js +7 -0
  27. package/dist/core/session/session-file-health.js +45 -0
  28. package/dist/core/session/session-manager.js +1072 -0
  29. package/dist/index.js +402 -252
  30. package/dist/ipc.js +106 -0
  31. package/dist/paths.js +1 -0
  32. package/dist/types.js +3 -0
  33. package/dist/utils/{platform.js → cross-platform.js} +38 -1
  34. package/dist/utils/error-utils.js +130 -5
  35. package/dist/utils/init-channel.js +649 -0
  36. package/dist/utils/init.js +190 -53
  37. package/dist/utils/logger.js +8 -3
  38. package/dist/utils/media-cache.js +207 -0
  39. package/dist/utils/migrate-project.js +122 -0
  40. package/dist/utils/rich-content-renderer.js +228 -0
  41. package/dist/utils/stats-collector.js +102 -0
  42. package/package.json +4 -2
  43. package/dist/core/agent-runner.js +0 -348
  44. package/dist/core/message-processor.js +0 -604
  45. package/dist/core/message-queue.js +0 -116
  46. package/dist/core/message-stream.js +0 -59
  47. package/dist/core/session-manager.js +0 -664
  48. package/dist/index.js.bak +0 -340
  49. package/dist/utils/init-feishu.js +0 -261
  50. package/dist/utils/init-wechat.js +0 -170
  51. package/dist/utils/markdown-to-feishu.js +0 -94
  52. package/dist/utils/permission.js +0 -43
  53. package/dist/utils/session-file-health.js +0 -68
  54. /package/dist/core/{message-cache.js → message/message-cache.js} +0 -0
package/dist/config.js CHANGED
@@ -2,7 +2,8 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
4
  import { logger } from './utils/logger.js';
5
- import { resolvePaths } from './paths.js';
5
+ import { resolvePaths, getPackageRoot as _getPackageRoot } from './paths.js';
6
+ import { commandExists } from './utils/cross-platform.js';
6
7
  // Re-export path utilities for backward compatibility
7
8
  export { resolveRoot, resolvePaths, ensureDataDirs, getPackageRoot } from './paths.js';
8
9
  function loadClaudeSettings() {
@@ -15,6 +16,38 @@ function loadClaudeSettings() {
15
16
  catch { }
16
17
  return {};
17
18
  }
19
+ function loadCodexSettings() {
20
+ try {
21
+ // Read auth.json for API key
22
+ const authPath = path.join(os.homedir(), '.codex', 'auth.json');
23
+ let apiKey;
24
+ if (fs.existsSync(authPath)) {
25
+ const auth = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
26
+ apiKey = auth.OPENAI_API_KEY;
27
+ }
28
+ // Read config.toml for model and baseUrl (simple TOML parsing)
29
+ const configPath = path.join(os.homedir(), '.codex', 'config.toml');
30
+ let model;
31
+ let baseUrl;
32
+ if (fs.existsSync(configPath)) {
33
+ const content = fs.readFileSync(configPath, 'utf-8');
34
+ const modelMatch = content.match(/^model\s*=\s*"([^"]+)"/m);
35
+ if (modelMatch)
36
+ model = modelMatch[1];
37
+ // Extract base_url from model_providers section
38
+ const providerMatch = content.match(/^model_provider\s*=\s*"([^"]+)"/m);
39
+ if (providerMatch) {
40
+ const provider = providerMatch[1];
41
+ const baseUrlMatch = content.match(new RegExp(`\\[model_providers\\.${provider}\\][\\s\\S]*?base_url\\s*=\\s*"([^"]+)"`, 'm'));
42
+ if (baseUrlMatch)
43
+ baseUrl = baseUrlMatch[1];
44
+ }
45
+ }
46
+ return { apiKey, baseUrl, model };
47
+ }
48
+ catch { }
49
+ return {};
50
+ }
18
51
  export function resolveAnthropicConfig(config) {
19
52
  const settings = loadClaudeSettings();
20
53
  // 过滤占位符,视为未配置
@@ -42,9 +75,95 @@ export function resolveAnthropicConfig(config) {
42
75
  || undefined;
43
76
  return { apiKey, baseUrl, model, effort };
44
77
  }
78
+ export function resolveOpenaiConfig(config) {
79
+ const codexSettings = loadCodexSettings();
80
+ // 过滤占位符,视为未配置
81
+ const configApiKey = config.agents?.openai?.apiKey;
82
+ const isPlaceholderKey = !configApiKey ||
83
+ configApiKey.includes('your-') ||
84
+ configApiKey.includes('placeholder');
85
+ const apiKey = (isPlaceholderKey ? null : configApiKey)
86
+ || process.env.OPENAI_API_KEY
87
+ || codexSettings.apiKey;
88
+ if (!apiKey) {
89
+ throw new Error('No OpenAI API key found. Set one of: agents.openai.apiKey, env OPENAI_API_KEY, or ~/.codex/auth.json');
90
+ }
91
+ // baseUrl 也过滤占位符(与 anthropic 保持一致:只检查默认域名)
92
+ const configBaseUrl = config.agents?.openai?.baseUrl;
93
+ const isPlaceholderUrl = configBaseUrl?.includes('api.openai.com');
94
+ const baseUrl = (isPlaceholderUrl ? null : configBaseUrl)
95
+ || process.env.OPENAI_BASE_URL
96
+ || codexSettings.baseUrl
97
+ || undefined;
98
+ const model = config.agents?.openai?.model
99
+ || codexSettings.model
100
+ || 'gpt-5.2-codex';
101
+ const effort = config.agents?.openai?.effort || config.agents?.openai?.reasoning || undefined;
102
+ return { apiKey, baseUrl, model, effort };
103
+ }
104
+ export function resolveGoogleConfig(config) {
105
+ const googleCfg = config.agents?.google;
106
+ // CLI path: config → which gemini
107
+ let cliPath = googleCfg?.cliPath || '';
108
+ if (!cliPath) {
109
+ cliPath = commandExists('gemini') ? 'gemini' : '';
110
+ }
111
+ // Model: config → default
112
+ const model = googleCfg?.model || 'gemini-2.5-flash';
113
+ // API key: config → env (optional, CLI has OAuth)
114
+ const configApiKey = googleCfg?.apiKey;
115
+ const isPlaceholder = !configApiKey || configApiKey.includes('your-') || configApiKey.includes('placeholder');
116
+ const apiKey = (isPlaceholder ? undefined : configApiKey)
117
+ || process.env.GEMINI_API_KEY
118
+ || process.env.GOOGLE_API_KEY
119
+ || undefined;
120
+ const mode = googleCfg?.mode || 'cli';
121
+ const useVertex = googleCfg?.useVertex || false;
122
+ const project = googleCfg?.project || process.env.GOOGLE_CLOUD_PROJECT || undefined;
123
+ const location = googleCfg?.location || process.env.GOOGLE_CLOUD_LOCATION || 'us-central1';
124
+ return { cliPath, model, apiKey, mode, useVertex, project, location };
125
+ }
45
126
  export function loadConfig(configPath = resolvePaths().config) {
46
127
  if (!fs.existsSync(configPath)) {
47
- throw new Error(`Config file not found: ${configPath}`);
128
+ // Try to recover from backup files
129
+ const dataDir = path.dirname(configPath);
130
+ const backupPath = path.join(dataDir, 'evolclaw.backup.json');
131
+ if (fs.existsSync(backupPath)) {
132
+ logger.warn(`Config file missing, restoring from backup: ${backupPath}`);
133
+ fs.copyFileSync(backupPath, configPath);
134
+ }
135
+ else {
136
+ // Look for timestamped backups (evolclaw-YYYYMMDD-HHMMSS.json)
137
+ const timestampedBackups = fs.existsSync(dataDir)
138
+ ? fs.readdirSync(dataDir)
139
+ .filter(f => /^evolclaw-\d{8}-\d{6}\.json$/.test(f))
140
+ .sort()
141
+ .reverse()
142
+ : [];
143
+ if (timestampedBackups.length > 0) {
144
+ const latest = path.join(dataDir, timestampedBackups[0]);
145
+ logger.warn(`Config file missing, restoring from timestamped backup: ${latest}`);
146
+ fs.copyFileSync(latest, configPath);
147
+ }
148
+ else {
149
+ // Create minimal config from sample
150
+ const samplePath = path.join(_getPackageRoot(), 'data', 'evolclaw.sample.json');
151
+ if (fs.existsSync(samplePath)) {
152
+ logger.warn(`Config file missing, creating from sample: ${samplePath}`);
153
+ const sample = JSON.parse(fs.readFileSync(samplePath, 'utf-8'));
154
+ // Set a usable defaultPath
155
+ const defaultProjectDir = path.join(os.homedir(), 'evolclaw-project');
156
+ sample.projects.defaultPath = defaultProjectDir;
157
+ if (!fs.existsSync(defaultProjectDir)) {
158
+ fs.mkdirSync(defaultProjectDir, { recursive: true });
159
+ }
160
+ fs.writeFileSync(configPath, JSON.stringify(sample, null, 2), 'utf-8');
161
+ }
162
+ else {
163
+ throw new Error(`Config file not found: ${configPath}`);
164
+ }
165
+ }
166
+ }
48
167
  }
49
168
  const content = fs.readFileSync(configPath, 'utf-8');
50
169
  const config = JSON.parse(content);
@@ -54,42 +173,140 @@ export function loadConfig(configPath = resolvePaths().config) {
54
173
  export function saveConfig(config, configPath = resolvePaths().config) {
55
174
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
56
175
  }
57
- export function getOwner(config, channel) {
58
- const ch = config.channels?.[channel];
59
- return ch?.owner;
176
+ // ── Channel instance normalization ──
177
+ export const channelTypes = ['feishu', 'wechat', 'aun'];
178
+ /**
179
+ * Normalize a channel config value (single object, array, or undefined) into an array
180
+ * where every element has a `name` field.
181
+ * - undefined → []
182
+ * - single object → [{ ...obj, name: obj.name ?? defaultName }]
183
+ * - array → passthrough (names must already be present)
184
+ */
185
+ export function normalizeChannelInstances(cfg, defaultName) {
186
+ if (cfg === undefined || cfg === null)
187
+ return [];
188
+ if (Array.isArray(cfg)) {
189
+ return cfg;
190
+ }
191
+ return [{ ...cfg, name: cfg.name ?? defaultName }];
60
192
  }
61
- export function setOwner(config, channel, userId, configPath = resolvePaths().config) {
193
+ /**
194
+ * Validate that all channel instance names are unique across all channel types.
195
+ * Throws if duplicate names are found.
196
+ */
197
+ export function validateChannelInstanceNames(config) {
198
+ const seen = new Map(); // name → channel type
199
+ for (const type of channelTypes) {
200
+ const instances = normalizeChannelInstances(config.channels?.[type], type);
201
+ for (const inst of instances) {
202
+ const prev = seen.get(inst.name);
203
+ if (prev !== undefined) {
204
+ throw new Error(`Duplicate channel instance name "${inst.name}" (found in ${prev} and ${type})`);
205
+ }
206
+ seen.set(inst.name, type);
207
+ }
208
+ }
209
+ }
210
+ export function getOwner(config, channelOrType) {
211
+ for (const type of channelTypes) {
212
+ const raw = config.channels?.[type];
213
+ const instances = normalizeChannelInstances(raw, type);
214
+ // 按实例名查找
215
+ const found = instances.find((inst) => inst.name === channelOrType);
216
+ if (found)
217
+ return found.owner;
218
+ // 按 channelType 查找:返回该类型下第一个有 owner 的实例
219
+ if (type === channelOrType) {
220
+ for (const inst of instances) {
221
+ if (inst.owner)
222
+ return inst.owner;
223
+ }
224
+ }
225
+ }
226
+ return undefined;
227
+ }
228
+ export function setOwner(config, instanceName, userId, configPath = resolvePaths().config) {
62
229
  if (!config.channels)
63
230
  config.channels = {};
64
231
  const channels = config.channels;
65
- if (!channels[channel])
66
- channels[channel] = {};
67
- channels[channel].owner = userId;
68
- saveConfig(config, configPath);
232
+ for (const type of channelTypes) {
233
+ const raw = channels[type];
234
+ if (raw === undefined)
235
+ continue;
236
+ if (Array.isArray(raw)) {
237
+ const inst = raw.find((item) => item.name === instanceName);
238
+ if (inst) {
239
+ inst.owner = userId;
240
+ saveConfig(config, configPath);
241
+ return;
242
+ }
243
+ }
244
+ else {
245
+ // Single-object form: match if name matches (or defaults to type name)
246
+ const effectiveName = raw.name ?? type;
247
+ if (effectiveName === instanceName) {
248
+ raw.owner = userId;
249
+ saveConfig(config, configPath);
250
+ return;
251
+ }
252
+ }
253
+ }
254
+ // Fallback: if instanceName matches a channel type with no config, create it
255
+ if (channelTypes.includes(instanceName)) {
256
+ channels[instanceName] = { owner: userId };
257
+ saveConfig(config, configPath);
258
+ return;
259
+ }
69
260
  }
70
- export function isOwner(config, channel, userId) {
71
- return getOwner(config, channel) === userId;
261
+ export function isOwner(config, channelOrType, userId) {
262
+ // 按实例名精确匹配
263
+ if (getOwner(config, channelOrType) === userId)
264
+ return true;
265
+ // 按 channelType 匹配:检查该类型下所有实例
266
+ for (const type of channelTypes) {
267
+ if (type !== channelOrType)
268
+ continue;
269
+ const raw = config.channels?.[type];
270
+ const instances = normalizeChannelInstances(raw, type);
271
+ for (const inst of instances) {
272
+ if (inst.owner === userId)
273
+ return true;
274
+ }
275
+ }
276
+ return false;
72
277
  }
73
278
  function validateConfig(config) {
74
279
  // anthropic 部分不再强制校验,由 resolveAnthropicConfig() 处理
75
- // Feishu 配置可选,但如果配置了就要完整
76
- if (config.channels?.feishu) {
77
- if (!config.channels.feishu.appId || config.channels.feishu.appId.startsWith('YOUR_')) {
78
- logger.warn('⚠ Feishu appId not configured (Feishu channel will be disabled)');
280
+ // Feishu 配置可选,但如果配置了就要完整(支持 array / object 两种格式)
281
+ const feishuInstances = normalizeChannelInstances(config.channels?.feishu, 'feishu');
282
+ for (const inst of feishuInstances) {
283
+ const label = feishuInstances.length > 1 ? ` [${inst.name}]` : '';
284
+ if (!inst.appId || inst.appId.startsWith('YOUR_')) {
285
+ logger.warn(`⚠ Feishu${label} appId not configured (Feishu channel will be disabled)`);
79
286
  }
80
- if (!config.channels.feishu.appSecret || config.channels.feishu.appSecret.startsWith('YOUR_')) {
81
- logger.warn('⚠ Feishu appSecret not configured (Feishu channel will be disabled)');
287
+ if (!inst.appSecret || inst.appSecret.startsWith('YOUR_')) {
288
+ logger.warn(`⚠ Feishu${label} appSecret not configured (Feishu channel will be disabled)`);
289
+ }
290
+ }
291
+ // AUN 配置可选,但如果配置了就要有 aid(支持 array / object 两种格式)
292
+ const aunInstances = normalizeChannelInstances(config.channels?.aun, 'aun');
293
+ for (const inst of aunInstances) {
294
+ if (inst.enabled === false)
295
+ continue;
296
+ const label = aunInstances.length > 1 ? ` [${inst.name}]` : '';
297
+ if (!inst.aid) {
298
+ logger.warn(`⚠ AUN${label} aid not configured (AUN channel will be disabled)`);
82
299
  }
83
300
  }
84
- if (!config.channels?.aun?.domain)
85
- throw new Error('Missing channels.aun.domain');
86
- if (!config.channels?.aun?.agentName)
87
- throw new Error('Missing channels.aun.agentName');
88
301
  if (!config.projects?.defaultPath)
89
302
  throw new Error('Missing projects.defaultPath');
90
- // WeChat 配置可选,但如果启用了就需要 token
91
- if (config.channels?.wechat?.enabled && !config.channels?.wechat?.token) {
92
- logger.warn('⚠ WeChat enabled but token not configured (WeChat channel will be disabled)');
303
+ // WeChat 配置可选,但如果启用了就需要 token(支持 array / object 两种格式)
304
+ const wechatInstances = normalizeChannelInstances(config.channels?.wechat, 'wechat');
305
+ for (const inst of wechatInstances) {
306
+ if (inst.enabled && !inst.token) {
307
+ const label = wechatInstances.length > 1 ? ` [${inst.name}]` : '';
308
+ logger.warn(`⚠ WeChat${label} enabled but token not configured (WeChat channel will be disabled)`);
309
+ }
93
310
  }
94
311
  }
95
312
  export function ensureDir(dirPath) {
@@ -97,3 +314,38 @@ export function ensureDir(dirPath) {
97
314
  fs.mkdirSync(dirPath, { recursive: true });
98
315
  }
99
316
  }
317
+ // agents.defaultAgent → config key 映射
318
+ const agentKeyMap = { claude: 'anthropic', codex: 'openai', gemini: 'google' };
319
+ /**
320
+ * 配置结构完整性校验(不校验凭据有效性)。
321
+ * 要求 agents/channels/projects 三段同时具备必要的锚点字段。
322
+ */
323
+ export function validateConfigIntegrity(config) {
324
+ const reasons = [];
325
+ // agents
326
+ const defaultAgent = config.agents?.defaultAgent;
327
+ if (!defaultAgent) {
328
+ reasons.push('Missing agents.defaultAgent');
329
+ }
330
+ else {
331
+ const key = agentKeyMap[defaultAgent] || defaultAgent;
332
+ if (!config.agents?.[key]) {
333
+ reasons.push(`agents.defaultAgent='${defaultAgent}' but agents.${key} does not exist`);
334
+ }
335
+ }
336
+ // channels
337
+ const defaultChannel = config.channels?.defaultChannel;
338
+ if (!defaultChannel) {
339
+ reasons.push('Missing channels.defaultChannel');
340
+ }
341
+ else {
342
+ if (!config.channels?.[defaultChannel]) {
343
+ reasons.push(`channels.defaultChannel='${defaultChannel}' but channels.${defaultChannel} does not exist`);
344
+ }
345
+ }
346
+ // projects
347
+ if (!config.projects?.defaultPath) {
348
+ reasons.push('Missing projects.defaultPath');
349
+ }
350
+ return { valid: reasons.length === 0, reasons };
351
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Agent Plugin System
3
+ *
4
+ * Provides a lightweight plugin interface for agent integration.
5
+ */
6
+ import { logger } from '../utils/logger.js';
7
+ /**
8
+ * Agent Loader
9
+ *
10
+ * Manages agent plugin registration and creation.
11
+ */
12
+ export class AgentLoader {
13
+ plugins = new Map();
14
+ register(plugin) {
15
+ if (this.plugins.has(plugin.name)) {
16
+ throw new Error(`Agent plugin '${plugin.name}' already registered`);
17
+ }
18
+ this.plugins.set(plugin.name, plugin);
19
+ logger.debug(`Registered agent plugin: ${plugin.name}`);
20
+ }
21
+ createAll(config, callbacks) {
22
+ const instances = [];
23
+ for (const [name, plugin] of this.plugins) {
24
+ if (!plugin.isEnabled(config)) {
25
+ logger.info(`Agent '${name}' is disabled, skipping`);
26
+ continue;
27
+ }
28
+ try {
29
+ const instance = plugin.createAgent(config, callbacks);
30
+ instances.push(instance);
31
+ logger.info(`✓ Agent '${name}' instance created`);
32
+ }
33
+ catch (error) {
34
+ logger.error(`✗ Failed to create agent '${name}':`, error);
35
+ }
36
+ }
37
+ return instances;
38
+ }
39
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Channel Plugin System
3
+ *
4
+ * Provides a lightweight plugin interface for channel integration.
5
+ * Plugins are responsible for creating channel instances only.
6
+ * The main service (index.ts) handles registration and message flow wiring.
7
+ */
8
+ import { logger } from '../utils/logger.js';
9
+ /**
10
+ * Channel Loader
11
+ *
12
+ * Manages channel plugin registration and lifecycle.
13
+ */
14
+ export class ChannelLoader {
15
+ plugins = new Map();
16
+ register(plugin) {
17
+ if (this.plugins.has(plugin.name)) {
18
+ throw new Error(`Channel plugin '${plugin.name}' already registered`);
19
+ }
20
+ this.plugins.set(plugin.name, plugin);
21
+ logger.debug(`Registered channel plugin: ${plugin.name}`);
22
+ }
23
+ async createAll(config) {
24
+ const instances = [];
25
+ for (const [name, plugin] of this.plugins) {
26
+ if (!plugin.isEnabled(config)) {
27
+ logger.info(`Channel '${name}' is disabled, skipping`);
28
+ continue;
29
+ }
30
+ try {
31
+ if (plugin.createChannels) {
32
+ const channelInstances = await plugin.createChannels(config);
33
+ instances.push(...channelInstances);
34
+ logger.info(`✓ Channel '${name}' created ${channelInstances.length} instance(s)`);
35
+ }
36
+ else {
37
+ const instance = await plugin.createChannel(config);
38
+ instances.push(instance);
39
+ logger.info(`✓ Channel '${name}' instance created`);
40
+ }
41
+ }
42
+ catch (error) {
43
+ logger.error(`✗ Failed to create channel '${name}':`, error);
44
+ }
45
+ }
46
+ return instances;
47
+ }
48
+ async connectAll(instances) {
49
+ const results = await Promise.allSettled(instances.map(async (inst) => {
50
+ await inst.connect();
51
+ return inst.adapter.channelName;
52
+ }));
53
+ const connected = results
54
+ .filter((r) => r.status === 'fulfilled')
55
+ .map((r) => r.value);
56
+ const failed = results
57
+ .filter((r) => r.status === 'rejected')
58
+ .map((r) => r.reason);
59
+ if (failed.length > 0) {
60
+ logger.warn(`Some channels failed to connect:`, failed);
61
+ }
62
+ return connected;
63
+ }
64
+ async disconnectAll(instances) {
65
+ await Promise.allSettled(instances.map((inst) => inst.disconnect()));
66
+ }
67
+ }