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.
- package/README.md +49 -27
- package/data/evolclaw.sample.json +6 -3
- package/dist/agents/claude-runner.js +125 -52
- package/dist/agents/codex-runner.js +10 -5
- package/dist/agents/gemini-runner.js +425 -0
- package/dist/channels/aun.js +283 -95
- package/dist/channels/feishu.js +556 -96
- package/dist/channels/wechat.js +98 -74
- package/dist/cli.js +232 -57
- package/dist/config.js +185 -31
- package/dist/core/channel-loader.js +11 -4
- package/dist/core/command-handler.js +803 -247
- package/dist/core/interaction-router.js +68 -0
- package/dist/core/message/message-bridge.js +217 -0
- package/dist/core/{message-processor.js → message/message-processor.js} +411 -124
- package/dist/core/{message-queue.js → message/message-queue.js} +1 -1
- package/dist/{utils → core/message}/stream-debouncer.js +1 -1
- package/dist/{utils → core/message}/stream-flusher.js +73 -13
- package/dist/core/permission.js +212 -11
- package/dist/core/{adapters → session/adapters}/claude-session-file-adapter.js +2 -2
- package/dist/core/{adapters → session/adapters}/codex-session-file-adapter.js +117 -52
- package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
- package/dist/{utils → core/session}/session-file-health.js +1 -1
- package/dist/core/{session-manager.js → session/session-manager.js} +61 -11
- package/dist/index.js +140 -57
- package/dist/{core/ipc-server.js → ipc.js} +36 -1
- package/dist/types.js +3 -0
- package/dist/utils/cross-platform.js +38 -1
- package/dist/utils/error-utils.js +130 -5
- package/dist/utils/init-channel.js +649 -0
- package/dist/utils/init.js +55 -150
- package/dist/utils/logger.js +8 -3
- package/dist/utils/media-cache.js +207 -0
- package/dist/{core → utils}/stats-collector.js +16 -0
- package/package.json +3 -3
- package/dist/core/message-bridge.js +0 -187
- package/dist/utils/init-feishu.js +0 -263
- package/dist/utils/init-wechat.js +0 -172
- package/dist/utils/ipc-client.js +0 -36
- package/dist/utils/permission-utils.js +0 -71
- /package/dist/{utils → core/message}/message-cache.js +0 -0
- /package/dist/{utils → core/message}/stream-idle-monitor.js +0 -0
- /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,10 +1,9 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import { ensureDir } from '
|
|
3
|
-
import { resolvePaths } from '
|
|
4
|
-
import { logger } from '
|
|
5
|
-
import { encodePath } from '
|
|
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
|
-
//
|
|
97
|
-
|
|
98
|
-
if (fs.existsSync(sessionFile))
|
|
99
|
-
return agentSessionId;
|
|
95
|
+
// 无适配器:无法验证文件,信任 DB 记录
|
|
96
|
+
return agentSessionId;
|
|
100
97
|
}
|
|
101
|
-
|
|
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
|
|
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);
|