evolclaw 2.2.0 → 2.4.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 +49 -27
  2. package/data/evolclaw.sample.json +6 -3
  3. package/dist/agents/claude-runner.js +125 -52
  4. package/dist/agents/codex-runner.js +10 -5
  5. package/dist/agents/gemini-runner.js +425 -0
  6. package/dist/channels/aun.js +283 -95
  7. package/dist/channels/feishu.js +556 -96
  8. package/dist/channels/wechat.js +98 -74
  9. package/dist/cli.js +232 -57
  10. package/dist/config.js +185 -31
  11. package/dist/core/channel-loader.js +11 -4
  12. package/dist/core/command-handler.js +803 -247
  13. package/dist/core/interaction-router.js +68 -0
  14. package/dist/core/message/message-bridge.js +217 -0
  15. package/dist/core/{message-processor.js → message/message-processor.js} +411 -124
  16. package/dist/core/{message-queue.js → message/message-queue.js} +1 -1
  17. package/dist/{utils → core/message}/stream-debouncer.js +1 -1
  18. package/dist/{utils → core/message}/stream-flusher.js +73 -13
  19. package/dist/core/permission.js +212 -11
  20. package/dist/core/{adapters → session/adapters}/claude-session-file-adapter.js +2 -2
  21. package/dist/core/{adapters → session/adapters}/codex-session-file-adapter.js +117 -52
  22. package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
  23. package/dist/{utils → core/session}/session-file-health.js +1 -1
  24. package/dist/core/{session-manager.js → session/session-manager.js} +61 -11
  25. package/dist/index.js +140 -57
  26. package/dist/{core/ipc-server.js → ipc.js} +36 -1
  27. package/dist/types.js +3 -0
  28. package/dist/utils/cross-platform.js +38 -1
  29. package/dist/utils/error-utils.js +130 -5
  30. package/dist/utils/init-channel.js +649 -0
  31. package/dist/utils/init.js +55 -150
  32. package/dist/utils/logger.js +8 -3
  33. package/dist/utils/media-cache.js +207 -0
  34. package/dist/{core → utils}/stats-collector.js +16 -0
  35. package/package.json +3 -3
  36. package/dist/core/message-bridge.js +0 -187
  37. package/dist/utils/init-feishu.js +0 -263
  38. package/dist/utils/init-wechat.js +0 -172
  39. package/dist/utils/ipc-client.js +0 -36
  40. package/dist/utils/permission-utils.js +0 -71
  41. /package/dist/{utils → core/message}/message-cache.js +0 -0
  42. /package/dist/{utils → core/message}/stream-idle-monitor.js +0 -0
  43. /package/dist/core/{session-file-adapter.js → session/session-file-adapter.js} +0 -0
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Gemini SessionFileAdapter
3
+ *
4
+ * Reads Gemini CLI session files from ~/.gemini/tmp/{project}/chats/.
5
+ * Gemini stores sessions as JSON files with naming convention:
6
+ * session-{YYYY-MM-DDTHH-mm}-{uuid-prefix}.json
7
+ *
8
+ * Each file contains:
9
+ * { sessionId, projectHash, startTime, lastUpdated, messages, kind }
10
+ *
11
+ * Messages have type: 'user' | 'gemini' (not 'role').
12
+ */
13
+ import { logger } from '../../../utils/logger.js';
14
+ import path from 'path';
15
+ import fs from 'fs';
16
+ import os from 'os';
17
+ export class GeminiSessionFileAdapter {
18
+ agentId = 'gemini';
19
+ getGeminiHome() {
20
+ return path.join(os.homedir(), '.gemini');
21
+ }
22
+ /**
23
+ * Resolve Gemini project key for a project path.
24
+ * Priority:
25
+ * 1. ~/.gemini/projects.json explicit mapping
26
+ * 2. .project_root file content match under ~/.gemini/tmp/<project-key>/
27
+ * 3. basename(projectPath) fallback
28
+ */
29
+ resolveProjectKey(projectPath) {
30
+ const geminiHome = this.getGeminiHome();
31
+ const projectsPath = path.join(geminiHome, 'projects.json');
32
+ try {
33
+ if (fs.existsSync(projectsPath)) {
34
+ const data = JSON.parse(fs.readFileSync(projectsPath, 'utf-8'));
35
+ const mapped = data?.projects?.[projectPath];
36
+ if (typeof mapped === 'string' && mapped.trim())
37
+ return mapped;
38
+ }
39
+ }
40
+ catch (error) {
41
+ logger.debug('[GeminiAdapter] Failed to read projects.json:', error);
42
+ }
43
+ const tmpDir = path.join(geminiHome, 'tmp');
44
+ if (fs.existsSync(tmpDir)) {
45
+ try {
46
+ for (const entry of fs.readdirSync(tmpDir, { withFileTypes: true })) {
47
+ if (!entry.isDirectory())
48
+ continue;
49
+ const rootFile = path.join(tmpDir, entry.name, '.project_root');
50
+ if (!fs.existsSync(rootFile))
51
+ continue;
52
+ try {
53
+ const content = fs.readFileSync(rootFile, 'utf-8').trim();
54
+ if (content === projectPath)
55
+ return entry.name;
56
+ }
57
+ catch {
58
+ // ignore broken .project_root
59
+ }
60
+ }
61
+ }
62
+ catch (error) {
63
+ logger.debug('[GeminiAdapter] Failed to scan tmp project roots:', error);
64
+ }
65
+ }
66
+ return path.basename(projectPath);
67
+ }
68
+ /**
69
+ * Resolve the Gemini chats directory for a given project path.
70
+ */
71
+ resolveChatsDir(projectPath) {
72
+ const geminiHome = this.getGeminiHome();
73
+ const projectKey = this.resolveProjectKey(projectPath);
74
+ return path.join(geminiHome, 'tmp', projectKey, 'chats');
75
+ }
76
+ /**
77
+ * Find the session file matching a given session UUID.
78
+ * Gemini files are named session-{date}-{uuid-prefix}.json where
79
+ * uuid-prefix is the first 8 chars of the session UUID.
80
+ */
81
+ findSessionFile(projectPath, agentSessionId) {
82
+ const chatsDir = this.resolveChatsDir(projectPath);
83
+ if (!fs.existsSync(chatsDir))
84
+ return null;
85
+ const uuidPrefix = agentSessionId.substring(0, 8);
86
+ try {
87
+ const files = fs.readdirSync(chatsDir);
88
+ const match = files.find(f => f.includes(uuidPrefix) && f.endsWith('.json'));
89
+ return match ? path.join(chatsDir, match) : null;
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ readSessionData(projectPath, agentSessionId) {
96
+ const filePath = this.findSessionFile(projectPath, agentSessionId);
97
+ if (!filePath)
98
+ return null;
99
+ try {
100
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
101
+ }
102
+ catch (error) {
103
+ logger.warn(`[GeminiAdapter] Failed to read session file: ${filePath}`, error);
104
+ return null;
105
+ }
106
+ }
107
+ checkExists(projectPath, agentSessionId) {
108
+ return this.findSessionFile(projectPath, agentSessionId) !== null;
109
+ }
110
+ getFileInfo(projectPath, agentSessionId) {
111
+ const data = this.readSessionData(projectPath, agentSessionId);
112
+ if (!data)
113
+ return { turns: 0 };
114
+ const msgs = data.messages || [];
115
+ const userTurns = msgs.filter((m) => m.type === 'user').length;
116
+ // Extract title from first user message
117
+ let title;
118
+ const firstUser = msgs.find((m) => m.type === 'user');
119
+ if (firstUser) {
120
+ const text = Array.isArray(firstUser.content)
121
+ ? firstUser.content[0]?.text || ''
122
+ : String(firstUser.content || '');
123
+ title = text.substring(0, 50).trim() || undefined;
124
+ }
125
+ return { turns: userTurns, title };
126
+ }
127
+ readFirstMessage(projectPath, agentSessionId) {
128
+ return this.readUserMessage(projectPath, agentSessionId, 'first');
129
+ }
130
+ readLastUserMessage(projectPath, agentSessionId) {
131
+ return this.readUserMessage(projectPath, agentSessionId, 'last');
132
+ }
133
+ scanCliSessions(projectPath) {
134
+ const chatsDir = this.resolveChatsDir(projectPath);
135
+ if (!fs.existsSync(chatsDir))
136
+ return [];
137
+ try {
138
+ const files = fs.readdirSync(chatsDir)
139
+ .filter(f => f.startsWith('session-') && f.endsWith('.json'))
140
+ .map(f => {
141
+ const filePath = path.join(chatsDir, f);
142
+ const stat = fs.statSync(filePath);
143
+ // Extract UUID from filename: session-{date}-{uuid-prefix}.json
144
+ // Read the file to get full UUID
145
+ try {
146
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
147
+ return { uuid: data.sessionId, mtime: stat.mtimeMs };
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ })
153
+ .filter((e) => e !== null)
154
+ .sort((a, b) => b.mtime - a.mtime)
155
+ .slice(0, 10);
156
+ return files;
157
+ }
158
+ catch (error) {
159
+ logger.warn('[GeminiAdapter] scanCliSessions failed:', error);
160
+ return [];
161
+ }
162
+ }
163
+ readUserMessage(projectPath, agentSessionId, which) {
164
+ const data = this.readSessionData(projectPath, agentSessionId);
165
+ if (!data)
166
+ return null;
167
+ const msgs = (data.messages || []).filter((m) => m.type === 'user');
168
+ if (msgs.length === 0)
169
+ return null;
170
+ const msg = which === 'first' ? msgs[0] : msgs[msgs.length - 1];
171
+ const text = Array.isArray(msg.content)
172
+ ? msg.content[0]?.text || ''
173
+ : String(msg.content || '');
174
+ const trimmed = text.trim().replace(/\s+/g, ' ');
175
+ return trimmed.substring(0, 50) + (trimmed.length > 50 ? '...' : '');
176
+ }
177
+ }
@@ -1,5 +1,5 @@
1
1
  import fsPromises from 'fs/promises';
2
- import { logger } from './logger.js';
2
+ import { logger } from '../../utils/logger.js';
3
3
  /**
4
4
  * 检查会话文件健康度(接收完整文件路径)
5
5
  */
@@ -1,10 +1,9 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import { ensureDir } from '../config.js';
3
- import { resolvePaths } from '../paths.js';
4
- import { logger } from '../utils/logger.js';
5
- import { encodePath } from '../utils/cross-platform.js';
2
+ import { ensureDir } from '../../config.js';
3
+ import { resolvePaths } from '../../paths.js';
4
+ import { logger } from '../../utils/logger.js';
5
+ import { encodePath } from '../../utils/cross-platform.js';
6
6
  import path from 'path';
7
- import fs from 'fs';
8
7
  import os from 'os';
9
8
  export class SessionManager {
10
9
  db;
@@ -93,12 +92,10 @@ export class SessionManager {
93
92
  const agentId = row.agent_id || 'claude';
94
93
  const adapter = this.getFileAdapter(agentId);
95
94
  if (!adapter) {
96
- // 无适配器时回退到 Claude 路径检查
97
- const sessionFile = this.getSessionFilePath(row.project_path, agentSessionId);
98
- if (fs.existsSync(sessionFile))
99
- return agentSessionId;
95
+ // 无适配器:无法验证文件,信任 DB 记录
96
+ return agentSessionId;
100
97
  }
101
- else if (adapter.checkExists(row.project_path, agentSessionId)) {
98
+ if (adapter.checkExists(row.project_path, agentSessionId)) {
102
99
  return agentSessionId;
103
100
  }
104
101
  logger.warn(`Session file not found for ${agentId}: ${agentSessionId}, clearing session ID`);
@@ -348,6 +345,29 @@ export class SessionManager {
348
345
  CREATE INDEX IF NOT EXISTS idx_session_health_safe_mode ON session_health(safe_mode);
349
346
  `);
350
347
  }
348
+ /**
349
+ * 启动时迁移:将 sessions.channel 从 channelType 回填为实例名(channelName)。
350
+ * 读取每行 metadata.channelName,若与 channel 列不同则更新。
351
+ */
352
+ migrateChannelToInstanceName() {
353
+ const rows = this.db.prepare(`SELECT id, channel, metadata FROM sessions WHERE metadata IS NOT NULL AND metadata != ''`).all();
354
+ let migrated = 0;
355
+ for (const row of rows) {
356
+ try {
357
+ const meta = JSON.parse(row.metadata);
358
+ if (meta.channelName && meta.channelName !== row.channel) {
359
+ this.db.prepare(`UPDATE sessions SET channel = ?, updated_at = ? WHERE id = ?`)
360
+ .run(meta.channelName, Date.now(), row.id);
361
+ migrated++;
362
+ logger.info(`[Migration] Restored channel '${row.channel}' -> '${meta.channelName}' (session ${row.id})`);
363
+ }
364
+ }
365
+ catch { /* skip invalid metadata */ }
366
+ }
367
+ if (migrated > 0) {
368
+ logger.info(`Channel instance name migration completed (${migrated} sessions updated)`);
369
+ }
370
+ }
351
371
  /**
352
372
  * 获取指定渠道所有已知的 thread_id(用于重启后预填充 seenThreads)
353
373
  */
@@ -402,6 +422,12 @@ export class SessionManager {
402
422
  if (threadId) {
403
423
  const session = this.getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId);
404
424
  session.identity = this.resolveIdentity(channel, userId);
425
+ // 新话题会话补写默认权限模式
426
+ if (session.metadata && !session.metadata.permissionMode) {
427
+ session.metadata.permissionMode = session.identity?.role === 'owner' ? 'bypass' : 'readonly';
428
+ this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
429
+ .run(JSON.stringify(session.metadata), Date.now(), session.id);
430
+ }
405
431
  return session;
406
432
  }
407
433
  // 主会话:查找活跃会话
@@ -413,7 +439,7 @@ export class SessionManager {
413
439
  const validSessionId = this.validateSessionFile(active);
414
440
  const session = { ...this.rowToSession(active), agentSessionId: validSessionId };
415
441
  session.identity = this.resolveIdentity(channel, userId);
416
- // 补写 peerId/peerName(私聊 session 可能在此字段引入前创建)
442
+ // 补写 peerId/peerName/channelName(旧 session 可能在这些字段引入前创建)
417
443
  if (chatType === 'private' && userId) {
418
444
  const activeMeta = active.metadata ? JSON.parse(active.metadata) : {};
419
445
  let updated = false;
@@ -425,12 +451,26 @@ export class SessionManager {
425
451
  activeMeta.peerName = metadata.peerName;
426
452
  updated = true;
427
453
  }
454
+ if (metadata?.channelName && activeMeta.channelName !== metadata.channelName) {
455
+ activeMeta.channelName = metadata.channelName;
456
+ updated = true;
457
+ }
428
458
  if (updated) {
429
459
  this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
430
460
  .run(JSON.stringify(activeMeta), Date.now(), active.id);
431
461
  session.metadata = activeMeta;
432
462
  }
433
463
  }
464
+ // 补写 channelName(非私聊时也需要)
465
+ if (metadata?.channelName && chatType !== 'private') {
466
+ const activeMeta = active.metadata ? JSON.parse(active.metadata) : {};
467
+ if (activeMeta.channelName !== metadata.channelName) {
468
+ activeMeta.channelName = metadata.channelName;
469
+ this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
470
+ .run(JSON.stringify(activeMeta), Date.now(), active.id);
471
+ session.metadata = activeMeta;
472
+ }
473
+ }
434
474
  return session;
435
475
  }
436
476
  // 查找默认项目的主会话
@@ -474,6 +514,10 @@ export class SessionManager {
474
514
  updatedAt: Date.now()
475
515
  };
476
516
  session.identity = this.resolveIdentity(channel, userId);
517
+ // 写入默认权限模式(基于角色,只在首次创建时设置)
518
+ if (!sessionMetadata.permissionMode) {
519
+ sessionMetadata.permissionMode = session.identity?.role === 'owner' ? 'bypass' : 'readonly';
520
+ }
477
521
  this.insertSession(session);
478
522
  this.eventBus.publish({
479
523
  type: 'session:created',
@@ -754,6 +798,10 @@ export class SessionManager {
754
798
  .run(JSON.stringify(metadata), Date.now(), targetSessionId);
755
799
  return { ...this.rowToSession(target), metadata, updatedAt: Date.now() };
756
800
  }
801
+ updateMetadata(sessionId, metadata) {
802
+ this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
803
+ .run(JSON.stringify(metadata), Date.now(), sessionId);
804
+ }
757
805
  async renameSession(sessionId, newName) {
758
806
  const result = this.db.prepare(`
759
807
  UPDATE sessions SET name = ?, updated_at = ? WHERE id = ?
@@ -966,6 +1014,8 @@ export class SessionManager {
966
1014
  VALUES (?, 0, 0, ?, ?, ?)
967
1015
  ON CONFLICT(session_id) DO UPDATE SET
968
1016
  consecutive_errors = 0,
1017
+ last_error = NULL,
1018
+ last_error_type = NULL,
969
1019
  last_success_time = ?,
970
1020
  updated_at = ?
971
1021
  `).run(sessionId, now, now, now, now, now);