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