evolclaw 2.0.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 +191 -0
- package/bin/evolclaw +10 -0
- package/data/evolclaw.sample.json +39 -0
- package/dist/channels/aun.js +28 -0
- package/dist/channels/feishu.js +452 -0
- package/dist/cli.js +759 -0
- package/dist/config.js +81 -0
- package/dist/core/agent-runner.js +326 -0
- package/dist/core/command-handler.js +823 -0
- package/dist/core/message-cache.js +56 -0
- package/dist/core/message-processor.js +516 -0
- package/dist/core/message-queue.js +110 -0
- package/dist/core/message-stream.js +59 -0
- package/dist/core/session-manager.js +803 -0
- package/dist/index.js +239 -0
- package/dist/paths.js +45 -0
- package/dist/types.js +1 -0
- package/dist/utils/error-utils.js +54 -0
- package/dist/utils/init.js +352 -0
- package/dist/utils/logger.js +47 -0
- package/dist/utils/markdown-to-feishu.js +38 -0
- package/dist/utils/permission.js +36 -0
- package/dist/utils/session-file-health.js +67 -0
- package/dist/utils/stream-flusher.js +151 -0
- package/dist/utils/stream-idle-monitor.js +103 -0
- package/package.json +38 -0
|
@@ -0,0 +1,803 @@
|
|
|
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 path from 'path';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
export class SessionManager {
|
|
9
|
+
db;
|
|
10
|
+
constructor(dbPath = resolvePaths().db) {
|
|
11
|
+
ensureDir(path.dirname(dbPath));
|
|
12
|
+
this.db = new DatabaseSync(dbPath);
|
|
13
|
+
this.initDatabase();
|
|
14
|
+
}
|
|
15
|
+
getDatabase() {
|
|
16
|
+
return this.db;
|
|
17
|
+
}
|
|
18
|
+
getProjectDirName(projectPath) {
|
|
19
|
+
return projectPath.replace(/\//g, '-');
|
|
20
|
+
}
|
|
21
|
+
getSessionFilePath(projectPath, sessionId) {
|
|
22
|
+
const homeDir = os.homedir();
|
|
23
|
+
const encodedPath = this.getProjectDirName(projectPath);
|
|
24
|
+
return path.join(homeDir, '.claude', 'projects', encodedPath, `${sessionId}.jsonl`);
|
|
25
|
+
}
|
|
26
|
+
initDatabase() {
|
|
27
|
+
const tableInfo = this.db.prepare('PRAGMA table_info(sessions)').all();
|
|
28
|
+
const hasIsActive = tableInfo.some((col) => col.name === 'is_active');
|
|
29
|
+
const hasName = tableInfo.some((col) => col.name === 'name');
|
|
30
|
+
// 检查是否有唯一约束
|
|
31
|
+
const indexes = this.db.prepare('PRAGMA index_list(sessions)').all();
|
|
32
|
+
const hasUniqueConstraint = indexes.some((idx) => idx.origin === 'u');
|
|
33
|
+
if (!hasIsActive && tableInfo.length > 0) {
|
|
34
|
+
logger.info('Migrating database schema (removing unique constraint)...');
|
|
35
|
+
this.db.exec(`
|
|
36
|
+
CREATE TABLE sessions_new (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
channel TEXT NOT NULL,
|
|
39
|
+
channel_id TEXT NOT NULL,
|
|
40
|
+
project_path TEXT NOT NULL,
|
|
41
|
+
claude_session_id TEXT,
|
|
42
|
+
name TEXT,
|
|
43
|
+
is_active INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
created_at INTEGER NOT NULL,
|
|
45
|
+
updated_at INTEGER NOT NULL
|
|
46
|
+
);
|
|
47
|
+
INSERT INTO sessions_new SELECT id, channel, channel_id, project_path, claude_session_id, NULL, 1, created_at, updated_at FROM sessions;
|
|
48
|
+
DROP TABLE sessions;
|
|
49
|
+
ALTER TABLE sessions_new RENAME TO sessions;
|
|
50
|
+
`);
|
|
51
|
+
logger.info('✓ Database migration completed');
|
|
52
|
+
}
|
|
53
|
+
else if (!hasName && tableInfo.length > 0) {
|
|
54
|
+
logger.info('Adding name column...');
|
|
55
|
+
this.db.exec(`ALTER TABLE sessions ADD COLUMN name TEXT`);
|
|
56
|
+
if (hasUniqueConstraint) {
|
|
57
|
+
logger.info('Removing unique constraint...');
|
|
58
|
+
this.db.exec(`
|
|
59
|
+
CREATE TABLE sessions_new (
|
|
60
|
+
id TEXT PRIMARY KEY,
|
|
61
|
+
channel TEXT NOT NULL,
|
|
62
|
+
channel_id TEXT NOT NULL,
|
|
63
|
+
project_path TEXT NOT NULL,
|
|
64
|
+
claude_session_id TEXT,
|
|
65
|
+
name TEXT,
|
|
66
|
+
is_active INTEGER NOT NULL DEFAULT 0,
|
|
67
|
+
created_at INTEGER NOT NULL,
|
|
68
|
+
updated_at INTEGER NOT NULL
|
|
69
|
+
);
|
|
70
|
+
INSERT INTO sessions_new SELECT * FROM sessions;
|
|
71
|
+
DROP TABLE sessions;
|
|
72
|
+
ALTER TABLE sessions_new RENAME TO sessions;
|
|
73
|
+
`);
|
|
74
|
+
}
|
|
75
|
+
logger.info('✓ Schema updated');
|
|
76
|
+
}
|
|
77
|
+
else if (hasUniqueConstraint) {
|
|
78
|
+
logger.info('Removing stale unique constraint...');
|
|
79
|
+
this.db.exec(`DROP TABLE IF EXISTS sessions_new`);
|
|
80
|
+
this.db.exec(`
|
|
81
|
+
CREATE TABLE sessions_new (
|
|
82
|
+
id TEXT PRIMARY KEY,
|
|
83
|
+
channel TEXT NOT NULL,
|
|
84
|
+
channel_id TEXT NOT NULL,
|
|
85
|
+
project_path TEXT NOT NULL,
|
|
86
|
+
claude_session_id TEXT,
|
|
87
|
+
name TEXT,
|
|
88
|
+
is_active INTEGER NOT NULL DEFAULT 0,
|
|
89
|
+
created_at INTEGER NOT NULL,
|
|
90
|
+
updated_at INTEGER NOT NULL
|
|
91
|
+
);
|
|
92
|
+
INSERT INTO sessions_new (id, channel, channel_id, project_path, claude_session_id, name, is_active, created_at, updated_at)
|
|
93
|
+
SELECT id, channel, channel_id, project_path, claude_session_id, name, is_active, created_at, updated_at FROM sessions;
|
|
94
|
+
DROP TABLE sessions;
|
|
95
|
+
ALTER TABLE sessions_new RENAME TO sessions;
|
|
96
|
+
`);
|
|
97
|
+
logger.info('✓ Unique constraint removed');
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
this.db.exec(`
|
|
101
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
102
|
+
id TEXT PRIMARY KEY,
|
|
103
|
+
channel TEXT NOT NULL,
|
|
104
|
+
channel_id TEXT NOT NULL,
|
|
105
|
+
project_path TEXT NOT NULL,
|
|
106
|
+
claude_session_id TEXT,
|
|
107
|
+
name TEXT,
|
|
108
|
+
is_active INTEGER NOT NULL DEFAULT 0,
|
|
109
|
+
created_at INTEGER NOT NULL,
|
|
110
|
+
updated_at INTEGER NOT NULL
|
|
111
|
+
)
|
|
112
|
+
`);
|
|
113
|
+
}
|
|
114
|
+
// 创建消息去重表
|
|
115
|
+
this.db.exec(`
|
|
116
|
+
CREATE TABLE IF NOT EXISTS processed_messages (
|
|
117
|
+
message_id TEXT PRIMARY KEY,
|
|
118
|
+
channel TEXT NOT NULL,
|
|
119
|
+
channel_id TEXT NOT NULL,
|
|
120
|
+
processed_at INTEGER NOT NULL
|
|
121
|
+
);
|
|
122
|
+
CREATE INDEX IF NOT EXISTS idx_processed_at ON processed_messages(processed_at);
|
|
123
|
+
`);
|
|
124
|
+
// 创建会话健康状态表
|
|
125
|
+
this.db.exec(`
|
|
126
|
+
CREATE TABLE IF NOT EXISTS session_health (
|
|
127
|
+
session_id TEXT PRIMARY KEY,
|
|
128
|
+
consecutive_errors INTEGER NOT NULL DEFAULT 0,
|
|
129
|
+
last_error TEXT,
|
|
130
|
+
last_error_type TEXT,
|
|
131
|
+
safe_mode INTEGER NOT NULL DEFAULT 0,
|
|
132
|
+
last_success_time INTEGER NOT NULL,
|
|
133
|
+
created_at INTEGER NOT NULL,
|
|
134
|
+
updated_at INTEGER NOT NULL,
|
|
135
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
136
|
+
);
|
|
137
|
+
CREATE INDEX IF NOT EXISTS idx_session_health_safe_mode ON session_health(safe_mode);
|
|
138
|
+
`);
|
|
139
|
+
}
|
|
140
|
+
async getOrCreateSession(channel, channelId, defaultProjectPath, name) {
|
|
141
|
+
// 1. 查找该聊天的活跃会话
|
|
142
|
+
const active = this.db.prepare(`
|
|
143
|
+
SELECT * FROM sessions
|
|
144
|
+
WHERE channel = ? AND channel_id = ? AND is_active = 1
|
|
145
|
+
`).get(channel, channelId);
|
|
146
|
+
if (active) {
|
|
147
|
+
// 验证会话文件是否存在
|
|
148
|
+
let validSessionId = active.claude_session_id;
|
|
149
|
+
if (validSessionId) {
|
|
150
|
+
const sessionFile = this.getSessionFilePath(active.project_path, validSessionId);
|
|
151
|
+
if (!fs.existsSync(sessionFile)) {
|
|
152
|
+
logger.warn(`Session file not found: ${sessionFile}`);
|
|
153
|
+
validSessionId = null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
id: active.id,
|
|
158
|
+
channel: active.channel,
|
|
159
|
+
channelId: active.channel_id,
|
|
160
|
+
projectPath: active.project_path,
|
|
161
|
+
claudeSessionId: validSessionId,
|
|
162
|
+
name: active.name,
|
|
163
|
+
isActive: active.is_active === 1,
|
|
164
|
+
createdAt: active.created_at,
|
|
165
|
+
updatedAt: active.updated_at
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// 2. 没有活跃会话,查找该聊天在默认项目的会话
|
|
169
|
+
const existing = this.db.prepare(`
|
|
170
|
+
SELECT * FROM sessions
|
|
171
|
+
WHERE channel = ? AND channel_id = ? AND project_path = ?
|
|
172
|
+
ORDER BY updated_at DESC LIMIT 1
|
|
173
|
+
`).get(channel, channelId, defaultProjectPath);
|
|
174
|
+
if (existing) {
|
|
175
|
+
// 验证会话文件是否存在
|
|
176
|
+
let validSessionId = existing.claude_session_id;
|
|
177
|
+
if (validSessionId) {
|
|
178
|
+
const sessionFile = this.getSessionFilePath(existing.project_path, validSessionId);
|
|
179
|
+
if (!fs.existsSync(sessionFile)) {
|
|
180
|
+
logger.warn(`Session file not found: ${sessionFile}, clearing session ID`);
|
|
181
|
+
validSessionId = null;
|
|
182
|
+
this.db.prepare(`UPDATE sessions SET claude_session_id = NULL WHERE id = ?`).run(existing.id);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// 激活该会话
|
|
186
|
+
this.db.prepare(`
|
|
187
|
+
UPDATE sessions SET is_active = 1, updated_at = ?
|
|
188
|
+
WHERE id = ?
|
|
189
|
+
`).run(Date.now(), existing.id);
|
|
190
|
+
return {
|
|
191
|
+
id: existing.id,
|
|
192
|
+
channel: existing.channel,
|
|
193
|
+
channelId: existing.channel_id,
|
|
194
|
+
projectPath: existing.project_path,
|
|
195
|
+
claudeSessionId: validSessionId,
|
|
196
|
+
name: existing.name,
|
|
197
|
+
isActive: true,
|
|
198
|
+
createdAt: existing.created_at,
|
|
199
|
+
updatedAt: existing.updated_at
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// 3. 创建新会话(默认为活跃)
|
|
203
|
+
const session = {
|
|
204
|
+
id: `${channel}-${channelId}-${Date.now()}`,
|
|
205
|
+
channel,
|
|
206
|
+
channelId,
|
|
207
|
+
projectPath: defaultProjectPath,
|
|
208
|
+
name: name || '默认会话',
|
|
209
|
+
isActive: true,
|
|
210
|
+
createdAt: Date.now(),
|
|
211
|
+
updatedAt: Date.now()
|
|
212
|
+
};
|
|
213
|
+
// 使用 INSERT OR IGNORE 避免并发时的 UNIQUE 约束冲突
|
|
214
|
+
const result = this.db.prepare(`
|
|
215
|
+
INSERT OR IGNORE INTO sessions (id, channel, channel_id, project_path, claude_session_id, name, is_active, created_at, updated_at)
|
|
216
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
217
|
+
`).run(session.id, session.channel, session.channelId, session.projectPath, session.claudeSessionId ?? null, session.name ?? null, 1, session.createdAt, session.updatedAt);
|
|
218
|
+
// 如果插入被忽略(已存在),重新查询
|
|
219
|
+
if (result.changes === 0) {
|
|
220
|
+
const existing = this.db.prepare(`
|
|
221
|
+
SELECT * FROM sessions
|
|
222
|
+
WHERE channel = ? AND channel_id = ? AND project_path = ?
|
|
223
|
+
`).get(channel, channelId, defaultProjectPath);
|
|
224
|
+
if (existing) {
|
|
225
|
+
this.db.prepare(`UPDATE sessions SET is_active = 1, updated_at = ? WHERE id = ?`).run(Date.now(), existing.id);
|
|
226
|
+
return {
|
|
227
|
+
id: existing.id,
|
|
228
|
+
channel: existing.channel,
|
|
229
|
+
channelId: existing.channel_id,
|
|
230
|
+
projectPath: existing.project_path,
|
|
231
|
+
claudeSessionId: existing.claude_session_id,
|
|
232
|
+
name: existing.name,
|
|
233
|
+
isActive: true,
|
|
234
|
+
createdAt: existing.created_at,
|
|
235
|
+
updatedAt: Date.now()
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return session;
|
|
240
|
+
}
|
|
241
|
+
async updateSession(sessionId, updates) {
|
|
242
|
+
const fields = Object.keys(updates).filter(k => k !== 'id').map(k => `${k} = ?`).join(', ');
|
|
243
|
+
const values = Object.keys(updates).filter(k => k !== 'id').map(k => {
|
|
244
|
+
const v = updates[k];
|
|
245
|
+
if (v === undefined)
|
|
246
|
+
return null;
|
|
247
|
+
if (typeof v === 'boolean')
|
|
248
|
+
return v ? 1 : 0;
|
|
249
|
+
return v;
|
|
250
|
+
});
|
|
251
|
+
this.db.prepare(`UPDATE sessions SET ${fields}, updated_at = ? WHERE id = ?`).run(...values, Date.now(), sessionId);
|
|
252
|
+
}
|
|
253
|
+
async switchProject(channel, channelId, newProjectPath) {
|
|
254
|
+
// 1. 取消当前活跃会话
|
|
255
|
+
this.db.prepare(`
|
|
256
|
+
UPDATE sessions SET is_active = 0, updated_at = ?
|
|
257
|
+
WHERE channel = ? AND channel_id = ? AND is_active = 1
|
|
258
|
+
`).run(Date.now(), channel, channelId);
|
|
259
|
+
// 2. 查找目标项目的会话
|
|
260
|
+
const target = this.db.prepare(`
|
|
261
|
+
SELECT * FROM sessions
|
|
262
|
+
WHERE channel = ? AND channel_id = ? AND project_path = ?
|
|
263
|
+
ORDER BY updated_at DESC LIMIT 1
|
|
264
|
+
`).get(channel, channelId, newProjectPath);
|
|
265
|
+
if (target) {
|
|
266
|
+
// 验证会话文件是否存在
|
|
267
|
+
let validSessionId = target.claude_session_id;
|
|
268
|
+
if (validSessionId) {
|
|
269
|
+
const sessionFile = this.getSessionFilePath(newProjectPath, validSessionId);
|
|
270
|
+
if (!fs.existsSync(sessionFile)) {
|
|
271
|
+
logger.warn(`Session file not found: ${sessionFile}, clearing session ID`);
|
|
272
|
+
validSessionId = null;
|
|
273
|
+
this.db.prepare(`UPDATE sessions SET claude_session_id = NULL WHERE id = ?`).run(target.id);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// 激活已有会话
|
|
277
|
+
this.db.prepare(`
|
|
278
|
+
UPDATE sessions SET is_active = 1, updated_at = ?
|
|
279
|
+
WHERE id = ?
|
|
280
|
+
`).run(Date.now(), target.id);
|
|
281
|
+
return {
|
|
282
|
+
id: target.id,
|
|
283
|
+
channel: target.channel,
|
|
284
|
+
channelId: target.channel_id,
|
|
285
|
+
projectPath: target.project_path,
|
|
286
|
+
claudeSessionId: validSessionId,
|
|
287
|
+
name: target.name,
|
|
288
|
+
isActive: true,
|
|
289
|
+
createdAt: target.created_at,
|
|
290
|
+
updatedAt: target.updated_at
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
// 3. 创建新会话
|
|
294
|
+
const session = {
|
|
295
|
+
id: `${channel}-${channelId}-${Date.now()}`,
|
|
296
|
+
channel,
|
|
297
|
+
channelId,
|
|
298
|
+
projectPath: newProjectPath,
|
|
299
|
+
name: '默认会话',
|
|
300
|
+
isActive: true,
|
|
301
|
+
createdAt: Date.now(),
|
|
302
|
+
updatedAt: Date.now()
|
|
303
|
+
};
|
|
304
|
+
this.db.prepare(`
|
|
305
|
+
INSERT OR IGNORE INTO sessions (id, channel, channel_id, project_path, claude_session_id, name, is_active, created_at, updated_at)
|
|
306
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
307
|
+
`).run(session.id, session.channel, session.channelId, session.projectPath, null, session.name ?? null, 1, session.createdAt, session.updatedAt);
|
|
308
|
+
return session;
|
|
309
|
+
}
|
|
310
|
+
async updateProjectPath(channel, channelId, projectPath) {
|
|
311
|
+
this.db.prepare('UPDATE sessions SET project_path = ?, updated_at = ? WHERE channel = ? AND channel_id = ?')
|
|
312
|
+
.run(projectPath, Date.now(), channel, channelId);
|
|
313
|
+
}
|
|
314
|
+
async updateClaudeSessionId(channel, channelId, claudeSessionId) {
|
|
315
|
+
// 只更新当前活跃会话的 Claude Session ID
|
|
316
|
+
this.db.prepare(`
|
|
317
|
+
UPDATE sessions
|
|
318
|
+
SET claude_session_id = ?, updated_at = ?
|
|
319
|
+
WHERE channel = ? AND channel_id = ? AND is_active = 1
|
|
320
|
+
`).run(claudeSessionId, Date.now(), channel, channelId);
|
|
321
|
+
}
|
|
322
|
+
async updateClaudeSessionIdBySessionId(sessionId, claudeSessionId) {
|
|
323
|
+
// 根据 sessionId 直接更新
|
|
324
|
+
logger.info(`[SessionManager] Updating claude_session_id: sessionId=${sessionId}, claudeSessionId=${claudeSessionId}`);
|
|
325
|
+
this.db.prepare(`
|
|
326
|
+
UPDATE sessions
|
|
327
|
+
SET claude_session_id = ?, updated_at = ?
|
|
328
|
+
WHERE id = ?
|
|
329
|
+
`).run(claudeSessionId, Date.now(), sessionId);
|
|
330
|
+
}
|
|
331
|
+
async clearActiveSession(channel, channelId) {
|
|
332
|
+
// 清除当前活跃会话的 Claude Session ID
|
|
333
|
+
this.db.prepare(`
|
|
334
|
+
UPDATE sessions
|
|
335
|
+
SET claude_session_id = NULL, updated_at = ?
|
|
336
|
+
WHERE channel = ? AND channel_id = ? AND is_active = 1
|
|
337
|
+
`).run(Date.now(), channel, channelId);
|
|
338
|
+
}
|
|
339
|
+
async clearClaudeSessionId(channel, channelId) {
|
|
340
|
+
// 向后兼容的别名
|
|
341
|
+
await this.clearActiveSession(channel, channelId);
|
|
342
|
+
}
|
|
343
|
+
async getSession(channel, channelId) {
|
|
344
|
+
// 获取活跃会话
|
|
345
|
+
const row = this.db.prepare(`
|
|
346
|
+
SELECT * FROM sessions
|
|
347
|
+
WHERE channel = ? AND channel_id = ? AND is_active = 1
|
|
348
|
+
`).get(channel, channelId);
|
|
349
|
+
if (!row)
|
|
350
|
+
return undefined;
|
|
351
|
+
return {
|
|
352
|
+
id: row.id,
|
|
353
|
+
channel: row.channel,
|
|
354
|
+
channelId: row.channel_id,
|
|
355
|
+
projectPath: row.project_path,
|
|
356
|
+
claudeSessionId: row.claude_session_id,
|
|
357
|
+
name: row.name,
|
|
358
|
+
isActive: row.is_active === 1,
|
|
359
|
+
createdAt: row.created_at,
|
|
360
|
+
updatedAt: row.updated_at
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* 获取活跃会话(getSession 的别名,语义更清晰)
|
|
365
|
+
*/
|
|
366
|
+
async getActiveSession(channel, channelId) {
|
|
367
|
+
return this.getSession(channel, channelId);
|
|
368
|
+
}
|
|
369
|
+
async listSessions(channel, channelId) {
|
|
370
|
+
// 列出该聊天的所有会话
|
|
371
|
+
const rows = this.db.prepare(`
|
|
372
|
+
SELECT * FROM sessions
|
|
373
|
+
WHERE channel = ? AND channel_id = ?
|
|
374
|
+
ORDER BY updated_at DESC
|
|
375
|
+
`).all(channel, channelId);
|
|
376
|
+
return rows.map(row => ({
|
|
377
|
+
id: row.id,
|
|
378
|
+
channel: row.channel,
|
|
379
|
+
channelId: row.channel_id,
|
|
380
|
+
projectPath: row.project_path,
|
|
381
|
+
claudeSessionId: row.claude_session_id,
|
|
382
|
+
name: row.name,
|
|
383
|
+
isActive: row.is_active === 1,
|
|
384
|
+
createdAt: row.created_at,
|
|
385
|
+
updatedAt: row.updated_at
|
|
386
|
+
}));
|
|
387
|
+
}
|
|
388
|
+
async getSessionByProjectPath(channel, channelId, projectPath) {
|
|
389
|
+
const row = this.db.prepare(`
|
|
390
|
+
SELECT * FROM sessions
|
|
391
|
+
WHERE channel = ? AND channel_id = ? AND project_path = ?
|
|
392
|
+
`).get(channel, channelId, projectPath);
|
|
393
|
+
if (!row)
|
|
394
|
+
return undefined;
|
|
395
|
+
return {
|
|
396
|
+
id: row.id,
|
|
397
|
+
channel: row.channel,
|
|
398
|
+
channelId: row.channel_id,
|
|
399
|
+
projectPath: row.project_path,
|
|
400
|
+
claudeSessionId: row.claude_session_id,
|
|
401
|
+
name: row.name,
|
|
402
|
+
isActive: row.is_active === 1,
|
|
403
|
+
createdAt: row.created_at,
|
|
404
|
+
updatedAt: row.updated_at
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
async getSessionByName(channel, channelId, name) {
|
|
408
|
+
const row = this.db.prepare(`
|
|
409
|
+
SELECT * FROM sessions
|
|
410
|
+
WHERE channel = ? AND channel_id = ? AND name = ?
|
|
411
|
+
`).get(channel, channelId, name);
|
|
412
|
+
if (!row)
|
|
413
|
+
return undefined;
|
|
414
|
+
return {
|
|
415
|
+
id: row.id,
|
|
416
|
+
channel: row.channel,
|
|
417
|
+
channelId: row.channel_id,
|
|
418
|
+
projectPath: row.project_path,
|
|
419
|
+
claudeSessionId: row.claude_session_id,
|
|
420
|
+
name: row.name,
|
|
421
|
+
isActive: row.is_active === 1,
|
|
422
|
+
createdAt: row.created_at,
|
|
423
|
+
updatedAt: row.updated_at
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
async switchToSession(channel, channelId, targetSessionId) {
|
|
427
|
+
// 验证目标会话存在
|
|
428
|
+
const target = this.db.prepare(`
|
|
429
|
+
SELECT * FROM sessions WHERE id = ? AND channel = ? AND channel_id = ?
|
|
430
|
+
`).get(targetSessionId, channel, channelId);
|
|
431
|
+
if (!target)
|
|
432
|
+
return null;
|
|
433
|
+
// 取消当前活跃会话
|
|
434
|
+
this.db.prepare(`
|
|
435
|
+
UPDATE sessions SET is_active = 0, updated_at = ?
|
|
436
|
+
WHERE channel = ? AND channel_id = ? AND is_active = 1
|
|
437
|
+
`).run(Date.now(), channel, channelId);
|
|
438
|
+
// 激活目标会话
|
|
439
|
+
this.db.prepare(`
|
|
440
|
+
UPDATE sessions SET is_active = 1, updated_at = ?
|
|
441
|
+
WHERE id = ?
|
|
442
|
+
`).run(Date.now(), targetSessionId);
|
|
443
|
+
return {
|
|
444
|
+
id: target.id,
|
|
445
|
+
channel: target.channel,
|
|
446
|
+
channelId: target.channel_id,
|
|
447
|
+
projectPath: target.project_path,
|
|
448
|
+
claudeSessionId: target.claude_session_id,
|
|
449
|
+
name: target.name,
|
|
450
|
+
isActive: true,
|
|
451
|
+
createdAt: target.created_at,
|
|
452
|
+
updatedAt: Date.now()
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
async renameSession(sessionId, newName) {
|
|
456
|
+
const result = this.db.prepare(`
|
|
457
|
+
UPDATE sessions SET name = ?, updated_at = ? WHERE id = ?
|
|
458
|
+
`).run(newName, Date.now(), sessionId);
|
|
459
|
+
return result.changes > 0;
|
|
460
|
+
}
|
|
461
|
+
async createNewSession(channel, channelId, projectPath, name) {
|
|
462
|
+
// 取消当前活跃会话
|
|
463
|
+
this.db.prepare(`
|
|
464
|
+
UPDATE sessions SET is_active = 0, updated_at = ?
|
|
465
|
+
WHERE channel = ? AND channel_id = ? AND is_active = 1
|
|
466
|
+
`).run(Date.now(), channel, channelId);
|
|
467
|
+
// 创建新会话
|
|
468
|
+
const session = {
|
|
469
|
+
id: `${channel}-${channelId}-${Date.now()}`,
|
|
470
|
+
channel,
|
|
471
|
+
channelId,
|
|
472
|
+
projectPath,
|
|
473
|
+
name: name || '默认会话',
|
|
474
|
+
isActive: true,
|
|
475
|
+
createdAt: Date.now(),
|
|
476
|
+
updatedAt: Date.now()
|
|
477
|
+
};
|
|
478
|
+
this.db.prepare(`
|
|
479
|
+
INSERT INTO sessions (id, channel, channel_id, project_path, claude_session_id, name, is_active, created_at, updated_at)
|
|
480
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
481
|
+
`).run(session.id, session.channel, session.channelId, session.projectPath, null, session.name ?? null, 1, session.createdAt, session.updatedAt);
|
|
482
|
+
return session;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* 基于现有会话创建分支会话
|
|
486
|
+
*/
|
|
487
|
+
async createForkedSession(sourceSession, forkedClaudeSessionId, name) {
|
|
488
|
+
// 取消当前活跃会话
|
|
489
|
+
this.db.prepare(`
|
|
490
|
+
UPDATE sessions SET is_active = 0, updated_at = ?
|
|
491
|
+
WHERE channel = ? AND channel_id = ? AND is_active = 1
|
|
492
|
+
`).run(Date.now(), sourceSession.channel, sourceSession.channelId);
|
|
493
|
+
const session = {
|
|
494
|
+
id: `${sourceSession.channel}-${sourceSession.channelId}-${Date.now()}`,
|
|
495
|
+
channel: sourceSession.channel,
|
|
496
|
+
channelId: sourceSession.channelId,
|
|
497
|
+
projectPath: sourceSession.projectPath,
|
|
498
|
+
claudeSessionId: forkedClaudeSessionId,
|
|
499
|
+
name: name || `${sourceSession.name || '会话'}-分支`,
|
|
500
|
+
isActive: true,
|
|
501
|
+
createdAt: Date.now(),
|
|
502
|
+
updatedAt: Date.now()
|
|
503
|
+
};
|
|
504
|
+
this.db.prepare(`
|
|
505
|
+
INSERT INTO sessions (id, channel, channel_id, project_path, claude_session_id, name, is_active, created_at, updated_at)
|
|
506
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
507
|
+
`).run(session.id, session.channel, session.channelId, session.projectPath, session.claudeSessionId ?? null, session.name ?? null, 1, session.createdAt, session.updatedAt);
|
|
508
|
+
return session;
|
|
509
|
+
}
|
|
510
|
+
async scanCliSessions(projectPath) {
|
|
511
|
+
const homeDir = os.homedir();
|
|
512
|
+
const encodedPath = this.getProjectDirName(projectPath);
|
|
513
|
+
const sessionDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
|
514
|
+
if (!fs.existsSync(sessionDir))
|
|
515
|
+
return [];
|
|
516
|
+
const files = fs.readdirSync(sessionDir)
|
|
517
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
518
|
+
.filter(f => !f.startsWith('agent-')) // 过滤子代理会话
|
|
519
|
+
.map(f => {
|
|
520
|
+
const filePath = path.join(sessionDir, f);
|
|
521
|
+
const stat = fs.statSync(filePath);
|
|
522
|
+
return { uuid: f.replace('.jsonl', ''), mtime: stat.mtimeMs, size: stat.size };
|
|
523
|
+
})
|
|
524
|
+
.filter(f => f.size > 0) // 过滤空文件
|
|
525
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
526
|
+
.slice(0, 10);
|
|
527
|
+
return files.map(f => ({ uuid: f.uuid, mtime: f.mtime }));
|
|
528
|
+
}
|
|
529
|
+
checkSessionFileExists(projectPath, claudeSessionId) {
|
|
530
|
+
const sessionFile = this.getSessionFilePath(projectPath, claudeSessionId);
|
|
531
|
+
return fs.existsSync(sessionFile);
|
|
532
|
+
}
|
|
533
|
+
readSessionFirstMessage(projectPath, claudeSessionId) {
|
|
534
|
+
const sessionFile = this.getSessionFilePath(projectPath, claudeSessionId);
|
|
535
|
+
if (!fs.existsSync(sessionFile))
|
|
536
|
+
return null;
|
|
537
|
+
try {
|
|
538
|
+
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
539
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
540
|
+
for (const line of lines) {
|
|
541
|
+
const event = JSON.parse(line);
|
|
542
|
+
// 格式: {type: "user", message: {role: "user", content: ...}}
|
|
543
|
+
if (event.type === 'user' && event.message?.role === 'user') {
|
|
544
|
+
const messageContent = event.message.content;
|
|
545
|
+
// content 可能是字符串或数组
|
|
546
|
+
if (typeof messageContent === 'string') {
|
|
547
|
+
const text = messageContent.trim();
|
|
548
|
+
return text.substring(0, 50) + (text.length > 50 ? '...' : '');
|
|
549
|
+
}
|
|
550
|
+
else if (Array.isArray(messageContent)) {
|
|
551
|
+
const textContent = messageContent.find((c) => c.type === 'text');
|
|
552
|
+
if (textContent?.text) {
|
|
553
|
+
const text = textContent.text.trim();
|
|
554
|
+
return text.substring(0, 50) + (text.length > 50 ? '...' : '');
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
logger.warn(`Failed to read session file: ${sessionFile}`, error);
|
|
562
|
+
}
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
readSessionLastUserMessage(projectPath, claudeSessionId) {
|
|
566
|
+
const sessionFile = this.getSessionFilePath(projectPath, claudeSessionId);
|
|
567
|
+
if (!fs.existsSync(sessionFile))
|
|
568
|
+
return null;
|
|
569
|
+
try {
|
|
570
|
+
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
571
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
572
|
+
let lastMessage = null;
|
|
573
|
+
for (const line of lines) {
|
|
574
|
+
const event = JSON.parse(line);
|
|
575
|
+
if (event.type === 'user' && event.message?.role === 'user') {
|
|
576
|
+
const messageContent = event.message.content;
|
|
577
|
+
if (typeof messageContent === 'string') {
|
|
578
|
+
const text = messageContent.trim();
|
|
579
|
+
lastMessage = text.substring(0, 50) + (text.length > 50 ? '...' : '');
|
|
580
|
+
}
|
|
581
|
+
else if (Array.isArray(messageContent)) {
|
|
582
|
+
const textContent = messageContent.find((c) => c.type === 'text');
|
|
583
|
+
if (textContent?.text) {
|
|
584
|
+
const text = textContent.text.trim();
|
|
585
|
+
lastMessage = text.substring(0, 50) + (text.length > 50 ? '...' : '');
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return lastMessage;
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
logger.warn(`Failed to read last message from session file: ${sessionFile}`, error);
|
|
594
|
+
}
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* 获取会话文件信息(回合数 + 标题)
|
|
599
|
+
*/
|
|
600
|
+
getSessionFileInfo(projectPath, claudeSessionId) {
|
|
601
|
+
const sessionFile = this.getSessionFilePath(projectPath, claudeSessionId);
|
|
602
|
+
if (!fs.existsSync(sessionFile))
|
|
603
|
+
return { turns: 0 };
|
|
604
|
+
try {
|
|
605
|
+
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
606
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
607
|
+
let turns = 0;
|
|
608
|
+
let title;
|
|
609
|
+
for (const line of lines) {
|
|
610
|
+
const event = JSON.parse(line);
|
|
611
|
+
if (event.type === 'user' && event.message?.role === 'user') {
|
|
612
|
+
turns++;
|
|
613
|
+
}
|
|
614
|
+
// 提取会话标题(从 session 元数据中)
|
|
615
|
+
if (event.title && !title) {
|
|
616
|
+
title = event.title;
|
|
617
|
+
}
|
|
618
|
+
if (event.sessionTitle && !title) {
|
|
619
|
+
title = event.sessionTitle;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return { turns, title };
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
logger.warn(`Failed to read session file info: ${sessionFile}`, error);
|
|
626
|
+
return { turns: 0 };
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* 统计会话回合数(用户消息数)— 兼容旧调用
|
|
631
|
+
*/
|
|
632
|
+
countSessionTurns(projectPath, claudeSessionId) {
|
|
633
|
+
return this.getSessionFileInfo(projectPath, claudeSessionId).turns;
|
|
634
|
+
}
|
|
635
|
+
async getSessionByUuidPrefix(channel, channelId, uuidPrefix) {
|
|
636
|
+
const rows = this.db.prepare(`
|
|
637
|
+
SELECT * FROM sessions
|
|
638
|
+
WHERE channel = ? AND channel_id = ? AND claude_session_id LIKE ?
|
|
639
|
+
`).all(channel, channelId, `${uuidPrefix}%`);
|
|
640
|
+
if (rows.length === 0)
|
|
641
|
+
return undefined;
|
|
642
|
+
if (rows.length > 1) {
|
|
643
|
+
logger.warn(`Multiple sessions found with UUID prefix: ${uuidPrefix}`);
|
|
644
|
+
}
|
|
645
|
+
const row = rows[0];
|
|
646
|
+
return {
|
|
647
|
+
id: row.id,
|
|
648
|
+
channel: row.channel,
|
|
649
|
+
channelId: row.channel_id,
|
|
650
|
+
projectPath: row.project_path,
|
|
651
|
+
claudeSessionId: row.claude_session_id,
|
|
652
|
+
name: row.name,
|
|
653
|
+
isActive: row.is_active === 1,
|
|
654
|
+
createdAt: row.created_at,
|
|
655
|
+
updatedAt: row.updated_at
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
async importCliSession(channel, channelId, projectPath, claudeSessionId) {
|
|
659
|
+
// 检查是否已存在相同项目路径的会话
|
|
660
|
+
const existingByPath = this.db.prepare(`
|
|
661
|
+
SELECT * FROM sessions
|
|
662
|
+
WHERE channel = ? AND channel_id = ? AND project_path = ?
|
|
663
|
+
`).get(channel, channelId, projectPath);
|
|
664
|
+
if (existingByPath) {
|
|
665
|
+
// 更新 claude_session_id 并激活
|
|
666
|
+
this.db.prepare(`
|
|
667
|
+
UPDATE sessions SET is_active = 0, updated_at = ?
|
|
668
|
+
WHERE channel = ? AND channel_id = ? AND is_active = 1 AND id != ?
|
|
669
|
+
`).run(Date.now(), channel, channelId, existingByPath.id);
|
|
670
|
+
this.db.prepare(`
|
|
671
|
+
UPDATE sessions SET claude_session_id = ?, is_active = 1, updated_at = ?
|
|
672
|
+
WHERE id = ?
|
|
673
|
+
`).run(claudeSessionId, Date.now(), existingByPath.id);
|
|
674
|
+
return {
|
|
675
|
+
id: existingByPath.id,
|
|
676
|
+
channel: existingByPath.channel,
|
|
677
|
+
channelId: existingByPath.channel_id,
|
|
678
|
+
projectPath: existingByPath.project_path,
|
|
679
|
+
claudeSessionId,
|
|
680
|
+
name: existingByPath.name,
|
|
681
|
+
isActive: true,
|
|
682
|
+
createdAt: existingByPath.created_at,
|
|
683
|
+
updatedAt: Date.now()
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
// 取消当前活跃会话
|
|
687
|
+
this.db.prepare(`
|
|
688
|
+
UPDATE sessions SET is_active = 0, updated_at = ?
|
|
689
|
+
WHERE channel = ? AND channel_id = ? AND is_active = 1
|
|
690
|
+
`).run(Date.now(), channel, channelId);
|
|
691
|
+
// 创建会话记录
|
|
692
|
+
const session = {
|
|
693
|
+
id: `${channel}-${channelId}-${Date.now()}`,
|
|
694
|
+
channel,
|
|
695
|
+
channelId,
|
|
696
|
+
projectPath,
|
|
697
|
+
claudeSessionId,
|
|
698
|
+
name: `CLI会话-${new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`,
|
|
699
|
+
isActive: true,
|
|
700
|
+
createdAt: Date.now(),
|
|
701
|
+
updatedAt: Date.now()
|
|
702
|
+
};
|
|
703
|
+
this.db.prepare(`
|
|
704
|
+
INSERT OR IGNORE INTO sessions (id, channel, channel_id, project_path, claude_session_id, name, is_active, created_at, updated_at)
|
|
705
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
706
|
+
`).run(session.id, session.channel, session.channelId, session.projectPath, session.claudeSessionId ?? null, session.name ?? null, 1, session.createdAt, session.updatedAt);
|
|
707
|
+
return session;
|
|
708
|
+
}
|
|
709
|
+
// ==================== 健康状态管理 ====================
|
|
710
|
+
/**
|
|
711
|
+
* 获取会话健康状态
|
|
712
|
+
*/
|
|
713
|
+
async getHealthStatus(sessionId) {
|
|
714
|
+
const row = this.db.prepare(`
|
|
715
|
+
SELECT * FROM session_health WHERE session_id = ?
|
|
716
|
+
`).get(sessionId);
|
|
717
|
+
if (!row) {
|
|
718
|
+
// 首次查询,创建默认记录
|
|
719
|
+
const now = Date.now();
|
|
720
|
+
this.db.prepare(`
|
|
721
|
+
INSERT INTO session_health (session_id, consecutive_errors, safe_mode, last_success_time, created_at, updated_at)
|
|
722
|
+
VALUES (?, 0, 0, ?, ?, ?)
|
|
723
|
+
`).run(sessionId, now, now, now);
|
|
724
|
+
return {
|
|
725
|
+
consecutiveErrors: 0,
|
|
726
|
+
safeMode: false,
|
|
727
|
+
lastSuccessTime: now
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
consecutiveErrors: row.consecutive_errors,
|
|
732
|
+
lastError: row.last_error,
|
|
733
|
+
lastErrorType: row.last_error_type,
|
|
734
|
+
safeMode: row.safe_mode === 1,
|
|
735
|
+
lastSuccessTime: row.last_success_time
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* 记录成功响应(重置错误计数)
|
|
740
|
+
*/
|
|
741
|
+
async recordSuccess(sessionId) {
|
|
742
|
+
const now = Date.now();
|
|
743
|
+
this.db.prepare(`
|
|
744
|
+
INSERT INTO session_health (session_id, consecutive_errors, safe_mode, last_success_time, created_at, updated_at)
|
|
745
|
+
VALUES (?, 0, 0, ?, ?, ?)
|
|
746
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
747
|
+
consecutive_errors = 0,
|
|
748
|
+
last_success_time = ?,
|
|
749
|
+
updated_at = ?
|
|
750
|
+
`).run(sessionId, now, now, now, now, now);
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* 记录错误(增加计数)
|
|
754
|
+
*/
|
|
755
|
+
async recordError(sessionId, errorType, errorMessage) {
|
|
756
|
+
const now = Date.now();
|
|
757
|
+
const health = await this.getHealthStatus(sessionId);
|
|
758
|
+
const newCount = health.consecutiveErrors + 1;
|
|
759
|
+
this.db.prepare(`
|
|
760
|
+
INSERT INTO session_health (session_id, consecutive_errors, last_error, last_error_type, safe_mode, last_success_time, created_at, updated_at)
|
|
761
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
762
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
763
|
+
consecutive_errors = consecutive_errors + 1,
|
|
764
|
+
last_error = ?,
|
|
765
|
+
last_error_type = ?,
|
|
766
|
+
updated_at = ?
|
|
767
|
+
`).run(sessionId, newCount, errorMessage, errorType, health.safeMode ? 1 : 0, health.lastSuccessTime, now, now, errorMessage, errorType, now);
|
|
768
|
+
return newCount;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* 设置安全模式
|
|
772
|
+
*/
|
|
773
|
+
async setSafeMode(sessionId, enabled) {
|
|
774
|
+
const now = Date.now();
|
|
775
|
+
const health = await this.getHealthStatus(sessionId);
|
|
776
|
+
this.db.prepare(`
|
|
777
|
+
INSERT INTO session_health (session_id, consecutive_errors, safe_mode, last_success_time, created_at, updated_at)
|
|
778
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
779
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
780
|
+
safe_mode = ?,
|
|
781
|
+
updated_at = ?
|
|
782
|
+
`).run(sessionId, health.consecutiveErrors, enabled ? 1 : 0, health.lastSuccessTime, now, now, enabled ? 1 : 0, now);
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* 重置健康状态(用于修复后)
|
|
786
|
+
*/
|
|
787
|
+
async resetHealthStatus(sessionId) {
|
|
788
|
+
const now = Date.now();
|
|
789
|
+
this.db.prepare(`
|
|
790
|
+
INSERT INTO session_health (session_id, consecutive_errors, safe_mode, last_success_time, created_at, updated_at)
|
|
791
|
+
VALUES (?, 0, 0, ?, ?, ?)
|
|
792
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
793
|
+
consecutive_errors = 0,
|
|
794
|
+
last_error = NULL,
|
|
795
|
+
last_error_type = NULL,
|
|
796
|
+
safe_mode = 0,
|
|
797
|
+
updated_at = ?
|
|
798
|
+
`).run(sessionId, now, now, now, now);
|
|
799
|
+
}
|
|
800
|
+
close() {
|
|
801
|
+
this.db.close();
|
|
802
|
+
}
|
|
803
|
+
}
|