evolclaw 2.1.2 → 2.2.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 +10 -3
- package/data/evolclaw.sample.json +9 -1
- package/dist/agents/claude-runner.js +612 -0
- package/dist/agents/codex-runner.js +310 -0
- package/dist/channels/aun.js +416 -9
- package/dist/channels/feishu.js +397 -104
- package/dist/channels/wechat.js +84 -2
- package/dist/cli.js +427 -126
- package/dist/config.js +102 -4
- package/dist/core/adapters/claude-session-file-adapter.js +144 -0
- package/dist/core/adapters/codex-session-file-adapter.js +196 -0
- package/dist/core/agent-loader.js +39 -0
- package/dist/core/channel-loader.js +60 -0
- package/dist/core/command-handler.js +908 -304
- package/dist/core/event-bus.js +32 -0
- package/dist/core/ipc-server.js +71 -0
- package/dist/core/message-bridge.js +187 -0
- package/dist/core/message-processor.js +370 -227
- package/dist/core/message-queue.js +153 -29
- package/dist/core/permission.js +58 -0
- package/dist/core/session-file-adapter.js +7 -0
- package/dist/core/session-manager.js +567 -205
- package/dist/core/stats-collector.js +86 -0
- package/dist/index.js +309 -243
- package/dist/paths.js +1 -0
- package/dist/utils/init-feishu.js +2 -0
- package/dist/utils/init-wechat.js +2 -0
- package/dist/utils/init.js +285 -53
- package/dist/utils/ipc-client.js +36 -0
- package/dist/utils/migrate-project.js +122 -0
- package/dist/utils/{permission.js → permission-utils.js} +31 -3
- package/dist/utils/rich-content-renderer.js +228 -0
- package/dist/utils/session-file-health.js +11 -34
- package/dist/utils/stream-debouncer.js +122 -0
- package/dist/utils/stream-idle-monitor.js +1 -1
- package/package.json +3 -1
- package/dist/core/agent-runner.js +0 -348
- package/dist/core/message-stream.js +0 -59
- package/dist/index.js.bak +0 -340
- package/dist/utils/markdown-to-feishu.js +0 -94
- /package/dist/utils/{platform.js → cross-platform.js} +0 -0
- /package/dist/{core → utils}/message-cache.js +0 -0
|
@@ -2,17 +2,32 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
2
2
|
import { ensureDir } from '../config.js';
|
|
3
3
|
import { resolvePaths } from '../paths.js';
|
|
4
4
|
import { logger } from '../utils/logger.js';
|
|
5
|
-
import { encodePath } from '../utils/platform.js';
|
|
5
|
+
import { encodePath } from '../utils/cross-platform.js';
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import os from 'os';
|
|
9
9
|
export class SessionManager {
|
|
10
10
|
db;
|
|
11
|
-
|
|
11
|
+
eventBus;
|
|
12
|
+
ownerResolver;
|
|
13
|
+
fileAdapters = new Map();
|
|
14
|
+
constructor(dbPath = resolvePaths().db, eventBus, ownerResolver) {
|
|
12
15
|
ensureDir(path.dirname(dbPath));
|
|
13
16
|
this.db = new DatabaseSync(dbPath);
|
|
17
|
+
this.eventBus = eventBus;
|
|
18
|
+
this.ownerResolver = ownerResolver;
|
|
14
19
|
this.initDatabase();
|
|
15
20
|
}
|
|
21
|
+
setOwnerResolver(resolver) {
|
|
22
|
+
this.ownerResolver = resolver;
|
|
23
|
+
}
|
|
24
|
+
registerFileAdapter(adapter) {
|
|
25
|
+
this.fileAdapters.set(adapter.agentId, adapter);
|
|
26
|
+
logger.debug(`[SessionManager] Registered file adapter: ${adapter.agentId}`);
|
|
27
|
+
}
|
|
28
|
+
getFileAdapter(agentId) {
|
|
29
|
+
return this.fileAdapters.get(agentId);
|
|
30
|
+
}
|
|
16
31
|
getDatabase() {
|
|
17
32
|
return this.db;
|
|
18
33
|
}
|
|
@@ -25,43 +40,76 @@ export class SessionManager {
|
|
|
25
40
|
return path.join(homeDir, '.claude', 'projects', encodedPath, `${sessionId}.jsonl`);
|
|
26
41
|
}
|
|
27
42
|
rowToSession(row) {
|
|
43
|
+
const metadata = row.metadata ? JSON.parse(row.metadata) : undefined;
|
|
28
44
|
return {
|
|
29
45
|
id: row.id,
|
|
30
46
|
channel: row.channel,
|
|
31
47
|
channelId: row.channel_id,
|
|
32
48
|
projectPath: row.project_path,
|
|
33
49
|
threadId: row.thread_id || '',
|
|
34
|
-
|
|
50
|
+
agentId: row.agent_id || 'claude',
|
|
51
|
+
chatType: row.chat_type || 'private',
|
|
52
|
+
sessionMode: row.session_mode || 'interactive',
|
|
35
53
|
agentSessionId: row.agent_session_id,
|
|
36
|
-
metadata
|
|
54
|
+
metadata,
|
|
37
55
|
name: row.name,
|
|
38
|
-
|
|
56
|
+
processingState: row.processing_state || undefined,
|
|
39
57
|
createdAt: row.created_at,
|
|
40
|
-
updatedAt: row.updated_at
|
|
58
|
+
updatedAt: row.updated_at,
|
|
59
|
+
deletedAt: row.deleted_at ?? undefined,
|
|
41
60
|
};
|
|
42
61
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
62
|
+
/** 根据 userId 计算身份 */
|
|
63
|
+
resolveIdentity(channel, userId) {
|
|
64
|
+
if (!userId)
|
|
65
|
+
return { role: 'anonymous', mode: 'interactive' };
|
|
66
|
+
const isOwner = this.ownerResolver?.(channel, userId) ?? false;
|
|
67
|
+
return { role: isOwner ? 'owner' : 'guest', mode: 'interactive' };
|
|
68
|
+
}
|
|
69
|
+
/** 更新 session 的 identity(owner 绑定后调用) */
|
|
70
|
+
async updateIdentity(sessionId, identity) {
|
|
71
|
+
// identity 不持久化到 DB,仅更新内存中的返回值
|
|
72
|
+
// 调用方应直接修改持有的 session 对象
|
|
73
|
+
logger.debug(`[SessionManager] updateIdentity: sessionId=${sessionId}, role=${identity.role}`);
|
|
74
|
+
}
|
|
75
|
+
/** 取消所有活跃会话(通过 metadata.isActive) */
|
|
76
|
+
deactivateAllMetadata(channel, channelId) {
|
|
77
|
+
const rows = this.db.prepare(`
|
|
78
|
+
SELECT id, metadata FROM sessions
|
|
79
|
+
WHERE channel = ? AND channel_id = ? AND json_extract(metadata, '$.isActive') = true
|
|
80
|
+
`).all(channel, channelId);
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
const metadata = row.metadata ? JSON.parse(row.metadata) : {};
|
|
83
|
+
metadata.isActive = false;
|
|
84
|
+
this.db.prepare(`
|
|
85
|
+
UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?
|
|
86
|
+
`).run(JSON.stringify(metadata), Date.now(), row.id);
|
|
87
|
+
}
|
|
48
88
|
}
|
|
49
89
|
validateSessionFile(row) {
|
|
50
90
|
const agentSessionId = row.agent_session_id;
|
|
51
91
|
if (!agentSessionId)
|
|
52
92
|
return undefined;
|
|
53
|
-
const
|
|
54
|
-
|
|
93
|
+
const agentId = row.agent_id || 'claude';
|
|
94
|
+
const adapter = this.getFileAdapter(agentId);
|
|
95
|
+
if (!adapter) {
|
|
96
|
+
// 无适配器时回退到 Claude 路径检查
|
|
97
|
+
const sessionFile = this.getSessionFilePath(row.project_path, agentSessionId);
|
|
98
|
+
if (fs.existsSync(sessionFile))
|
|
99
|
+
return agentSessionId;
|
|
100
|
+
}
|
|
101
|
+
else if (adapter.checkExists(row.project_path, agentSessionId)) {
|
|
55
102
|
return agentSessionId;
|
|
56
|
-
|
|
103
|
+
}
|
|
104
|
+
logger.warn(`Session file not found for ${agentId}: ${agentSessionId}, clearing session ID`);
|
|
57
105
|
this.db.prepare(`UPDATE sessions SET agent_session_id = NULL WHERE id = ?`).run(row.id);
|
|
58
106
|
return undefined;
|
|
59
107
|
}
|
|
60
108
|
insertSession(session) {
|
|
61
109
|
this.db.prepare(`
|
|
62
|
-
INSERT OR IGNORE INTO sessions (id, channel, channel_id, project_path, thread_id,
|
|
63
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
64
|
-
`).run(session.id, session.channel, session.channelId, session.projectPath, session.threadId || '', session.
|
|
110
|
+
INSERT OR IGNORE INTO sessions (id, channel, channel_id, project_path, thread_id, agent_id, chat_type, session_mode, agent_session_id, name, created_at, updated_at, metadata)
|
|
111
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
112
|
+
`).run(session.id, session.channel, session.channelId, session.projectPath, session.threadId || '', session.agentId || 'claude', session.chatType || 'private', session.sessionMode || 'interactive', session.agentSessionId ?? null, session.name ?? null, session.createdAt, session.updatedAt, session.metadata ? JSON.stringify(session.metadata) : null);
|
|
65
113
|
}
|
|
66
114
|
extractUserMessageText(messageContent) {
|
|
67
115
|
if (typeof messageContent === 'string') {
|
|
@@ -83,42 +131,165 @@ export class SessionManager {
|
|
|
83
131
|
const hasName = tableInfo.some((col) => col.name === 'name');
|
|
84
132
|
const hasThreadId = tableInfo.some((col) => col.name === 'thread_id');
|
|
85
133
|
const hasAgentType = tableInfo.some((col) => col.name === 'agent_type');
|
|
134
|
+
const hasAgentId = tableInfo.some((col) => col.name === 'agent_id');
|
|
86
135
|
const hasAgentSessionId = tableInfo.some((col) => col.name === 'agent_session_id');
|
|
87
136
|
const hasMetadata = tableInfo.some((col) => col.name === 'metadata');
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
137
|
+
const hasIsGroup = tableInfo.some((col) => col.name === 'is_group');
|
|
138
|
+
const hasChatType = tableInfo.some((col) => col.name === 'chat_type');
|
|
139
|
+
const hasSessionMode = tableInfo.some((col) => col.name === 'session_mode');
|
|
140
|
+
const hasDeletedAt = tableInfo.some((col) => col.name === 'deleted_at');
|
|
141
|
+
// 检测是否需要 schema 重构迁移(旧字段存在,新字段不存在)
|
|
142
|
+
const needsSchemaRefactor = tableInfo.length > 0 && (hasIsGroup || hasIsActive || hasAgentType) && (!hasChatType || !hasAgentId || !hasSessionMode);
|
|
143
|
+
// Schema 重构迁移:is_group → chat_type, agent_type → agent_id, 移除 is_active
|
|
144
|
+
if (needsSchemaRefactor) {
|
|
145
|
+
logger.info('Migrating database schema (session model refactor)...');
|
|
94
146
|
this.db.exec(`DROP TABLE IF EXISTS sessions_new`);
|
|
95
147
|
this.db.exec(`
|
|
96
148
|
CREATE TABLE sessions_new (
|
|
97
149
|
id TEXT PRIMARY KEY,
|
|
98
150
|
channel TEXT NOT NULL,
|
|
99
151
|
channel_id TEXT NOT NULL,
|
|
100
|
-
|
|
152
|
+
agent_id TEXT NOT NULL DEFAULT 'claude',
|
|
101
153
|
thread_id TEXT NOT NULL DEFAULT '',
|
|
102
|
-
|
|
154
|
+
chat_type TEXT NOT NULL DEFAULT 'private',
|
|
155
|
+
session_mode TEXT NOT NULL DEFAULT 'interactive',
|
|
156
|
+
project_path TEXT NOT NULL,
|
|
103
157
|
agent_session_id TEXT,
|
|
104
158
|
name TEXT,
|
|
105
|
-
is_active INTEGER NOT NULL DEFAULT 0,
|
|
106
159
|
created_at INTEGER NOT NULL,
|
|
107
160
|
updated_at INTEGER NOT NULL,
|
|
108
|
-
metadata TEXT
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
161
|
+
metadata TEXT,
|
|
162
|
+
deleted_at INTEGER
|
|
163
|
+
)
|
|
164
|
+
`);
|
|
165
|
+
// 迁移数据:is_group → chat_type, agent_type → agent_id
|
|
166
|
+
this.db.exec(`
|
|
167
|
+
INSERT INTO sessions_new (id, channel, channel_id, agent_id, thread_id, chat_type, session_mode, project_path, agent_session_id, name, created_at, updated_at, metadata, deleted_at)
|
|
168
|
+
SELECT
|
|
169
|
+
id,
|
|
170
|
+
channel,
|
|
171
|
+
channel_id,
|
|
172
|
+
COALESCE(agent_type, 'claude'),
|
|
173
|
+
COALESCE(thread_id, ''),
|
|
174
|
+
CASE WHEN is_group = 1 THEN 'group' ELSE 'private' END,
|
|
175
|
+
'interactive',
|
|
176
|
+
project_path,
|
|
177
|
+
agent_session_id,
|
|
178
|
+
name,
|
|
179
|
+
created_at,
|
|
180
|
+
updated_at,
|
|
181
|
+
metadata,
|
|
182
|
+
deleted_at
|
|
183
|
+
FROM sessions
|
|
184
|
+
`);
|
|
185
|
+
this.db.exec(`DROP TABLE sessions`);
|
|
186
|
+
this.db.exec(`ALTER TABLE sessions_new RENAME TO sessions`);
|
|
187
|
+
// 创建新索引
|
|
188
|
+
this.db.exec(`
|
|
189
|
+
CREATE INDEX IF NOT EXISTS idx_session_space
|
|
190
|
+
ON sessions(channel, channel_id, agent_id, thread_id)
|
|
191
|
+
WHERE deleted_at IS NULL
|
|
114
192
|
`);
|
|
115
|
-
// 话题会话唯一约束(thread_id 非空时才生效)
|
|
116
193
|
this.db.exec(`
|
|
117
|
-
CREATE
|
|
118
|
-
ON sessions(channel, channel_id
|
|
119
|
-
WHERE
|
|
194
|
+
CREATE INDEX IF NOT EXISTS idx_session_active
|
|
195
|
+
ON sessions(channel, channel_id)
|
|
196
|
+
WHERE deleted_at IS NULL
|
|
120
197
|
`);
|
|
121
|
-
logger.info('✓ Database migration completed (
|
|
198
|
+
logger.info('✓ Database migration completed (session model refactored)');
|
|
199
|
+
}
|
|
200
|
+
// ── 旧 schema 迁移(仅当旧字段存在、新字段还未迁移时运行)──
|
|
201
|
+
// 这些迁移按顺序将最旧的 schema 逐步升级到包含 is_group 的中间格式,
|
|
202
|
+
// 然后由上面的 needsSchemaRefactor 迁移一步到位转为新 schema。
|
|
203
|
+
if (!needsSchemaRefactor && tableInfo.length > 0 && hasAgentType) {
|
|
204
|
+
// 检查是否有唯一约束
|
|
205
|
+
const indexes = this.db.prepare('PRAGMA index_list(sessions)').all();
|
|
206
|
+
const hasUniqueConstraint = indexes.some((idx) => idx.origin === 'u');
|
|
207
|
+
// 迁移到新表结构(添加 thread_id, agent_type, agent_session_id, metadata)
|
|
208
|
+
if (!hasThreadId) {
|
|
209
|
+
logger.info('Migrating database schema (adding thread support)...');
|
|
210
|
+
this.db.exec(`DROP TABLE IF EXISTS sessions_new`);
|
|
211
|
+
this.db.exec(`
|
|
212
|
+
CREATE TABLE sessions_new (
|
|
213
|
+
id TEXT PRIMARY KEY,
|
|
214
|
+
channel TEXT NOT NULL,
|
|
215
|
+
channel_id TEXT NOT NULL,
|
|
216
|
+
project_path TEXT NOT NULL,
|
|
217
|
+
thread_id TEXT NOT NULL DEFAULT '',
|
|
218
|
+
agent_type TEXT NOT NULL DEFAULT 'claude',
|
|
219
|
+
agent_session_id TEXT,
|
|
220
|
+
name TEXT,
|
|
221
|
+
is_active INTEGER NOT NULL DEFAULT 0,
|
|
222
|
+
created_at INTEGER NOT NULL,
|
|
223
|
+
updated_at INTEGER NOT NULL,
|
|
224
|
+
metadata TEXT
|
|
225
|
+
);
|
|
226
|
+
INSERT INTO sessions_new (id, channel, channel_id, project_path, thread_id, agent_type, agent_session_id, name, is_active, created_at, updated_at, metadata)
|
|
227
|
+
SELECT id, channel, channel_id, project_path, '', 'claude', claude_session_id, name, is_active, created_at, updated_at, NULL FROM sessions;
|
|
228
|
+
DROP TABLE sessions;
|
|
229
|
+
ALTER TABLE sessions_new RENAME TO sessions;
|
|
230
|
+
`);
|
|
231
|
+
// 话题会话唯一约束(thread_id 非空时才生效)
|
|
232
|
+
this.db.exec(`
|
|
233
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_thread
|
|
234
|
+
ON sessions(channel, channel_id, project_path, thread_id)
|
|
235
|
+
WHERE thread_id != ''
|
|
236
|
+
`);
|
|
237
|
+
logger.info('✓ Database migration completed (thread support added)');
|
|
238
|
+
}
|
|
239
|
+
// Migration: add is_group column
|
|
240
|
+
if (!hasIsGroup) {
|
|
241
|
+
logger.info('Migrating database schema (adding is_group)...');
|
|
242
|
+
const addIsGroupCol = 'ALTER TABLE sessions ADD COLUMN is_group INTEGER NOT NULL DEFAULT 0';
|
|
243
|
+
this.db.exec(addIsGroupCol);
|
|
244
|
+
logger.info('✓ Database migration completed (is_group added)');
|
|
245
|
+
}
|
|
246
|
+
// Reset incorrect is_group values (oc_ prefix doesn't reliably indicate group chat)
|
|
247
|
+
if (hasIsGroup) {
|
|
248
|
+
this.db.exec("UPDATE sessions SET is_group = 0 WHERE channel = 'feishu'");
|
|
249
|
+
}
|
|
250
|
+
// Migration: add deleted_at column
|
|
251
|
+
if (!hasDeletedAt) {
|
|
252
|
+
logger.info('Migrating database schema (adding deleted_at)...');
|
|
253
|
+
this.db.exec(`ALTER TABLE sessions ADD COLUMN deleted_at INTEGER`);
|
|
254
|
+
logger.info('✓ Database migration completed (deleted_at added)');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Migration: add processing_state column (独立于 schema 重构)
|
|
258
|
+
if (tableInfo.length > 0) {
|
|
259
|
+
const hasProcessingState = tableInfo.some((col) => col.name === 'processing_state');
|
|
260
|
+
if (!hasProcessingState) {
|
|
261
|
+
logger.info('Migrating database schema (adding processing_state)...');
|
|
262
|
+
this.db.exec(`ALTER TABLE sessions ADD COLUMN processing_state TEXT`);
|
|
263
|
+
logger.info('✓ Database migration completed (processing_state added)');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Migration: normalize legacy metadata rootId → replyContext
|
|
267
|
+
if (hasMetadata && tableInfo.length > 0) {
|
|
268
|
+
const rows = this.db.prepare(`SELECT id, metadata FROM sessions WHERE metadata IS NOT NULL AND metadata != ''`).all();
|
|
269
|
+
let migrated = 0;
|
|
270
|
+
for (const row of rows) {
|
|
271
|
+
try {
|
|
272
|
+
const meta = JSON.parse(row.metadata);
|
|
273
|
+
const rootId = meta.feishu?.rootId ?? meta.threadRootId ?? meta.replyOpts?.rootId;
|
|
274
|
+
if (!rootId && !meta.feishu && !meta.threadRootId && !meta.replyOpts)
|
|
275
|
+
continue;
|
|
276
|
+
// Generate replyContext from rootId if missing
|
|
277
|
+
if (rootId && !meta.replyContext) {
|
|
278
|
+
meta.replyContext = { replyToMessageId: rootId, replyInThread: true };
|
|
279
|
+
}
|
|
280
|
+
// Clean up all legacy fields
|
|
281
|
+
delete meta.feishu;
|
|
282
|
+
delete meta.threadRootId;
|
|
283
|
+
delete meta.replyOpts;
|
|
284
|
+
this.db.prepare('UPDATE sessions SET metadata = ? WHERE id = ?')
|
|
285
|
+
.run(JSON.stringify(meta), row.id);
|
|
286
|
+
migrated++;
|
|
287
|
+
}
|
|
288
|
+
catch { /* skip malformed JSON */ }
|
|
289
|
+
}
|
|
290
|
+
if (migrated > 0) {
|
|
291
|
+
logger.info(`✓ Migrated ${migrated} session(s): rootId normalized to replyContext`);
|
|
292
|
+
}
|
|
122
293
|
}
|
|
123
294
|
// 创建新表(首次初始化)
|
|
124
295
|
this.db.exec(`
|
|
@@ -126,22 +297,30 @@ export class SessionManager {
|
|
|
126
297
|
id TEXT PRIMARY KEY,
|
|
127
298
|
channel TEXT NOT NULL,
|
|
128
299
|
channel_id TEXT NOT NULL,
|
|
129
|
-
|
|
300
|
+
agent_id TEXT NOT NULL DEFAULT 'claude',
|
|
130
301
|
thread_id TEXT NOT NULL DEFAULT '',
|
|
131
|
-
|
|
302
|
+
chat_type TEXT NOT NULL DEFAULT 'private',
|
|
303
|
+
session_mode TEXT NOT NULL DEFAULT 'interactive',
|
|
304
|
+
project_path TEXT NOT NULL,
|
|
132
305
|
agent_session_id TEXT,
|
|
133
306
|
name TEXT,
|
|
134
|
-
|
|
307
|
+
processing_state TEXT,
|
|
135
308
|
created_at INTEGER NOT NULL,
|
|
136
309
|
updated_at INTEGER NOT NULL,
|
|
137
|
-
metadata TEXT
|
|
310
|
+
metadata TEXT,
|
|
311
|
+
deleted_at INTEGER
|
|
138
312
|
)
|
|
139
313
|
`);
|
|
140
|
-
//
|
|
314
|
+
// 会话空间索引(查询优化,无唯一约束)
|
|
315
|
+
this.db.exec(`
|
|
316
|
+
CREATE INDEX IF NOT EXISTS idx_session_space
|
|
317
|
+
ON sessions(channel, channel_id, agent_id, thread_id)
|
|
318
|
+
WHERE deleted_at IS NULL
|
|
319
|
+
`);
|
|
141
320
|
this.db.exec(`
|
|
142
|
-
CREATE
|
|
143
|
-
ON sessions(channel, channel_id
|
|
144
|
-
WHERE
|
|
321
|
+
CREATE INDEX IF NOT EXISTS idx_session_active
|
|
322
|
+
ON sessions(channel, channel_id)
|
|
323
|
+
WHERE deleted_at IS NULL
|
|
145
324
|
`);
|
|
146
325
|
// 创建消息去重表
|
|
147
326
|
this.db.exec(`
|
|
@@ -169,67 +348,172 @@ export class SessionManager {
|
|
|
169
348
|
CREATE INDEX IF NOT EXISTS idx_session_health_safe_mode ON session_health(safe_mode);
|
|
170
349
|
`);
|
|
171
350
|
}
|
|
172
|
-
|
|
173
|
-
|
|
351
|
+
/**
|
|
352
|
+
* 获取指定渠道所有已知的 thread_id(用于重启后预填充 seenThreads)
|
|
353
|
+
*/
|
|
354
|
+
getKnownThreadIds(channel) {
|
|
355
|
+
const rows = this.db.prepare(`
|
|
356
|
+
SELECT DISTINCT thread_id FROM sessions
|
|
357
|
+
WHERE channel = ? AND thread_id != '' AND deleted_at IS NULL
|
|
358
|
+
`).all(channel);
|
|
359
|
+
return rows.map(r => r.thread_id);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* 标记会话为处理中(实时写 DB,crash 也能恢复)
|
|
363
|
+
*/
|
|
364
|
+
markProcessing(sessionId) {
|
|
365
|
+
const now = Date.now();
|
|
366
|
+
this.db.prepare(`UPDATE sessions SET processing_state = ?, updated_at = ? WHERE id = ?`)
|
|
367
|
+
.run(String(now), now, sessionId);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* 清除会话处理中状态
|
|
371
|
+
*/
|
|
372
|
+
clearProcessing(sessionId) {
|
|
373
|
+
this.db.prepare(`UPDATE sessions SET processing_state = NULL, updated_at = ? WHERE id = ?`)
|
|
374
|
+
.run(Date.now(), sessionId);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* 获取所有处于 processing 状态的会话(用于重启后恢复)
|
|
378
|
+
* @param maxAgeMs 最大存活时间(超过则视为超时,清除状态)默认 1 小时
|
|
379
|
+
*/
|
|
380
|
+
getPendingProcessingSessions(maxAgeMs = 60 * 60 * 1000) {
|
|
381
|
+
const rows = this.db.prepare(`
|
|
382
|
+
SELECT * FROM sessions
|
|
383
|
+
WHERE processing_state IS NOT NULL AND deleted_at IS NULL
|
|
384
|
+
`).all();
|
|
385
|
+
const now = Date.now();
|
|
386
|
+
const result = [];
|
|
387
|
+
for (const row of rows) {
|
|
388
|
+
const ts = parseInt(row.processing_state, 10);
|
|
389
|
+
if (!isNaN(ts) && (now - ts) < maxAgeMs) {
|
|
390
|
+
result.push(this.rowToSession(row));
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
// 超时:清除过期状态
|
|
394
|
+
this.db.prepare(`UPDATE sessions SET processing_state = NULL WHERE id = ?`)
|
|
395
|
+
.run(row.id);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return result;
|
|
399
|
+
}
|
|
400
|
+
async getOrCreateSession(channel, channelId, defaultProjectPath, threadId, metadata, name, userId, chatType, agentId) {
|
|
401
|
+
// 话题会话:独立查找/创建
|
|
174
402
|
if (threadId) {
|
|
175
|
-
|
|
403
|
+
const session = this.getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId);
|
|
404
|
+
session.identity = this.resolveIdentity(channel, userId);
|
|
405
|
+
return session;
|
|
176
406
|
}
|
|
177
407
|
// 主会话:查找活跃会话
|
|
178
408
|
const active = this.db.prepare(`
|
|
179
409
|
SELECT * FROM sessions
|
|
180
|
-
WHERE channel = ? AND channel_id = ? AND
|
|
410
|
+
WHERE channel = ? AND channel_id = ? AND json_extract(metadata, '$.isActive') = true AND thread_id = '' AND deleted_at IS NULL
|
|
181
411
|
`).get(channel, channelId);
|
|
182
412
|
if (active) {
|
|
183
413
|
const validSessionId = this.validateSessionFile(active);
|
|
184
|
-
|
|
414
|
+
const session = { ...this.rowToSession(active), agentSessionId: validSessionId };
|
|
415
|
+
session.identity = this.resolveIdentity(channel, userId);
|
|
416
|
+
// 补写 peerId/peerName(私聊 session 可能在此字段引入前创建)
|
|
417
|
+
if (chatType === 'private' && userId) {
|
|
418
|
+
const activeMeta = active.metadata ? JSON.parse(active.metadata) : {};
|
|
419
|
+
let updated = false;
|
|
420
|
+
if (!activeMeta.peerId) {
|
|
421
|
+
activeMeta.peerId = userId;
|
|
422
|
+
updated = true;
|
|
423
|
+
}
|
|
424
|
+
if (!activeMeta.peerName && metadata?.peerName) {
|
|
425
|
+
activeMeta.peerName = metadata.peerName;
|
|
426
|
+
updated = true;
|
|
427
|
+
}
|
|
428
|
+
if (updated) {
|
|
429
|
+
this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
|
|
430
|
+
.run(JSON.stringify(activeMeta), Date.now(), active.id);
|
|
431
|
+
session.metadata = activeMeta;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return session;
|
|
185
435
|
}
|
|
186
436
|
// 查找默认项目的主会话
|
|
187
437
|
const existing = this.db.prepare(`
|
|
188
438
|
SELECT * FROM sessions
|
|
189
|
-
WHERE channel = ? AND channel_id = ? AND project_path = ? AND thread_id = ''
|
|
439
|
+
WHERE channel = ? AND channel_id = ? AND project_path = ? AND thread_id = '' AND deleted_at IS NULL
|
|
190
440
|
ORDER BY updated_at DESC LIMIT 1
|
|
191
441
|
`).get(channel, channelId, defaultProjectPath);
|
|
192
442
|
if (existing) {
|
|
193
443
|
const validSessionId = this.validateSessionFile(existing);
|
|
194
|
-
|
|
195
|
-
|
|
444
|
+
// 激活此会话
|
|
445
|
+
const existingMeta = existing.metadata ? JSON.parse(existing.metadata) : {};
|
|
446
|
+
existingMeta.isActive = true;
|
|
447
|
+
// 补写 peerId/peerName
|
|
448
|
+
if (chatType === 'private' && userId && !existingMeta.peerId) {
|
|
449
|
+
existingMeta.peerId = userId;
|
|
450
|
+
}
|
|
451
|
+
if (chatType === 'private' && metadata?.peerName && !existingMeta.peerName) {
|
|
452
|
+
existingMeta.peerName = metadata.peerName;
|
|
453
|
+
}
|
|
454
|
+
this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
|
|
455
|
+
.run(JSON.stringify(existingMeta), Date.now(), existing.id);
|
|
456
|
+
const session = { ...this.rowToSession(existing), agentSessionId: validSessionId, metadata: existingMeta };
|
|
457
|
+
session.identity = this.resolveIdentity(channel, userId);
|
|
458
|
+
return session;
|
|
196
459
|
}
|
|
197
460
|
// 创建新主会话
|
|
461
|
+
const sessionMetadata = { ...metadata, isActive: true };
|
|
198
462
|
const session = {
|
|
199
463
|
id: `${channel}-${channelId}-${Date.now()}`,
|
|
200
464
|
channel,
|
|
201
465
|
channelId,
|
|
202
466
|
projectPath: defaultProjectPath,
|
|
203
467
|
threadId: '',
|
|
204
|
-
|
|
205
|
-
|
|
468
|
+
agentId: agentId || 'claude',
|
|
469
|
+
chatType: chatType || 'private',
|
|
470
|
+
sessionMode: 'interactive',
|
|
471
|
+
metadata: sessionMetadata,
|
|
206
472
|
name: name || '默认会话',
|
|
207
|
-
isActive: true,
|
|
208
473
|
createdAt: Date.now(),
|
|
209
474
|
updatedAt: Date.now()
|
|
210
475
|
};
|
|
476
|
+
session.identity = this.resolveIdentity(channel, userId);
|
|
211
477
|
this.insertSession(session);
|
|
478
|
+
this.eventBus.publish({
|
|
479
|
+
type: 'session:created',
|
|
480
|
+
sessionId: session.id,
|
|
481
|
+
channel,
|
|
482
|
+
channelId,
|
|
483
|
+
projectPath: defaultProjectPath,
|
|
484
|
+
name: session.name,
|
|
485
|
+
chatType: session.chatType,
|
|
486
|
+
timestamp: Date.now()
|
|
487
|
+
});
|
|
212
488
|
return session;
|
|
213
489
|
}
|
|
214
490
|
async updateSession(sessionId, updates) {
|
|
215
|
-
const
|
|
216
|
-
const values =
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
491
|
+
const sets = [];
|
|
492
|
+
const values = [];
|
|
493
|
+
if (updates.chatType !== undefined) {
|
|
494
|
+
sets.push('chat_type = ?');
|
|
495
|
+
values.push(updates.chatType);
|
|
496
|
+
}
|
|
497
|
+
if (updates.name !== undefined) {
|
|
498
|
+
sets.push('name = ?');
|
|
499
|
+
values.push(updates.name);
|
|
500
|
+
}
|
|
501
|
+
if (updates.metadata !== undefined) {
|
|
502
|
+
sets.push('metadata = ?');
|
|
503
|
+
values.push(updates.metadata ? JSON.stringify(updates.metadata) : null);
|
|
504
|
+
}
|
|
505
|
+
if (sets.length === 0)
|
|
506
|
+
return;
|
|
507
|
+
sets.push('updated_at = ?');
|
|
508
|
+
values.push(Date.now());
|
|
509
|
+
values.push(sessionId);
|
|
510
|
+
this.db.prepare(`UPDATE sessions SET ${sets.join(', ')} WHERE id = ?`).run(...values);
|
|
227
511
|
}
|
|
228
|
-
getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name) {
|
|
512
|
+
getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId) {
|
|
229
513
|
// 查找已有话题会话
|
|
230
514
|
const existing = this.db.prepare(`
|
|
231
515
|
SELECT * FROM sessions
|
|
232
|
-
WHERE channel = ? AND channel_id = ? AND thread_id = ?
|
|
516
|
+
WHERE channel = ? AND channel_id = ? AND thread_id = ? AND deleted_at IS NULL
|
|
233
517
|
`).get(channel, channelId, threadId);
|
|
234
518
|
if (existing) {
|
|
235
519
|
const validSessionId = this.validateSessionFile(existing);
|
|
@@ -246,43 +530,54 @@ export class SessionManager {
|
|
|
246
530
|
// 继承当前活跃主会话的项目路径
|
|
247
531
|
const activeMain = this.db.prepare(`
|
|
248
532
|
SELECT project_path FROM sessions
|
|
249
|
-
WHERE channel = ? AND channel_id = ? AND
|
|
533
|
+
WHERE channel = ? AND channel_id = ? AND json_extract(metadata, '$.isActive') = true AND thread_id = ''
|
|
250
534
|
`).get(channel, channelId);
|
|
251
535
|
const projectPath = activeMain?.project_path || defaultProjectPath;
|
|
252
|
-
//
|
|
536
|
+
// 创建新话题会话
|
|
253
537
|
const session = {
|
|
254
538
|
id: `${channel}-${channelId}-${Date.now()}`,
|
|
255
539
|
channel,
|
|
256
540
|
channelId,
|
|
257
541
|
projectPath,
|
|
258
542
|
threadId,
|
|
259
|
-
|
|
543
|
+
agentId: agentId || 'claude',
|
|
544
|
+
chatType: 'private',
|
|
545
|
+
sessionMode: 'interactive',
|
|
260
546
|
metadata,
|
|
261
547
|
name: name || '话题会话',
|
|
262
|
-
isActive: false,
|
|
263
548
|
createdAt: Date.now(),
|
|
264
549
|
updatedAt: Date.now()
|
|
265
550
|
};
|
|
266
551
|
this.insertSession(session);
|
|
552
|
+
this.eventBus.publish({
|
|
553
|
+
type: 'session:created',
|
|
554
|
+
sessionId: session.id,
|
|
555
|
+
channel,
|
|
556
|
+
channelId,
|
|
557
|
+
projectPath,
|
|
558
|
+
name: session.name,
|
|
559
|
+
timestamp: Date.now()
|
|
560
|
+
});
|
|
267
561
|
return session;
|
|
268
562
|
}
|
|
269
|
-
async switchProject(channel, channelId, newProjectPath) {
|
|
563
|
+
async switchProject(channel, channelId, newProjectPath, currentAgentId) {
|
|
564
|
+
const agentId = currentAgentId || 'claude';
|
|
270
565
|
// 1. 取消当前活跃会话
|
|
271
|
-
this.
|
|
272
|
-
// 2.
|
|
566
|
+
this.deactivateAllMetadata(channel, channelId);
|
|
567
|
+
// 2. 查找目标项目 + 当前 agent 的会话
|
|
273
568
|
const target = this.db.prepare(`
|
|
274
569
|
SELECT * FROM sessions
|
|
275
|
-
WHERE channel = ? AND channel_id = ? AND project_path = ?
|
|
570
|
+
WHERE channel = ? AND channel_id = ? AND project_path = ? AND agent_id = ? AND thread_id = '' AND deleted_at IS NULL
|
|
276
571
|
ORDER BY updated_at DESC LIMIT 1
|
|
277
|
-
`).get(channel, channelId, newProjectPath);
|
|
572
|
+
`).get(channel, channelId, newProjectPath, agentId);
|
|
278
573
|
if (target) {
|
|
279
574
|
const validSessionId = this.validateSessionFile(target);
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
return { ...this.rowToSession(target), agentSessionId: validSessionId,
|
|
575
|
+
// 激活目标会话
|
|
576
|
+
const metadata = target.metadata ? JSON.parse(target.metadata) : {};
|
|
577
|
+
metadata.isActive = true;
|
|
578
|
+
this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
|
|
579
|
+
.run(JSON.stringify(metadata), Date.now(), target.id);
|
|
580
|
+
return { ...this.rowToSession(target), agentSessionId: validSessionId, metadata };
|
|
286
581
|
}
|
|
287
582
|
// 3. 创建新会话
|
|
288
583
|
const session = {
|
|
@@ -291,13 +586,24 @@ export class SessionManager {
|
|
|
291
586
|
channelId,
|
|
292
587
|
projectPath: newProjectPath,
|
|
293
588
|
threadId: '',
|
|
294
|
-
|
|
589
|
+
agentId,
|
|
590
|
+
chatType: 'private',
|
|
591
|
+
sessionMode: 'interactive',
|
|
592
|
+
metadata: { isActive: true },
|
|
295
593
|
name: '默认会话',
|
|
296
|
-
isActive: true,
|
|
297
594
|
createdAt: Date.now(),
|
|
298
595
|
updatedAt: Date.now()
|
|
299
596
|
};
|
|
300
597
|
this.insertSession(session);
|
|
598
|
+
this.eventBus.publish({
|
|
599
|
+
type: 'session:created',
|
|
600
|
+
sessionId: session.id,
|
|
601
|
+
channel,
|
|
602
|
+
channelId,
|
|
603
|
+
projectPath: newProjectPath,
|
|
604
|
+
name: session.name,
|
|
605
|
+
timestamp: Date.now()
|
|
606
|
+
});
|
|
301
607
|
return session;
|
|
302
608
|
}
|
|
303
609
|
async updateAgentSessionId(channel, channelId, agentSessionId) {
|
|
@@ -305,7 +611,7 @@ export class SessionManager {
|
|
|
305
611
|
this.db.prepare(`
|
|
306
612
|
UPDATE sessions
|
|
307
613
|
SET agent_session_id = ?, updated_at = ?
|
|
308
|
-
WHERE channel = ? AND channel_id = ? AND
|
|
614
|
+
WHERE channel = ? AND channel_id = ? AND json_extract(metadata, '$.isActive') = true
|
|
309
615
|
`).run(agentSessionId, Date.now(), channel, channelId);
|
|
310
616
|
}
|
|
311
617
|
async updateAgentSessionIdBySessionId(sessionId, agentSessionId) {
|
|
@@ -317,28 +623,97 @@ export class SessionManager {
|
|
|
317
623
|
WHERE id = ?
|
|
318
624
|
`).run(agentSessionId, Date.now(), sessionId);
|
|
319
625
|
}
|
|
626
|
+
async switchAgent(channel, channelId, projectPath, newAgentId) {
|
|
627
|
+
// 1. 取消当前活跃会话
|
|
628
|
+
this.deactivateAllMetadata(channel, channelId);
|
|
629
|
+
// 2. 查找目标 agent 在当前项目下的会话
|
|
630
|
+
const target = this.db.prepare(`
|
|
631
|
+
SELECT * FROM sessions
|
|
632
|
+
WHERE channel = ? AND channel_id = ? AND project_path = ? AND agent_id = ? AND thread_id = '' AND deleted_at IS NULL
|
|
633
|
+
ORDER BY updated_at DESC LIMIT 1
|
|
634
|
+
`).get(channel, channelId, projectPath, newAgentId);
|
|
635
|
+
if (target) {
|
|
636
|
+
const validSessionId = this.validateSessionFile(target);
|
|
637
|
+
// 激活目标会话
|
|
638
|
+
const metadata = target.metadata ? JSON.parse(target.metadata) : {};
|
|
639
|
+
metadata.isActive = true;
|
|
640
|
+
this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
|
|
641
|
+
.run(JSON.stringify(metadata), Date.now(), target.id);
|
|
642
|
+
return { ...this.rowToSession(target), agentSessionId: validSessionId, metadata };
|
|
643
|
+
}
|
|
644
|
+
// 3. 创建新会话(与 switchProject 保持一致)
|
|
645
|
+
const session = {
|
|
646
|
+
id: `${channel}-${channelId}-${Date.now()}`,
|
|
647
|
+
channel,
|
|
648
|
+
channelId,
|
|
649
|
+
projectPath,
|
|
650
|
+
threadId: '',
|
|
651
|
+
agentId: newAgentId,
|
|
652
|
+
chatType: 'private',
|
|
653
|
+
sessionMode: 'interactive',
|
|
654
|
+
metadata: { isActive: true },
|
|
655
|
+
name: '默认会话',
|
|
656
|
+
createdAt: Date.now(),
|
|
657
|
+
updatedAt: Date.now()
|
|
658
|
+
};
|
|
659
|
+
this.insertSession(session);
|
|
660
|
+
this.eventBus.publish({
|
|
661
|
+
type: 'session:created',
|
|
662
|
+
sessionId: session.id,
|
|
663
|
+
channel,
|
|
664
|
+
channelId,
|
|
665
|
+
projectPath,
|
|
666
|
+
name: session.name,
|
|
667
|
+
timestamp: Date.now()
|
|
668
|
+
});
|
|
669
|
+
return session;
|
|
670
|
+
}
|
|
320
671
|
async clearActiveSession(channel, channelId) {
|
|
321
672
|
// 清除当前活跃会话的 Agent Session ID
|
|
322
673
|
this.db.prepare(`
|
|
323
674
|
UPDATE sessions
|
|
324
675
|
SET agent_session_id = NULL, updated_at = ?
|
|
325
|
-
WHERE channel = ? AND channel_id = ? AND
|
|
676
|
+
WHERE channel = ? AND channel_id = ? AND json_extract(metadata, '$.isActive') = true
|
|
326
677
|
`).run(Date.now(), channel, channelId);
|
|
327
678
|
}
|
|
679
|
+
/** 查找 owner 在目标通道的私聊 channelId(用于跨通道文件投递) */
|
|
680
|
+
getOwnerChatId(targetChannel, ownerPeerId) {
|
|
681
|
+
const row = this.db.prepare(`
|
|
682
|
+
SELECT channel_id FROM sessions
|
|
683
|
+
WHERE channel = ? AND chat_type = 'private'
|
|
684
|
+
AND json_extract(metadata, '$.peerId') = ?
|
|
685
|
+
AND deleted_at IS NULL
|
|
686
|
+
ORDER BY updated_at DESC LIMIT 1
|
|
687
|
+
`).get(targetChannel, ownerPeerId);
|
|
688
|
+
return row?.channel_id;
|
|
689
|
+
}
|
|
328
690
|
async getActiveSession(channel, channelId) {
|
|
329
691
|
const row = this.db.prepare(`
|
|
330
692
|
SELECT * FROM sessions
|
|
331
|
-
WHERE channel = ? AND channel_id = ? AND
|
|
693
|
+
WHERE channel = ? AND channel_id = ? AND json_extract(metadata, '$.isActive') = true AND deleted_at IS NULL
|
|
332
694
|
`).get(channel, channelId);
|
|
333
695
|
if (!row)
|
|
334
696
|
return undefined;
|
|
335
697
|
return this.rowToSession(row);
|
|
336
698
|
}
|
|
699
|
+
/**
|
|
700
|
+
* 查询话题会话(不创建)
|
|
701
|
+
*/
|
|
702
|
+
async getThreadSession(channel, channelId, threadId) {
|
|
703
|
+
const row = this.db.prepare(`
|
|
704
|
+
SELECT * FROM sessions
|
|
705
|
+
WHERE channel = ? AND channel_id = ? AND thread_id = ? AND deleted_at IS NULL
|
|
706
|
+
`).get(channel, channelId, threadId);
|
|
707
|
+
if (!row)
|
|
708
|
+
return undefined;
|
|
709
|
+
const validSessionId = this.validateSessionFile(row);
|
|
710
|
+
return { ...this.rowToSession(row), agentSessionId: validSessionId };
|
|
711
|
+
}
|
|
337
712
|
async listSessions(channel, channelId) {
|
|
338
713
|
// 列出该聊天的所有会话
|
|
339
714
|
const rows = this.db.prepare(`
|
|
340
715
|
SELECT * FROM sessions
|
|
341
|
-
WHERE channel = ? AND channel_id = ?
|
|
716
|
+
WHERE channel = ? AND channel_id = ? AND deleted_at IS NULL
|
|
342
717
|
ORDER BY updated_at DESC
|
|
343
718
|
`).all(channel, channelId);
|
|
344
719
|
return rows.map(row => this.rowToSession(row));
|
|
@@ -346,7 +721,9 @@ export class SessionManager {
|
|
|
346
721
|
async getSessionByProjectPath(channel, channelId, projectPath) {
|
|
347
722
|
const row = this.db.prepare(`
|
|
348
723
|
SELECT * FROM sessions
|
|
349
|
-
WHERE channel = ? AND channel_id = ? AND project_path = ?
|
|
724
|
+
WHERE channel = ? AND channel_id = ? AND project_path = ? AND deleted_at IS NULL
|
|
725
|
+
ORDER BY processing_state IS NOT NULL DESC, updated_at DESC
|
|
726
|
+
LIMIT 1
|
|
350
727
|
`).get(channel, channelId, projectPath);
|
|
351
728
|
if (!row)
|
|
352
729
|
return undefined;
|
|
@@ -355,7 +732,7 @@ export class SessionManager {
|
|
|
355
732
|
async getSessionByName(channel, channelId, name) {
|
|
356
733
|
const row = this.db.prepare(`
|
|
357
734
|
SELECT * FROM sessions
|
|
358
|
-
WHERE channel = ? AND channel_id = ? AND name = ?
|
|
735
|
+
WHERE channel = ? AND channel_id = ? AND name = ? AND deleted_at IS NULL
|
|
359
736
|
`).get(channel, channelId, name);
|
|
360
737
|
if (!row)
|
|
361
738
|
return undefined;
|
|
@@ -364,18 +741,18 @@ export class SessionManager {
|
|
|
364
741
|
async switchToSession(channel, channelId, targetSessionId) {
|
|
365
742
|
// 验证目标会话存在
|
|
366
743
|
const target = this.db.prepare(`
|
|
367
|
-
SELECT * FROM sessions WHERE id = ? AND channel = ? AND channel_id = ?
|
|
744
|
+
SELECT * FROM sessions WHERE id = ? AND channel = ? AND channel_id = ? AND deleted_at IS NULL
|
|
368
745
|
`).get(targetSessionId, channel, channelId);
|
|
369
746
|
if (!target)
|
|
370
747
|
return null;
|
|
371
748
|
// 取消当前活跃会话
|
|
372
|
-
this.
|
|
749
|
+
this.deactivateAllMetadata(channel, channelId);
|
|
373
750
|
// 激活目标会话
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
return { ...this.rowToSession(target),
|
|
751
|
+
const metadata = target.metadata ? JSON.parse(target.metadata) : {};
|
|
752
|
+
metadata.isActive = true;
|
|
753
|
+
this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
|
|
754
|
+
.run(JSON.stringify(metadata), Date.now(), targetSessionId);
|
|
755
|
+
return { ...this.rowToSession(target), metadata, updatedAt: Date.now() };
|
|
379
756
|
}
|
|
380
757
|
async renameSession(sessionId, newName) {
|
|
381
758
|
const result = this.db.prepare(`
|
|
@@ -389,9 +766,14 @@ export class SessionManager {
|
|
|
389
766
|
`).run(sessionId);
|
|
390
767
|
return result.changes > 0;
|
|
391
768
|
}
|
|
392
|
-
async
|
|
769
|
+
async softDeleteSession(channelId) {
|
|
770
|
+
this.db.prepare(`
|
|
771
|
+
UPDATE sessions SET deleted_at = ?, updated_at = ? WHERE channel_id = ? AND deleted_at IS NULL
|
|
772
|
+
`).run(Date.now(), Date.now(), channelId);
|
|
773
|
+
}
|
|
774
|
+
async createNewSession(channel, channelId, projectPath, name, agentId) {
|
|
393
775
|
// 取消当前活跃会话
|
|
394
|
-
this.
|
|
776
|
+
this.deactivateAllMetadata(channel, channelId);
|
|
395
777
|
// 创建新会话
|
|
396
778
|
const session = {
|
|
397
779
|
id: `${channel}-${channelId}-${Date.now()}`,
|
|
@@ -399,13 +781,24 @@ export class SessionManager {
|
|
|
399
781
|
channelId,
|
|
400
782
|
projectPath,
|
|
401
783
|
threadId: '',
|
|
402
|
-
|
|
784
|
+
agentId: agentId || 'claude',
|
|
785
|
+
chatType: 'private',
|
|
786
|
+
sessionMode: 'interactive',
|
|
787
|
+
metadata: { isActive: true },
|
|
403
788
|
name: name || '默认会话',
|
|
404
|
-
isActive: true,
|
|
405
789
|
createdAt: Date.now(),
|
|
406
790
|
updatedAt: Date.now()
|
|
407
791
|
};
|
|
408
792
|
this.insertSession(session);
|
|
793
|
+
this.eventBus.publish({
|
|
794
|
+
type: 'session:created',
|
|
795
|
+
sessionId: session.id,
|
|
796
|
+
channel,
|
|
797
|
+
channelId,
|
|
798
|
+
projectPath,
|
|
799
|
+
name: session.name,
|
|
800
|
+
timestamp: Date.now()
|
|
801
|
+
});
|
|
409
802
|
return session;
|
|
410
803
|
}
|
|
411
804
|
/**
|
|
@@ -413,129 +806,80 @@ export class SessionManager {
|
|
|
413
806
|
*/
|
|
414
807
|
async createForkedSession(sourceSession, forkedAgentSessionId, name) {
|
|
415
808
|
// 取消当前活跃会话
|
|
416
|
-
this.
|
|
809
|
+
this.deactivateAllMetadata(sourceSession.channel, sourceSession.channelId);
|
|
417
810
|
const session = {
|
|
418
811
|
id: `${sourceSession.channel}-${sourceSession.channelId}-${Date.now()}`,
|
|
419
812
|
channel: sourceSession.channel,
|
|
420
813
|
channelId: sourceSession.channelId,
|
|
421
814
|
projectPath: sourceSession.projectPath,
|
|
422
815
|
threadId: sourceSession.threadId || '',
|
|
423
|
-
|
|
816
|
+
agentId: sourceSession.agentId || 'claude',
|
|
817
|
+
chatType: sourceSession.chatType || 'private',
|
|
818
|
+
sessionMode: sourceSession.sessionMode || 'interactive',
|
|
424
819
|
agentSessionId: forkedAgentSessionId,
|
|
820
|
+
metadata: { isActive: true },
|
|
425
821
|
name: name || `${sourceSession.name || '会话'}-分支`,
|
|
426
|
-
isActive: true,
|
|
427
822
|
createdAt: Date.now(),
|
|
428
823
|
updatedAt: Date.now()
|
|
429
824
|
};
|
|
430
825
|
this.insertSession(session);
|
|
826
|
+
this.eventBus.publish({
|
|
827
|
+
type: 'session:created',
|
|
828
|
+
sessionId: session.id,
|
|
829
|
+
channel: sourceSession.channel,
|
|
830
|
+
channelId: sourceSession.channelId,
|
|
831
|
+
projectPath: sourceSession.projectPath,
|
|
832
|
+
name: session.name,
|
|
833
|
+
timestamp: Date.now()
|
|
834
|
+
});
|
|
431
835
|
return session;
|
|
432
836
|
}
|
|
433
|
-
async scanCliSessions(projectPath) {
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
const sessionDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
|
437
|
-
if (!fs.existsSync(sessionDir))
|
|
837
|
+
async scanCliSessions(projectPath, agentId) {
|
|
838
|
+
const adapter = this.getFileAdapter(agentId);
|
|
839
|
+
if (!adapter)
|
|
438
840
|
return [];
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
return files.map(f => ({ uuid: f.uuid, mtime: f.mtime }));
|
|
451
|
-
}
|
|
452
|
-
checkSessionFileExists(projectPath, agentSessionId) {
|
|
453
|
-
const sessionFile = this.getSessionFilePath(projectPath, agentSessionId);
|
|
454
|
-
return fs.existsSync(sessionFile);
|
|
455
|
-
}
|
|
456
|
-
readSessionFirstMessage(projectPath, agentSessionId) {
|
|
457
|
-
const sessionFile = this.getSessionFilePath(projectPath, agentSessionId);
|
|
458
|
-
if (!fs.existsSync(sessionFile))
|
|
841
|
+
return adapter.scanCliSessions(projectPath);
|
|
842
|
+
}
|
|
843
|
+
checkSessionFileExists(projectPath, agentSessionId, agentId) {
|
|
844
|
+
const adapter = this.getFileAdapter(agentId);
|
|
845
|
+
if (!adapter)
|
|
846
|
+
return false;
|
|
847
|
+
return adapter.checkExists(projectPath, agentSessionId);
|
|
848
|
+
}
|
|
849
|
+
readSessionFirstMessage(projectPath, agentSessionId, agentId) {
|
|
850
|
+
const adapter = this.getFileAdapter(agentId);
|
|
851
|
+
if (!adapter)
|
|
459
852
|
return null;
|
|
460
|
-
|
|
461
|
-
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
462
|
-
const lines = content.split('\n').filter(l => l.trim());
|
|
463
|
-
for (const line of lines) {
|
|
464
|
-
const event = JSON.parse(line);
|
|
465
|
-
if (event.type === 'user' && event.message?.role === 'user') {
|
|
466
|
-
const text = this.extractUserMessageText(event.message.content);
|
|
467
|
-
if (text)
|
|
468
|
-
return text;
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
catch (error) {
|
|
473
|
-
logger.warn(`Failed to read session file: ${sessionFile}`, error);
|
|
474
|
-
}
|
|
475
|
-
return null;
|
|
853
|
+
return adapter.readFirstMessage(projectPath, agentSessionId);
|
|
476
854
|
}
|
|
477
|
-
readSessionLastUserMessage(projectPath, agentSessionId) {
|
|
478
|
-
const
|
|
479
|
-
if (!
|
|
855
|
+
readSessionLastUserMessage(projectPath, agentSessionId, agentId) {
|
|
856
|
+
const adapter = this.getFileAdapter(agentId);
|
|
857
|
+
if (!adapter)
|
|
480
858
|
return null;
|
|
481
|
-
|
|
482
|
-
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
483
|
-
const lines = content.split('\n').filter(l => l.trim());
|
|
484
|
-
let lastMessage = null;
|
|
485
|
-
for (const line of lines) {
|
|
486
|
-
const event = JSON.parse(line);
|
|
487
|
-
if (event.type === 'user' && event.message?.role === 'user') {
|
|
488
|
-
lastMessage = this.extractUserMessageText(event.message.content) ?? lastMessage;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
return lastMessage;
|
|
492
|
-
}
|
|
493
|
-
catch (error) {
|
|
494
|
-
logger.warn(`Failed to read last message from session file: ${sessionFile}`, error);
|
|
495
|
-
}
|
|
496
|
-
return null;
|
|
859
|
+
return adapter.readLastUserMessage(projectPath, agentSessionId);
|
|
497
860
|
}
|
|
498
861
|
/**
|
|
499
862
|
* 获取会话文件信息(回合数 + 标题)
|
|
500
863
|
*/
|
|
501
|
-
getSessionFileInfo(projectPath, agentSessionId) {
|
|
502
|
-
const
|
|
503
|
-
if (!
|
|
504
|
-
return { turns: 0 };
|
|
505
|
-
try {
|
|
506
|
-
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
507
|
-
const lines = content.split('\n').filter(l => l.trim());
|
|
508
|
-
let turns = 0;
|
|
509
|
-
let title;
|
|
510
|
-
for (const line of lines) {
|
|
511
|
-
const event = JSON.parse(line);
|
|
512
|
-
if (event.type === 'user' && event.message?.role === 'user') {
|
|
513
|
-
// Only count real user input, skip auto-generated tool_result messages
|
|
514
|
-
const content = event.message.content;
|
|
515
|
-
const isToolResult = Array.isArray(content) && content.every((c) => c.type === 'tool_result');
|
|
516
|
-
if (!isToolResult) {
|
|
517
|
-
turns++;
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
// 提取会话标题(从 session 元数据中)
|
|
521
|
-
if (event.title && !title) {
|
|
522
|
-
title = event.title;
|
|
523
|
-
}
|
|
524
|
-
if (event.sessionTitle && !title) {
|
|
525
|
-
title = event.sessionTitle;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
return { turns, title };
|
|
529
|
-
}
|
|
530
|
-
catch (error) {
|
|
531
|
-
logger.warn(`Failed to read session file info: ${sessionFile}`, error);
|
|
864
|
+
getSessionFileInfo(projectPath, agentSessionId, agentId) {
|
|
865
|
+
const adapter = this.getFileAdapter(agentId);
|
|
866
|
+
if (!adapter)
|
|
532
867
|
return { turns: 0 };
|
|
533
|
-
|
|
868
|
+
return adapter.getFileInfo(projectPath, agentSessionId);
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* 列出 SDK 侧的会话列表(用于名称同步)
|
|
872
|
+
*/
|
|
873
|
+
async listSdkSessions(projectPath, agentId) {
|
|
874
|
+
const adapter = this.getFileAdapter(agentId);
|
|
875
|
+
if (!adapter?.listSdkSessions)
|
|
876
|
+
return [];
|
|
877
|
+
return adapter.listSdkSessions(projectPath);
|
|
534
878
|
}
|
|
535
879
|
async getSessionByUuidPrefix(channel, channelId, uuidPrefix) {
|
|
536
880
|
const rows = this.db.prepare(`
|
|
537
881
|
SELECT * FROM sessions
|
|
538
|
-
WHERE channel = ? AND channel_id = ? AND agent_session_id LIKE ?
|
|
882
|
+
WHERE channel = ? AND channel_id = ? AND agent_session_id LIKE ? AND deleted_at IS NULL
|
|
539
883
|
`).all(channel, channelId, `${uuidPrefix}%`);
|
|
540
884
|
if (rows.length === 0)
|
|
541
885
|
return undefined;
|
|
@@ -544,11 +888,11 @@ export class SessionManager {
|
|
|
544
888
|
}
|
|
545
889
|
return this.rowToSession(rows[0]);
|
|
546
890
|
}
|
|
547
|
-
async importCliSession(channel, channelId, projectPath, agentSessionId) {
|
|
891
|
+
async importCliSession(channel, channelId, projectPath, agentSessionId, agentId = 'claude') {
|
|
548
892
|
// 取消当前活跃会话
|
|
549
|
-
this.
|
|
893
|
+
this.deactivateAllMetadata(channel, channelId);
|
|
550
894
|
// 从 CLI 会话文件读取标题
|
|
551
|
-
const fileInfo = this.getSessionFileInfo(projectPath, agentSessionId);
|
|
895
|
+
const fileInfo = this.getSessionFileInfo(projectPath, agentSessionId, agentId);
|
|
552
896
|
const name = fileInfo.title || `CLI会话-${new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`;
|
|
553
897
|
// 创建新会话记录
|
|
554
898
|
const session = {
|
|
@@ -557,14 +901,25 @@ export class SessionManager {
|
|
|
557
901
|
channelId,
|
|
558
902
|
projectPath,
|
|
559
903
|
threadId: '',
|
|
560
|
-
|
|
904
|
+
agentId,
|
|
905
|
+
chatType: 'private',
|
|
906
|
+
sessionMode: 'interactive',
|
|
561
907
|
agentSessionId,
|
|
908
|
+
metadata: { isActive: true },
|
|
562
909
|
name,
|
|
563
|
-
isActive: true,
|
|
564
910
|
createdAt: Date.now(),
|
|
565
911
|
updatedAt: Date.now()
|
|
566
912
|
};
|
|
567
913
|
this.insertSession(session);
|
|
914
|
+
this.eventBus.publish({
|
|
915
|
+
type: 'session:created',
|
|
916
|
+
sessionId: session.id,
|
|
917
|
+
channel,
|
|
918
|
+
channelId,
|
|
919
|
+
projectPath,
|
|
920
|
+
name,
|
|
921
|
+
timestamp: Date.now()
|
|
922
|
+
});
|
|
568
923
|
return session;
|
|
569
924
|
}
|
|
570
925
|
// ==================== 健康状态管理 ====================
|
|
@@ -596,6 +951,11 @@ export class SessionManager {
|
|
|
596
951
|
lastSuccessTime: row.last_success_time
|
|
597
952
|
};
|
|
598
953
|
}
|
|
954
|
+
/** 当前处于安全模式的会话数 */
|
|
955
|
+
getSafeModeSessionCount() {
|
|
956
|
+
const row = this.db.prepare(`SELECT COUNT(*) as count FROM session_health WHERE safe_mode = 1`).get();
|
|
957
|
+
return row?.count ?? 0;
|
|
958
|
+
}
|
|
599
959
|
/**
|
|
600
960
|
* 记录成功响应(重置错误计数)
|
|
601
961
|
*/
|
|
@@ -659,6 +1019,8 @@ export class SessionManager {
|
|
|
659
1019
|
`).run(sessionId, now, now, now, now);
|
|
660
1020
|
}
|
|
661
1021
|
close() {
|
|
1022
|
+
for (const adapter of this.fileAdapters.values())
|
|
1023
|
+
adapter.close?.();
|
|
662
1024
|
this.db.close();
|
|
663
1025
|
}
|
|
664
1026
|
}
|