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.
@@ -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
+ }