evolclaw 2.8.3 → 3.1.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 +21 -12
- package/bin/ec.js +29 -0
- package/dist/agents/baseagent-normalize.js +19 -0
- package/dist/agents/claude-runner.js +108 -46
- package/dist/agents/codex-runner.js +13 -14
- package/dist/agents/gemini-runner.js +15 -17
- package/dist/agents/kit-renderer.js +281 -0
- package/dist/agents/resolve.js +134 -0
- package/dist/aun/aid/agentmd.js +186 -0
- package/dist/aun/aid/client.js +134 -0
- package/dist/aun/aid/identity.js +159 -0
- package/dist/aun/aid/index.js +3 -0
- package/dist/aun/aid/lifecycle-log.js +33 -0
- package/dist/aun/aid/types.js +1 -0
- package/dist/aun/aid/validation.js +21 -0
- package/dist/aun/msg/group.js +293 -0
- package/dist/aun/msg/index.js +4 -0
- package/dist/aun/msg/p2p.js +147 -0
- package/dist/aun/msg/payload-type.js +27 -0
- package/dist/aun/msg/upload.js +98 -0
- package/dist/aun/outbox.js +138 -0
- package/dist/aun/rpc/caller.js +42 -0
- package/dist/aun/rpc/connection.js +34 -0
- package/dist/aun/rpc/index.js +2 -0
- package/dist/aun/storage/download.js +29 -0
- package/dist/aun/storage/index.js +3 -0
- package/dist/aun/storage/manage.js +10 -0
- package/dist/aun/storage/upload.js +35 -0
- package/dist/channels/aun.js +1340 -349
- package/dist/channels/dingtalk.js +59 -5
- package/dist/channels/feishu.js +381 -32
- package/dist/channels/qqbot.js +68 -12
- package/dist/channels/wechat.js +63 -4
- package/dist/channels/wecom.js +59 -5
- package/dist/cli/agent.js +800 -0
- package/dist/cli/bench.js +1219 -0
- package/dist/cli/index.js +4513 -0
- package/dist/{utils → cli}/init-channel.js +211 -621
- package/dist/cli/init.js +178 -0
- package/dist/cli/link-rules.js +245 -0
- package/dist/cli/net-check.js +640 -0
- package/dist/cli/watch-msg.js +589 -0
- package/dist/config-store.js +645 -0
- package/dist/core/{agent-loader.js → baseagent-loader.js} +6 -12
- package/dist/core/channel-loader.js +176 -12
- package/dist/core/command-handler.js +883 -848
- package/dist/core/evolagent-registry.js +191 -371
- package/dist/core/evolagent.js +202 -238
- package/dist/core/interaction-router.js +52 -5
- package/dist/core/message/im-renderer.js +486 -0
- package/dist/core/message/items-formatter.js +68 -0
- package/dist/core/message/message-bridge.js +109 -56
- package/dist/core/message/message-log.js +93 -0
- package/dist/core/message/message-processor.js +430 -212
- package/dist/core/message/message-queue.js +13 -6
- package/dist/core/permission.js +116 -11
- package/dist/core/session/adapters/codex-session-file-adapter.js +24 -2
- package/dist/core/session/session-fs-store.js +230 -0
- package/dist/core/session/session-manager.js +740 -777
- package/dist/core/session/session-mapper.js +87 -0
- package/dist/core/trigger/manager.js +122 -0
- package/dist/core/trigger/parser.js +128 -0
- package/dist/core/trigger/scheduler.js +224 -0
- package/dist/data/error-dict.json +118 -0
- package/dist/eck/baseagent-caps.js +18 -0
- package/dist/eck/detect.js +47 -0
- package/dist/eck/init.js +77 -0
- package/dist/eck/rules-loader.js +28 -0
- package/dist/index.js +560 -283
- package/dist/ipc.js +49 -0
- package/dist/net-check.js +640 -0
- package/dist/paths.js +73 -9
- package/dist/types.js +8 -2
- package/dist/utils/aid-lifecycle-log.js +33 -0
- package/dist/utils/atomic-write.js +89 -0
- package/dist/utils/channel-helpers.js +46 -0
- package/dist/utils/cross-platform.js +17 -26
- package/dist/utils/error-utils.js +10 -2
- package/dist/utils/instance-registry.js +434 -0
- package/dist/utils/log-writer.js +217 -0
- package/dist/utils/logger.js +34 -77
- package/dist/utils/media-cache.js +23 -0
- package/dist/utils/npm-ops.js +163 -0
- package/dist/utils/process-introspect.js +122 -0
- package/dist/utils/stats.js +192 -0
- package/dist/watch-msg.js +544 -0
- package/evolclaw-install-aun.md +127 -47
- package/kits/docs/GUIDE.md +20 -0
- package/kits/docs/INDEX.md +52 -0
- package/kits/docs/aun/CHEATSHEET.md +17 -0
- package/kits/docs/aun/SYNC_PROTOCOL.md +15 -0
- package/kits/docs/channels/aun.md +25 -0
- package/kits/docs/channels/feishu.md +27 -0
- package/kits/docs/eck_templates/GUIDE.template.md +22 -0
- package/kits/docs/eck_templates/INDEX.template.md +28 -0
- package/kits/docs/eck_templates/path-registry.template.md +33 -0
- package/kits/docs/eck_templates/runtime.template.md +19 -0
- package/kits/docs/evolclaw/AGENT_CMD.md +31 -0
- package/kits/docs/evolclaw/MSG_GROUP.md +30 -0
- package/kits/docs/evolclaw/MSG_PRIVATE.md +25 -0
- package/kits/docs/evolclaw/self-summary.md +29 -0
- package/kits/docs/evolclaw/tools.md +25 -0
- package/kits/docs/identity/AID_PROFILE_SPEC.md +27 -0
- package/kits/docs/identity/PATH_OPS.md +16 -0
- package/kits/docs/identity/ROLE_DETAIL.md +20 -0
- package/kits/docs/identity/identity-tools.md +26 -0
- package/kits/docs/path-registry.md +43 -0
- package/kits/eck_manifest.json +95 -0
- package/kits/rules/01-overview.md +120 -0
- package/kits/rules/02-navigation.md +75 -0
- package/kits/rules/03-identity.md +34 -0
- package/kits/rules/04-relation.md +49 -0
- package/kits/rules/05-venue.md +45 -0
- package/kits/rules/06-channel.md +43 -0
- package/kits/templates/system-fragments/baseagent.md +2 -0
- package/kits/templates/system-fragments/channel.md +10 -0
- package/kits/templates/system-fragments/identity.md +12 -0
- package/kits/templates/system-fragments/relation.md +9 -0
- package/kits/templates/system-fragments/runtime.md +19 -0
- package/kits/templates/system-fragments/venue.md +5 -0
- package/package.json +10 -6
- package/data/evolclaw.sample.json +0 -60
- package/dist/agents/templates.js +0 -122
- package/dist/channels/aun-ops.js +0 -275
- package/dist/cli.js +0 -2178
- package/dist/config.js +0 -591
- package/dist/core/agent-registry.js +0 -450
- package/dist/core/evolagent-schema.js +0 -72
- package/dist/core/message/stream-flusher.js +0 -238
- package/dist/core/message/thought-emitter.js +0 -162
- package/dist/core/reload-hooks.js +0 -87
- package/dist/prompts/templates.js +0 -122
- package/dist/templates/prompts.md +0 -104
- package/dist/templates/skills.md +0 -66
- package/dist/utils/channel-fingerprint.js +0 -59
- package/dist/utils/error-dict.js +0 -63
- package/dist/utils/format.js +0 -32
- package/dist/utils/init.js +0 -645
- package/dist/utils/migrate-project.js +0 -122
- package/dist/utils/reload-hooks.js +0 -87
- package/dist/utils/stats-collector.js +0 -99
- package/dist/utils/upgrade.js +0 -100
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
2
1
|
import { DEFAULT_PERMISSION_MODE } from '../../types.js';
|
|
3
|
-
import { ensureDir } from '../../
|
|
4
|
-
import { resolvePaths } from '../../paths.js';
|
|
2
|
+
import { ensureDir } from '../../utils/atomic-write.js';
|
|
5
3
|
import { logger } from '../../utils/logger.js';
|
|
6
4
|
import { encodePath } from '../../utils/cross-platform.js';
|
|
5
|
+
import { chatDirPath, generateSessionId, formatTimestamp, atomicWriteJson, appendJsonl, readJsonFile, readLastJsonlLine, readAllJsonlLines, scanChatDirs, scanMetaFiles, ensureChatDir, readThreadIndex, writeThreadIndex, } from './session-fs-store.js';
|
|
6
|
+
import { sessionToFile, fileToSession } from './session-mapper.js';
|
|
7
7
|
import path from 'path';
|
|
8
|
+
import fs from 'fs';
|
|
8
9
|
import os from 'os';
|
|
9
10
|
export class SessionManager {
|
|
10
|
-
|
|
11
|
+
sessionsDir;
|
|
11
12
|
eventBus;
|
|
12
13
|
ownerResolver;
|
|
13
14
|
adminResolver;
|
|
14
15
|
sessionModeResolver;
|
|
15
16
|
fileAdapters = new Map();
|
|
16
17
|
sessionEncryptState = new Map();
|
|
17
|
-
constructor(
|
|
18
|
-
ensureDir(
|
|
19
|
-
this.
|
|
18
|
+
constructor(sessionsDir, eventBus, ownerResolver, adminResolver) {
|
|
19
|
+
ensureDir(sessionsDir);
|
|
20
|
+
this.sessionsDir = sessionsDir;
|
|
20
21
|
this.eventBus = eventBus;
|
|
21
22
|
this.ownerResolver = ownerResolver;
|
|
22
23
|
this.adminResolver = adminResolver;
|
|
23
|
-
this.initDatabase();
|
|
24
24
|
}
|
|
25
25
|
setOwnerResolver(resolver) {
|
|
26
26
|
this.ownerResolver = resolver;
|
|
@@ -31,8 +31,10 @@ export class SessionManager {
|
|
|
31
31
|
setSessionModeResolver(resolver) {
|
|
32
32
|
this.sessionModeResolver = resolver;
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
resolveDefaultSessionMode(channel, chatType, peerType) {
|
|
35
|
+
// 非 human 对端(ai/bot)强制 proactive,无视 agent 的默认 chatmode 配置
|
|
36
|
+
if (peerType && peerType !== 'human' && peerType !== 'unknown')
|
|
37
|
+
return 'proactive';
|
|
36
38
|
const ct = chatType || 'private';
|
|
37
39
|
const resolved = this.sessionModeResolver?.(channel, ct);
|
|
38
40
|
return resolved || 'interactive';
|
|
@@ -44,9 +46,6 @@ export class SessionManager {
|
|
|
44
46
|
getFileAdapter(agentId) {
|
|
45
47
|
return this.fileAdapters.get(agentId);
|
|
46
48
|
}
|
|
47
|
-
getDatabase() {
|
|
48
|
-
return this.db;
|
|
49
|
-
}
|
|
50
49
|
getProjectDirName(projectPath) {
|
|
51
50
|
return encodePath(projectPath);
|
|
52
51
|
}
|
|
@@ -55,27 +54,6 @@ export class SessionManager {
|
|
|
55
54
|
const encodedPath = this.getProjectDirName(projectPath);
|
|
56
55
|
return path.join(homeDir, '.claude', 'projects', encodedPath, `${sessionId}.jsonl`);
|
|
57
56
|
}
|
|
58
|
-
rowToSession(row) {
|
|
59
|
-
const metadata = row.metadata ? JSON.parse(row.metadata) : undefined;
|
|
60
|
-
return {
|
|
61
|
-
id: row.id,
|
|
62
|
-
channel: row.channel,
|
|
63
|
-
channelId: row.channel_id,
|
|
64
|
-
projectPath: row.project_path,
|
|
65
|
-
threadId: row.thread_id || '',
|
|
66
|
-
agentId: row.agent_id || 'claude',
|
|
67
|
-
chatType: row.chat_type || 'private',
|
|
68
|
-
sessionMode: row.session_mode || 'interactive',
|
|
69
|
-
agentSessionId: row.agent_session_id,
|
|
70
|
-
metadata,
|
|
71
|
-
name: row.name,
|
|
72
|
-
processingState: row.processing_state || undefined,
|
|
73
|
-
createdAt: row.created_at,
|
|
74
|
-
updatedAt: row.updated_at,
|
|
75
|
-
deletedAt: row.deleted_at ?? undefined,
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
/** 根据 userId 计算身份 */
|
|
79
57
|
resolveIdentity(channel, userId) {
|
|
80
58
|
if (!userId)
|
|
81
59
|
return { role: 'anonymous', mode: 'interactive' };
|
|
@@ -85,58 +63,9 @@ export class SessionManager {
|
|
|
85
63
|
return { role: 'admin', mode: 'interactive' };
|
|
86
64
|
return { role: 'guest', mode: 'interactive' };
|
|
87
65
|
}
|
|
88
|
-
/** 更新 session 的 identity(owner 绑定后调用) */
|
|
89
66
|
async updateIdentity(sessionId, identity) {
|
|
90
|
-
// identity 不持久化到 DB,仅更新内存中的返回值
|
|
91
|
-
// 调用方应直接修改持有的 session 对象
|
|
92
67
|
logger.debug(`[SessionManager] updateIdentity: sessionId=${sessionId}, role=${identity.role}`);
|
|
93
68
|
}
|
|
94
|
-
/** 取消所有活跃会话(通过 metadata.isActive) */
|
|
95
|
-
deactivateAllMetadata(channel, channelId) {
|
|
96
|
-
const rows = this.db.prepare(`
|
|
97
|
-
SELECT id, metadata FROM sessions
|
|
98
|
-
WHERE channel = ? AND channel_id = ? AND json_extract(metadata, '$.isActive') = true
|
|
99
|
-
`).all(channel, channelId);
|
|
100
|
-
for (const row of rows) {
|
|
101
|
-
const metadata = row.metadata ? JSON.parse(row.metadata) : {};
|
|
102
|
-
metadata.isActive = false;
|
|
103
|
-
this.db.prepare(`
|
|
104
|
-
UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?
|
|
105
|
-
`).run(JSON.stringify(metadata), Date.now(), row.id);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
/** 获取当前活跃 session 的 chatType(用于新建 session 时继承) */
|
|
109
|
-
getActiveChatType(channel, channelId) {
|
|
110
|
-
const row = this.db.prepare(`
|
|
111
|
-
SELECT chat_type FROM sessions
|
|
112
|
-
WHERE channel = ? AND channel_id = ? AND json_extract(metadata, '$.isActive') = true AND thread_id = '' AND deleted_at IS NULL
|
|
113
|
-
ORDER BY updated_at DESC LIMIT 1
|
|
114
|
-
`).get(channel, channelId);
|
|
115
|
-
return row?.chat_type || 'private';
|
|
116
|
-
}
|
|
117
|
-
validateSessionFile(row) {
|
|
118
|
-
const agentSessionId = row.agent_session_id;
|
|
119
|
-
if (!agentSessionId)
|
|
120
|
-
return undefined;
|
|
121
|
-
const agentId = row.agent_id || 'claude';
|
|
122
|
-
const adapter = this.getFileAdapter(agentId);
|
|
123
|
-
if (!adapter) {
|
|
124
|
-
// 无适配器:无法验证文件,信任 DB 记录
|
|
125
|
-
return agentSessionId;
|
|
126
|
-
}
|
|
127
|
-
if (adapter.checkExists(row.project_path, agentSessionId)) {
|
|
128
|
-
return agentSessionId;
|
|
129
|
-
}
|
|
130
|
-
logger.warn(`Session file not found for ${agentId}: ${agentSessionId}, clearing session ID`);
|
|
131
|
-
this.db.prepare(`UPDATE sessions SET agent_session_id = NULL WHERE id = ?`).run(row.id);
|
|
132
|
-
return undefined;
|
|
133
|
-
}
|
|
134
|
-
insertSession(session) {
|
|
135
|
-
this.db.prepare(`
|
|
136
|
-
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)
|
|
137
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
138
|
-
`).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);
|
|
139
|
-
}
|
|
140
69
|
extractUserMessageText(messageContent) {
|
|
141
70
|
if (typeof messageContent === 'string') {
|
|
142
71
|
const text = messageContent.trim().replace(/\s+/g, ' ');
|
|
@@ -151,306 +80,290 @@ export class SessionManager {
|
|
|
151
80
|
}
|
|
152
81
|
return null;
|
|
153
82
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
id TEXT PRIMARY KEY,
|
|
176
|
-
channel TEXT NOT NULL,
|
|
177
|
-
channel_id TEXT NOT NULL,
|
|
178
|
-
agent_id TEXT NOT NULL DEFAULT 'claude',
|
|
179
|
-
thread_id TEXT NOT NULL DEFAULT '',
|
|
180
|
-
chat_type TEXT NOT NULL DEFAULT 'private',
|
|
181
|
-
session_mode TEXT NOT NULL DEFAULT 'interactive',
|
|
182
|
-
project_path TEXT NOT NULL,
|
|
183
|
-
agent_session_id TEXT,
|
|
184
|
-
name TEXT,
|
|
185
|
-
created_at INTEGER NOT NULL,
|
|
186
|
-
updated_at INTEGER NOT NULL,
|
|
187
|
-
metadata TEXT,
|
|
188
|
-
deleted_at INTEGER
|
|
189
|
-
)
|
|
190
|
-
`);
|
|
191
|
-
// 迁移数据:is_group → chat_type, agent_type → agent_id
|
|
192
|
-
this.db.exec(`
|
|
193
|
-
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)
|
|
194
|
-
SELECT
|
|
195
|
-
id,
|
|
196
|
-
channel,
|
|
197
|
-
channel_id,
|
|
198
|
-
COALESCE(agent_type, 'claude'),
|
|
199
|
-
COALESCE(thread_id, ''),
|
|
200
|
-
CASE WHEN is_group = 1 THEN 'group' ELSE 'private' END,
|
|
201
|
-
'interactive',
|
|
202
|
-
project_path,
|
|
203
|
-
agent_session_id,
|
|
204
|
-
name,
|
|
205
|
-
created_at,
|
|
206
|
-
updated_at,
|
|
207
|
-
metadata,
|
|
208
|
-
deleted_at
|
|
209
|
-
FROM sessions
|
|
210
|
-
`);
|
|
211
|
-
this.db.exec(`DROP TABLE sessions`);
|
|
212
|
-
this.db.exec(`ALTER TABLE sessions_new RENAME TO sessions`);
|
|
213
|
-
// 创建新索引
|
|
214
|
-
this.db.exec(`
|
|
215
|
-
CREATE INDEX IF NOT EXISTS idx_session_space
|
|
216
|
-
ON sessions(channel, channel_id, agent_id, thread_id)
|
|
217
|
-
WHERE deleted_at IS NULL
|
|
218
|
-
`);
|
|
219
|
-
this.db.exec(`
|
|
220
|
-
CREATE INDEX IF NOT EXISTS idx_session_active
|
|
221
|
-
ON sessions(channel, channel_id)
|
|
222
|
-
WHERE deleted_at IS NULL
|
|
223
|
-
`);
|
|
224
|
-
logger.info('✓ Database migration completed (session model refactored)');
|
|
83
|
+
// ─── File I/O helpers ───
|
|
84
|
+
/**
|
|
85
|
+
* 解析 chat 目录路径。
|
|
86
|
+
* 1. 先扫描所有 chat 目录,按 channelId 查找匹配项(同时 channelType==channel 或缺失时直接 channelId 匹配)
|
|
87
|
+
* 2. 找不到则按 fallback:channelType=channel(实例名),selfId=null
|
|
88
|
+
*
|
|
89
|
+
* 这样保持兼容:不知道 channelType 的 caller 仍可以用 (channel, channelId) 调用。
|
|
90
|
+
*/
|
|
91
|
+
resolveChatDir(channel, channelId) {
|
|
92
|
+
// 优先尝试从已有目录里找
|
|
93
|
+
const dirs = scanChatDirs(this.sessionsDir);
|
|
94
|
+
for (const d of dirs) {
|
|
95
|
+
if (d.channelId !== channelId)
|
|
96
|
+
continue;
|
|
97
|
+
// 验证 active.json 或 meta 文件里 channel(实例名)匹配
|
|
98
|
+
const active = readJsonFile(path.join(d.dirPath, 'active.json'));
|
|
99
|
+
if (active && active.channel === channel)
|
|
100
|
+
return d.dirPath;
|
|
101
|
+
// 没 active.json 时,看 channelType 是否能匹配 channel
|
|
102
|
+
if (!active && d.channelType === channel)
|
|
103
|
+
return d.dirPath;
|
|
225
104
|
}
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
this.db.exec(`DROP TABLE IF EXISTS sessions_new`);
|
|
237
|
-
this.db.exec(`
|
|
238
|
-
CREATE TABLE sessions_new (
|
|
239
|
-
id TEXT PRIMARY KEY,
|
|
240
|
-
channel TEXT NOT NULL,
|
|
241
|
-
channel_id TEXT NOT NULL,
|
|
242
|
-
project_path TEXT NOT NULL,
|
|
243
|
-
thread_id TEXT NOT NULL DEFAULT '',
|
|
244
|
-
agent_type TEXT NOT NULL DEFAULT 'claude',
|
|
245
|
-
agent_session_id TEXT,
|
|
246
|
-
name TEXT,
|
|
247
|
-
is_active INTEGER NOT NULL DEFAULT 0,
|
|
248
|
-
created_at INTEGER NOT NULL,
|
|
249
|
-
updated_at INTEGER NOT NULL,
|
|
250
|
-
metadata TEXT
|
|
251
|
-
);
|
|
252
|
-
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)
|
|
253
|
-
SELECT id, channel, channel_id, project_path, '', 'claude', claude_session_id, name, is_active, created_at, updated_at, NULL FROM sessions;
|
|
254
|
-
DROP TABLE sessions;
|
|
255
|
-
ALTER TABLE sessions_new RENAME TO sessions;
|
|
256
|
-
`);
|
|
257
|
-
// 话题会话唯一约束(thread_id 非空时才生效)
|
|
258
|
-
this.db.exec(`
|
|
259
|
-
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_thread
|
|
260
|
-
ON sessions(channel, channel_id, project_path, thread_id)
|
|
261
|
-
WHERE thread_id != ''
|
|
262
|
-
`);
|
|
263
|
-
logger.info('✓ Database migration completed (thread support added)');
|
|
264
|
-
}
|
|
265
|
-
// Migration: add is_group column
|
|
266
|
-
if (!hasIsGroup) {
|
|
267
|
-
logger.info('Migrating database schema (adding is_group)...');
|
|
268
|
-
const addIsGroupCol = 'ALTER TABLE sessions ADD COLUMN is_group INTEGER NOT NULL DEFAULT 0';
|
|
269
|
-
this.db.exec(addIsGroupCol);
|
|
270
|
-
logger.info('✓ Database migration completed (is_group added)');
|
|
271
|
-
}
|
|
272
|
-
// Reset incorrect is_group values (oc_ prefix doesn't reliably indicate group chat)
|
|
273
|
-
if (hasIsGroup) {
|
|
274
|
-
this.db.exec("UPDATE sessions SET is_group = 0 WHERE channel = 'feishu'");
|
|
275
|
-
}
|
|
276
|
-
// Migration: add deleted_at column
|
|
277
|
-
if (!hasDeletedAt) {
|
|
278
|
-
logger.info('Migrating database schema (adding deleted_at)...');
|
|
279
|
-
this.db.exec(`ALTER TABLE sessions ADD COLUMN deleted_at INTEGER`);
|
|
280
|
-
logger.info('✓ Database migration completed (deleted_at added)');
|
|
281
|
-
}
|
|
105
|
+
// Fallback:按 channel 当 channelType 创建(旧路径布局兼容)
|
|
106
|
+
return chatDirPath(this.sessionsDir, channel, channelId);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 给定明确的 channelType + selfId 时直接计算路径(不扫描)。
|
|
110
|
+
* 用于 caller 已经知道完整路由信息的场景(如 message-bridge 透传)。
|
|
111
|
+
*/
|
|
112
|
+
resolveChatDirExact(channel, channelId, channelType, selfId) {
|
|
113
|
+
if (channelType) {
|
|
114
|
+
return chatDirPath(this.sessionsDir, channelType, channelId, selfId);
|
|
282
115
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
116
|
+
return this.resolveChatDir(channel, channelId);
|
|
117
|
+
}
|
|
118
|
+
resolveChatDirFromSession(session) {
|
|
119
|
+
const channelType = session.channelType || session.channel;
|
|
120
|
+
return chatDirPath(this.sessionsDir, channelType, session.channelId, session.selfId);
|
|
121
|
+
}
|
|
122
|
+
/** Public accessor: get the chat directory path for a session (for message log etc.) */
|
|
123
|
+
getChatDir(session) {
|
|
124
|
+
return this.resolveChatDirFromSession(session);
|
|
125
|
+
}
|
|
126
|
+
/** Like resolveChatDir but also ensures the dir + _threads + _trash exist. */
|
|
127
|
+
ensureResolvedChatDir(channel, channelId) {
|
|
128
|
+
const dir = this.resolveChatDir(channel, channelId);
|
|
129
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
130
|
+
fs.mkdirSync(path.join(dir, '_threads'), { recursive: true });
|
|
131
|
+
fs.mkdirSync(path.join(dir, '_trash'), { recursive: true });
|
|
132
|
+
return dir;
|
|
133
|
+
}
|
|
134
|
+
/** 推断给定 chat 的 channelType(优先取 active.json)。无活跃时回落到 channel 实例名。 */
|
|
135
|
+
inferChannelType(channel, channelId) {
|
|
136
|
+
const active = this.readActive(channel, channelId);
|
|
137
|
+
return active?.channelType || channel;
|
|
138
|
+
}
|
|
139
|
+
/** 从 active 推断 selfId(已有 session 的复用) */
|
|
140
|
+
inferSelfId(channel, channelId) {
|
|
141
|
+
const active = this.readActive(channel, channelId);
|
|
142
|
+
return active?.selfId;
|
|
143
|
+
}
|
|
144
|
+
readActive(channel, channelId) {
|
|
145
|
+
const dir = this.resolveChatDir(channel, channelId);
|
|
146
|
+
const file = readJsonFile(path.join(dir, 'active.json'));
|
|
147
|
+
if (!file)
|
|
148
|
+
return undefined;
|
|
149
|
+
return fileToSession(file);
|
|
150
|
+
}
|
|
151
|
+
writeActive(channel, channelId, session) {
|
|
152
|
+
const dir = this.ensureChatDirForSession(session);
|
|
153
|
+
const file = sessionToFile(session);
|
|
154
|
+
atomicWriteJson(path.join(dir, 'active.json'), file);
|
|
155
|
+
}
|
|
156
|
+
clearActive(channel, channelId) {
|
|
157
|
+
const dir = this.resolveChatDir(channel, channelId);
|
|
158
|
+
const activePath = path.join(dir, 'active.json');
|
|
159
|
+
try {
|
|
160
|
+
fs.unlinkSync(activePath);
|
|
291
161
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
let migrated = 0;
|
|
296
|
-
for (const row of rows) {
|
|
297
|
-
try {
|
|
298
|
-
const meta = JSON.parse(row.metadata);
|
|
299
|
-
const rootId = meta.feishu?.rootId ?? meta.threadRootId ?? meta.replyOpts?.rootId;
|
|
300
|
-
if (!rootId && !meta.feishu && !meta.threadRootId && !meta.replyOpts)
|
|
301
|
-
continue;
|
|
302
|
-
// Generate replyContext from rootId if missing
|
|
303
|
-
if (rootId && !meta.replyContext) {
|
|
304
|
-
meta.replyContext = { replyToMessageId: rootId, replyInThread: true };
|
|
305
|
-
}
|
|
306
|
-
// Clean up all legacy fields
|
|
307
|
-
delete meta.feishu;
|
|
308
|
-
delete meta.threadRootId;
|
|
309
|
-
delete meta.replyOpts;
|
|
310
|
-
this.db.prepare('UPDATE sessions SET metadata = ? WHERE id = ?')
|
|
311
|
-
.run(JSON.stringify(meta), row.id);
|
|
312
|
-
migrated++;
|
|
313
|
-
}
|
|
314
|
-
catch { /* skip malformed JSON */ }
|
|
315
|
-
}
|
|
316
|
-
if (migrated > 0) {
|
|
317
|
-
logger.info(`✓ Migrated ${migrated} session(s): rootId normalized to replyContext`);
|
|
318
|
-
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
if (e.code !== 'ENOENT')
|
|
164
|
+
throw e;
|
|
319
165
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
166
|
+
}
|
|
167
|
+
ensureChatDirForSession(session) {
|
|
168
|
+
const channelType = session.channelType || session.channel;
|
|
169
|
+
return ensureChatDir(this.sessionsDir, channelType, session.channelId, session.selfId);
|
|
170
|
+
}
|
|
171
|
+
metaFilePath(chatDir, sessionId) {
|
|
172
|
+
return path.join(chatDir, `${sessionId}.jsonl`);
|
|
173
|
+
}
|
|
174
|
+
appendMeta(channel, channelId, session) {
|
|
175
|
+
const dir = this.ensureChatDirForSession(session);
|
|
176
|
+
const isThread = !!session.threadId;
|
|
177
|
+
const targetDir = isThread ? path.join(dir, '_threads') : dir;
|
|
178
|
+
const file = sessionToFile(session);
|
|
179
|
+
const metaPath = this.metaFilePath(targetDir, session.id);
|
|
180
|
+
appendJsonl(metaPath, file);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* 比较两个 SessionFile 是否在内容上相等(忽略 updatedAt / updatedAtStr)。
|
|
184
|
+
* 用于跳过"没真变化"的写入,避免 jsonl 写放大。
|
|
185
|
+
*/
|
|
186
|
+
sessionFilesEqual(a, b) {
|
|
187
|
+
const stripVolatile = ({ updatedAt, updatedAtStr, ...rest }) => rest;
|
|
188
|
+
return JSON.stringify(stripVolatile(a)) === JSON.stringify(stripVolatile(b));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Append meta + write active.json,但只在 session 内容(除 updatedAt 外)真正变化时才写。
|
|
192
|
+
* prev 是修改前的快照(用于 diff),next 是修改后的 session。
|
|
193
|
+
* 返回是否发生了写入。
|
|
194
|
+
*/
|
|
195
|
+
writeSessionIfChanged(channel, channelId, prev, next) {
|
|
196
|
+
if (prev) {
|
|
197
|
+
const prevFile = sessionToFile(prev);
|
|
198
|
+
const nextFile = sessionToFile(next);
|
|
199
|
+
if (this.sessionFilesEqual(prevFile, nextFile))
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
next.updatedAt = Date.now();
|
|
203
|
+
this.appendMeta(channel, channelId, next);
|
|
204
|
+
const active = this.readActive(channel, channelId);
|
|
205
|
+
if (active && active.id === next.id) {
|
|
206
|
+
// 保留 active.json 中已有的 activeTask(markProcessing 写入的处理状态)
|
|
207
|
+
if (active.processingState && !next.processingState) {
|
|
208
|
+
next.processingState = active.processingState;
|
|
338
209
|
}
|
|
210
|
+
this.writeActive(channel, channelId, next);
|
|
339
211
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
thread_id TEXT NOT NULL DEFAULT '',
|
|
348
|
-
chat_type TEXT NOT NULL DEFAULT 'private',
|
|
349
|
-
session_mode TEXT NOT NULL DEFAULT 'interactive',
|
|
350
|
-
project_path TEXT NOT NULL,
|
|
351
|
-
agent_session_id TEXT,
|
|
352
|
-
name TEXT,
|
|
353
|
-
processing_state TEXT,
|
|
354
|
-
created_at INTEGER NOT NULL,
|
|
355
|
-
updated_at INTEGER NOT NULL,
|
|
356
|
-
metadata TEXT,
|
|
357
|
-
deleted_at INTEGER
|
|
358
|
-
)
|
|
359
|
-
`);
|
|
360
|
-
// 会话空间索引(查询优化,无唯一约束)
|
|
361
|
-
this.db.exec(`
|
|
362
|
-
CREATE INDEX IF NOT EXISTS idx_session_space
|
|
363
|
-
ON sessions(channel, channel_id, agent_id, thread_id)
|
|
364
|
-
WHERE deleted_at IS NULL
|
|
365
|
-
`);
|
|
366
|
-
this.db.exec(`
|
|
367
|
-
CREATE INDEX IF NOT EXISTS idx_session_active
|
|
368
|
-
ON sessions(channel, channel_id)
|
|
369
|
-
WHERE deleted_at IS NULL
|
|
370
|
-
`);
|
|
371
|
-
// 创建消息去重表
|
|
372
|
-
this.db.exec(`
|
|
373
|
-
CREATE TABLE IF NOT EXISTS processed_messages (
|
|
374
|
-
message_id TEXT PRIMARY KEY,
|
|
375
|
-
channel TEXT NOT NULL,
|
|
376
|
-
channel_id TEXT NOT NULL,
|
|
377
|
-
processed_at INTEGER NOT NULL
|
|
378
|
-
);
|
|
379
|
-
CREATE INDEX IF NOT EXISTS idx_processed_at ON processed_messages(processed_at);
|
|
380
|
-
`);
|
|
381
|
-
// 创建会话健康状态表
|
|
382
|
-
this.db.exec(`
|
|
383
|
-
CREATE TABLE IF NOT EXISTS session_health (
|
|
384
|
-
session_id TEXT PRIMARY KEY,
|
|
385
|
-
consecutive_errors INTEGER NOT NULL DEFAULT 0,
|
|
386
|
-
last_error TEXT,
|
|
387
|
-
last_error_type TEXT,
|
|
388
|
-
safe_mode INTEGER NOT NULL DEFAULT 0,
|
|
389
|
-
last_success_time INTEGER NOT NULL,
|
|
390
|
-
created_at INTEGER NOT NULL,
|
|
391
|
-
updated_at INTEGER NOT NULL,
|
|
392
|
-
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
393
|
-
);
|
|
394
|
-
CREATE INDEX IF NOT EXISTS idx_session_health_safe_mode ON session_health(safe_mode);
|
|
395
|
-
`);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
readMetaLatest(metaFilePath) {
|
|
215
|
+
const file = readLastJsonlLine(metaFilePath);
|
|
216
|
+
if (!file)
|
|
217
|
+
return undefined;
|
|
218
|
+
return fileToSession(file);
|
|
396
219
|
}
|
|
397
220
|
/**
|
|
398
|
-
*
|
|
399
|
-
*
|
|
221
|
+
* 为 by-sessionId 改方法加载"当前 session 状态"。
|
|
222
|
+
*
|
|
223
|
+
* 设计契约(docs/refactor/01-db-to-fs.md):
|
|
224
|
+
* active.json 是热路径权威源。.jsonl 是历史档案。
|
|
225
|
+
*
|
|
226
|
+
* 读取策略:
|
|
227
|
+
* 1. 先按 sessionId 定位 .jsonl 文件(确认 session 存在 + 拿到 channel/channelId)
|
|
228
|
+
* 2. 优先读 active.json(如果 active.id === sessionId)—— 当前状态
|
|
229
|
+
* 3. 否则 fallback 到 .jsonl 末行 —— 非活跃 session 的更新(如多 session 并存时改非 active 那个)
|
|
230
|
+
*
|
|
231
|
+
* 返回 { current, prev }:
|
|
232
|
+
* - current 用于 caller 修改后写回
|
|
233
|
+
* - prev 是 current 的初始快照(用于 writeSessionIfChanged 的 diff 检查)
|
|
400
234
|
*/
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
235
|
+
loadSessionForUpdate(sessionId) {
|
|
236
|
+
const found = this.findSessionFileById(sessionId);
|
|
237
|
+
if (!found)
|
|
238
|
+
return undefined;
|
|
239
|
+
// 先读 .jsonl 末行拿 channel/channelId(active.json 文件路径需要这两个)
|
|
240
|
+
const fromJsonl = this.readMetaLatest(found.metaPath);
|
|
241
|
+
if (!fromJsonl)
|
|
242
|
+
return undefined;
|
|
243
|
+
// 优先用 active.json 的当前状态(如果它就是这个 sessionId)
|
|
244
|
+
const active = this.readActive(fromJsonl.channel, fromJsonl.channelId);
|
|
245
|
+
const base = (active && active.id === sessionId) ? active : fromJsonl;
|
|
246
|
+
// 深拷贝避免 caller 改 current 时污染 prev
|
|
247
|
+
const current = JSON.parse(JSON.stringify(base));
|
|
248
|
+
const prev = JSON.parse(JSON.stringify(base));
|
|
249
|
+
return { current, prev };
|
|
250
|
+
}
|
|
251
|
+
validateSessionFile(session) {
|
|
252
|
+
const agentSessionId = session.agentSessionId;
|
|
253
|
+
if (!agentSessionId)
|
|
254
|
+
return undefined;
|
|
255
|
+
const agentId = session.agentId || 'claude';
|
|
256
|
+
const adapter = this.getFileAdapter(agentId);
|
|
257
|
+
if (!adapter)
|
|
258
|
+
return agentSessionId;
|
|
259
|
+
if (adapter.checkExists(session.projectPath, agentSessionId))
|
|
260
|
+
return agentSessionId;
|
|
261
|
+
logger.warn(`Session file not found for ${agentId}: ${agentSessionId}, clearing session ID`);
|
|
262
|
+
const prev = JSON.parse(JSON.stringify(session));
|
|
263
|
+
session.agentSessionId = undefined;
|
|
264
|
+
this.writeSessionIfChanged(session.channel, session.channelId, prev, session);
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
getActiveChatType(channel, channelId) {
|
|
268
|
+
const active = this.readActive(channel, channelId);
|
|
269
|
+
if (active && !active.threadId)
|
|
270
|
+
return active.chatType || 'private';
|
|
271
|
+
return 'private';
|
|
272
|
+
}
|
|
273
|
+
findAllSessionsInChat(chatDir, includeThreads = true) {
|
|
274
|
+
const metaFiles = scanMetaFiles(chatDir);
|
|
275
|
+
const results = [];
|
|
276
|
+
for (const metaFile of metaFiles) {
|
|
277
|
+
const session = this.readMetaLatest(path.join(chatDir, metaFile));
|
|
278
|
+
if (session)
|
|
279
|
+
results.push(session);
|
|
280
|
+
}
|
|
281
|
+
if (includeThreads) {
|
|
282
|
+
const threadsDir = path.join(chatDir, '_threads');
|
|
283
|
+
const threadMetas = scanMetaFiles(threadsDir);
|
|
284
|
+
for (const metaFile of threadMetas) {
|
|
285
|
+
const session = this.readMetaLatest(path.join(threadsDir, metaFile));
|
|
286
|
+
if (session)
|
|
287
|
+
results.push(session);
|
|
413
288
|
}
|
|
414
|
-
catch { /* skip invalid metadata */ }
|
|
415
289
|
}
|
|
416
|
-
|
|
417
|
-
|
|
290
|
+
return results;
|
|
291
|
+
}
|
|
292
|
+
findSessionFileById(sessionId) {
|
|
293
|
+
const chatDirs = scanChatDirs(this.sessionsDir);
|
|
294
|
+
for (const { dirPath } of chatDirs) {
|
|
295
|
+
const mainPath = path.join(dirPath, `${sessionId}.jsonl`);
|
|
296
|
+
try {
|
|
297
|
+
fs.statSync(mainPath);
|
|
298
|
+
return { chatDir: dirPath, metaPath: mainPath, isThread: false };
|
|
299
|
+
}
|
|
300
|
+
catch { }
|
|
301
|
+
const threadPath = path.join(dirPath, '_threads', `${sessionId}.jsonl`);
|
|
302
|
+
try {
|
|
303
|
+
fs.statSync(threadPath);
|
|
304
|
+
return { chatDir: dirPath, metaPath: threadPath, isThread: true };
|
|
305
|
+
}
|
|
306
|
+
catch { }
|
|
418
307
|
}
|
|
308
|
+
return undefined;
|
|
419
309
|
}
|
|
420
|
-
|
|
421
|
-
* 获取指定渠道所有已知的 thread_id(用于重启后预填充 seenThreads)
|
|
422
|
-
*/
|
|
310
|
+
// ─── Public API ───
|
|
423
311
|
getKnownThreadIds(channel) {
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
312
|
+
const chatDirs = scanChatDirs(this.sessionsDir);
|
|
313
|
+
const threadIds = [];
|
|
314
|
+
for (const { dirPath } of chatDirs) {
|
|
315
|
+
const active = readJsonFile(path.join(dirPath, 'active.json'));
|
|
316
|
+
const matchInstance = active?.channel === channel;
|
|
317
|
+
// 也兼容没 active.json 时按目录顶层 channelType 匹配(fallback 路径布局)
|
|
318
|
+
if (!matchInstance)
|
|
319
|
+
continue;
|
|
320
|
+
const index = readThreadIndex(dirPath);
|
|
321
|
+
for (const tid of Object.keys(index))
|
|
322
|
+
threadIds.push(tid);
|
|
323
|
+
}
|
|
324
|
+
return threadIds;
|
|
429
325
|
}
|
|
430
|
-
/**
|
|
431
|
-
* 标记会话为处理中(实时写 DB,crash 也能恢复)
|
|
432
|
-
* processing_state 格式: "timestamp:taskId"
|
|
433
|
-
*/
|
|
434
326
|
markProcessing(sessionId, taskId) {
|
|
435
327
|
const now = Date.now();
|
|
436
328
|
const state = taskId ? `${now}:${taskId}` : String(now);
|
|
437
|
-
|
|
438
|
-
|
|
329
|
+
const chatDirs = scanChatDirs(this.sessionsDir);
|
|
330
|
+
for (const { dirPath } of chatDirs) {
|
|
331
|
+
const active = readJsonFile(path.join(dirPath, 'active.json'));
|
|
332
|
+
if (active && active.id === sessionId) {
|
|
333
|
+
active.activeTask = state;
|
|
334
|
+
active.updatedAt = now;
|
|
335
|
+
active.updatedAtStr = formatTimestamp(now);
|
|
336
|
+
atomicWriteJson(path.join(dirPath, 'active.json'), active);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
439
340
|
}
|
|
440
|
-
/** 从 processing_state 解析当前活跃 taskId */
|
|
441
341
|
getActiveTaskId(sessionId) {
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
342
|
+
const chatDirs = scanChatDirs(this.sessionsDir);
|
|
343
|
+
for (const { dirPath } of chatDirs) {
|
|
344
|
+
const active = readJsonFile(path.join(dirPath, 'active.json'));
|
|
345
|
+
if (active && active.id === sessionId) {
|
|
346
|
+
if (!active.activeTask)
|
|
347
|
+
return undefined;
|
|
348
|
+
const colonIdx = active.activeTask.indexOf(':');
|
|
349
|
+
return colonIdx > 0 ? active.activeTask.slice(colonIdx + 1) : undefined;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return undefined;
|
|
447
353
|
}
|
|
448
|
-
/**
|
|
449
|
-
* 清除会话处理中状态
|
|
450
|
-
*/
|
|
451
354
|
clearProcessing(sessionId) {
|
|
452
|
-
|
|
453
|
-
|
|
355
|
+
const now = Date.now();
|
|
356
|
+
const chatDirs = scanChatDirs(this.sessionsDir);
|
|
357
|
+
for (const { dirPath } of chatDirs) {
|
|
358
|
+
const active = readJsonFile(path.join(dirPath, 'active.json'));
|
|
359
|
+
if (active && active.id === sessionId) {
|
|
360
|
+
active.activeTask = null;
|
|
361
|
+
active.updatedAt = now;
|
|
362
|
+
active.updatedAtStr = formatTimestamp(now);
|
|
363
|
+
atomicWriteJson(path.join(dirPath, 'active.json'), active);
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
454
367
|
this.sessionEncryptState.delete(sessionId);
|
|
455
368
|
}
|
|
456
369
|
setSessionEncrypt(sessionId, encrypted) {
|
|
@@ -459,152 +372,140 @@ export class SessionManager {
|
|
|
459
372
|
getSessionEncrypt(sessionId) {
|
|
460
373
|
return this.sessionEncryptState.get(sessionId);
|
|
461
374
|
}
|
|
462
|
-
/**
|
|
463
|
-
* 获取所有处于 processing 状态的会话(用于重启后恢复)
|
|
464
|
-
* @param maxAgeMs 最大存活时间(超过则视为超时,清除状态)默认 1 小时
|
|
465
|
-
*/
|
|
466
375
|
getPendingProcessingSessions(maxAgeMs = 60 * 60 * 1000) {
|
|
467
|
-
const rows = this.db.prepare(`
|
|
468
|
-
SELECT * FROM sessions
|
|
469
|
-
WHERE processing_state IS NOT NULL AND deleted_at IS NULL
|
|
470
|
-
`).all();
|
|
471
376
|
const now = Date.now();
|
|
472
377
|
const result = [];
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const
|
|
378
|
+
const chatDirs = scanChatDirs(this.sessionsDir);
|
|
379
|
+
for (const { dirPath } of chatDirs) {
|
|
380
|
+
const active = readJsonFile(path.join(dirPath, 'active.json'));
|
|
381
|
+
if (!active || !active.activeTask)
|
|
382
|
+
continue;
|
|
383
|
+
const colonIdx = active.activeTask.indexOf(':');
|
|
384
|
+
const ts = parseInt(colonIdx > 0 ? active.activeTask.slice(0, colonIdx) : active.activeTask, 10);
|
|
476
385
|
if (!isNaN(ts) && (now - ts) < maxAgeMs) {
|
|
477
|
-
result.push(
|
|
386
|
+
result.push(fileToSession(active));
|
|
478
387
|
}
|
|
479
388
|
else {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
389
|
+
active.activeTask = null;
|
|
390
|
+
active.updatedAt = now;
|
|
391
|
+
active.updatedAtStr = formatTimestamp(now);
|
|
392
|
+
atomicWriteJson(path.join(dirPath, 'active.json'), active);
|
|
483
393
|
}
|
|
484
394
|
}
|
|
485
395
|
return result;
|
|
486
396
|
}
|
|
487
|
-
|
|
488
|
-
|
|
397
|
+
// ─── Session lifecycle ───
|
|
398
|
+
async getOrCreateSession(channel, channelId, defaultProjectPath, threadId, metadata, name, userId, chatType, agentId, selfId, channelType, peerType) {
|
|
489
399
|
if (threadId) {
|
|
490
|
-
const session = this.getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId);
|
|
400
|
+
const session = this.getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId, selfId, channelType, peerType);
|
|
491
401
|
session.identity = this.resolveIdentity(channel, userId);
|
|
492
|
-
// 新话题会话补写默认权限模式
|
|
493
402
|
if (session.metadata && !session.metadata.permissionMode) {
|
|
494
403
|
session.metadata.permissionMode = DEFAULT_PERMISSION_MODE;
|
|
495
|
-
this.
|
|
496
|
-
.run(JSON.stringify(session.metadata), Date.now(), session.id);
|
|
404
|
+
this.appendMeta(channel, channelId, session);
|
|
497
405
|
}
|
|
498
406
|
return session;
|
|
499
407
|
}
|
|
500
|
-
//
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
if (active) {
|
|
408
|
+
// 使用精确路径解析(caller 提供了 channelType 时直接定位,避免扫描回落)
|
|
409
|
+
const exactDir = this.resolveChatDirExact(channel, channelId, channelType, selfId);
|
|
410
|
+
const activeFile = readJsonFile(path.join(exactDir, 'active.json'));
|
|
411
|
+
const active = activeFile ? fileToSession(activeFile) : undefined;
|
|
412
|
+
if (active && !active.threadId) {
|
|
506
413
|
const validSessionId = this.validateSessionFile(active);
|
|
507
|
-
const session = { ...
|
|
414
|
+
const session = { ...active, agentSessionId: validSessionId };
|
|
508
415
|
session.identity = this.resolveIdentity(channel, userId);
|
|
509
|
-
|
|
416
|
+
let mutated = false;
|
|
510
417
|
if (chatType && session.chatType !== chatType) {
|
|
511
418
|
logger.info(`[SessionManager] Updating chatType for session ${session.id}: ${session.chatType} -> ${chatType}`);
|
|
512
|
-
this.db.prepare(`UPDATE sessions SET chat_type = ?, updated_at = ? WHERE id = ?`)
|
|
513
|
-
.run(chatType, Date.now(), active.id);
|
|
514
419
|
session.chatType = chatType;
|
|
420
|
+
mutated = true;
|
|
421
|
+
}
|
|
422
|
+
if (selfId && session.selfId !== selfId) {
|
|
423
|
+
session.selfId = selfId;
|
|
424
|
+
mutated = true;
|
|
515
425
|
}
|
|
516
|
-
// 补写 peerId/peerName/channelName(旧 session 可能在这些字段引入前创建)
|
|
517
426
|
if (chatType === 'private' && userId) {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (!
|
|
521
|
-
|
|
522
|
-
|
|
427
|
+
if (!session.metadata)
|
|
428
|
+
session.metadata = {};
|
|
429
|
+
if (!session.metadata.peerId) {
|
|
430
|
+
session.metadata.peerId = userId;
|
|
431
|
+
mutated = true;
|
|
523
432
|
}
|
|
524
|
-
if (!
|
|
525
|
-
|
|
526
|
-
|
|
433
|
+
if (!session.metadata.peerName && metadata?.peerName) {
|
|
434
|
+
session.metadata.peerName = metadata.peerName;
|
|
435
|
+
mutated = true;
|
|
527
436
|
}
|
|
528
|
-
if (metadata?.channelName &&
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
}
|
|
532
|
-
if (updated) {
|
|
533
|
-
this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
|
|
534
|
-
.run(JSON.stringify(activeMeta), Date.now(), active.id);
|
|
535
|
-
session.metadata = activeMeta;
|
|
437
|
+
if (metadata?.channelName && session.metadata.channelName !== metadata.channelName) {
|
|
438
|
+
session.metadata.channelName = metadata.channelName;
|
|
439
|
+
mutated = true;
|
|
536
440
|
}
|
|
537
441
|
}
|
|
538
|
-
// 补写 channelName(非私聊时也需要)
|
|
539
442
|
if (metadata?.channelName && chatType !== 'private') {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
session.metadata = activeMeta;
|
|
443
|
+
if (!session.metadata)
|
|
444
|
+
session.metadata = {};
|
|
445
|
+
if (session.metadata.channelName !== metadata.channelName) {
|
|
446
|
+
session.metadata.channelName = metadata.channelName;
|
|
447
|
+
mutated = true;
|
|
546
448
|
}
|
|
547
449
|
}
|
|
450
|
+
if (mutated) {
|
|
451
|
+
session.updatedAt = Date.now();
|
|
452
|
+
this.appendMeta(channel, channelId, session);
|
|
453
|
+
this.writeActive(channel, channelId, session);
|
|
454
|
+
}
|
|
548
455
|
return session;
|
|
549
456
|
}
|
|
550
|
-
//
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
457
|
+
// Find existing session for default project path
|
|
458
|
+
const chatDir = this.resolveChatDir(channel, channelId);
|
|
459
|
+
const allSessions = this.findAllSessionsInChat(chatDir, false);
|
|
460
|
+
const existing = allSessions
|
|
461
|
+
.filter(s => s.projectPath === defaultProjectPath && !s.threadId)
|
|
462
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
|
556
463
|
if (existing) {
|
|
557
464
|
const validSessionId = this.validateSessionFile(existing);
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
if (
|
|
564
|
-
|
|
565
|
-
existing.chat_type = chatType;
|
|
566
|
-
}
|
|
567
|
-
// 补写 peerId/peerName
|
|
568
|
-
if (chatType === 'private' && userId && !existingMeta.peerId) {
|
|
569
|
-
existingMeta.peerId = userId;
|
|
465
|
+
const prev = JSON.parse(JSON.stringify({ ...existing, agentSessionId: validSessionId }));
|
|
466
|
+
const session = { ...existing, agentSessionId: validSessionId };
|
|
467
|
+
session.identity = this.resolveIdentity(channel, userId);
|
|
468
|
+
if (!session.metadata)
|
|
469
|
+
session.metadata = {};
|
|
470
|
+
if (selfId && session.selfId !== selfId) {
|
|
471
|
+
session.selfId = selfId;
|
|
570
472
|
}
|
|
571
|
-
if (chatType
|
|
572
|
-
|
|
473
|
+
if (chatType && session.chatType !== chatType) {
|
|
474
|
+
logger.info(`[SessionManager] Updating chatType for session ${session.id}: ${session.chatType} -> ${chatType}`);
|
|
475
|
+
session.chatType = chatType;
|
|
573
476
|
}
|
|
574
|
-
if (
|
|
575
|
-
|
|
576
|
-
.run(JSON.stringify(existingMeta), Date.now(), chatType, existing.id);
|
|
477
|
+
if (chatType === 'private' && userId && !session.metadata.peerId) {
|
|
478
|
+
session.metadata.peerId = userId;
|
|
577
479
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
.run(JSON.stringify(existingMeta), Date.now(), existing.id);
|
|
480
|
+
if (chatType === 'private' && metadata?.peerName && !session.metadata.peerName) {
|
|
481
|
+
session.metadata.peerName = metadata.peerName;
|
|
581
482
|
}
|
|
582
|
-
|
|
583
|
-
session.identity = this.resolveIdentity(channel, userId);
|
|
483
|
+
this.writeSessionIfChanged(channel, channelId, prev, session);
|
|
584
484
|
return session;
|
|
585
485
|
}
|
|
586
|
-
//
|
|
587
|
-
const sessionMetadata = { ...metadata
|
|
486
|
+
// Create new session
|
|
487
|
+
const sessionMetadata = { ...(metadata || {}) };
|
|
488
|
+
if (!sessionMetadata.permissionMode)
|
|
489
|
+
sessionMetadata.permissionMode = DEFAULT_PERMISSION_MODE;
|
|
588
490
|
const session = {
|
|
589
|
-
id:
|
|
491
|
+
id: generateSessionId(),
|
|
590
492
|
channel,
|
|
493
|
+
channelType: channelType || channel,
|
|
591
494
|
channelId,
|
|
495
|
+
selfId,
|
|
592
496
|
projectPath: defaultProjectPath,
|
|
593
497
|
threadId: '',
|
|
594
498
|
agentId: agentId || 'claude',
|
|
595
499
|
chatType: chatType || 'private',
|
|
596
|
-
sessionMode: this.resolveDefaultSessionMode(channel, chatType || 'private'),
|
|
500
|
+
sessionMode: this.resolveDefaultSessionMode(channel, chatType || 'private', peerType),
|
|
597
501
|
metadata: sessionMetadata,
|
|
598
502
|
name: name || '默认会话',
|
|
599
503
|
createdAt: Date.now(),
|
|
600
|
-
updatedAt: Date.now()
|
|
504
|
+
updatedAt: Date.now(),
|
|
601
505
|
};
|
|
602
506
|
session.identity = this.resolveIdentity(channel, userId);
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
sessionMetadata.permissionMode = DEFAULT_PERMISSION_MODE;
|
|
606
|
-
}
|
|
607
|
-
this.insertSession(session);
|
|
507
|
+
this.appendMeta(channel, channelId, session);
|
|
508
|
+
this.writeActive(channel, channelId, session);
|
|
608
509
|
this.eventBus.publish({
|
|
609
510
|
type: 'session:created',
|
|
610
511
|
sessionId: session.id,
|
|
@@ -613,81 +514,67 @@ export class SessionManager {
|
|
|
613
514
|
projectPath: defaultProjectPath,
|
|
614
515
|
name: session.name,
|
|
615
516
|
chatType: session.chatType,
|
|
616
|
-
timestamp: Date.now()
|
|
517
|
+
timestamp: Date.now(),
|
|
617
518
|
});
|
|
618
519
|
return session;
|
|
619
520
|
}
|
|
620
521
|
async updateSession(sessionId, updates) {
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
if (updates.chatType !== undefined) {
|
|
624
|
-
sets.push('chat_type = ?');
|
|
625
|
-
values.push(updates.chatType);
|
|
626
|
-
}
|
|
627
|
-
if (updates.name !== undefined) {
|
|
628
|
-
sets.push('name = ?');
|
|
629
|
-
values.push(updates.name);
|
|
630
|
-
}
|
|
631
|
-
if (updates.sessionMode !== undefined) {
|
|
632
|
-
sets.push('session_mode = ?');
|
|
633
|
-
values.push(updates.sessionMode);
|
|
634
|
-
}
|
|
635
|
-
if (updates.metadata !== undefined) {
|
|
636
|
-
sets.push('metadata = ?');
|
|
637
|
-
values.push(updates.metadata ? JSON.stringify(updates.metadata) : null);
|
|
638
|
-
}
|
|
639
|
-
if ('agentSessionId' in updates) {
|
|
640
|
-
sets.push('claude_session_id = ?');
|
|
641
|
-
values.push(updates.agentSessionId ?? null);
|
|
642
|
-
}
|
|
643
|
-
if (sets.length === 0)
|
|
522
|
+
const loaded = this.loadSessionForUpdate(sessionId);
|
|
523
|
+
if (!loaded)
|
|
644
524
|
return;
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
525
|
+
const { current, prev } = loaded;
|
|
526
|
+
if (updates.chatType !== undefined)
|
|
527
|
+
current.chatType = updates.chatType;
|
|
528
|
+
if (updates.name !== undefined)
|
|
529
|
+
current.name = updates.name;
|
|
530
|
+
if (updates.sessionMode !== undefined)
|
|
531
|
+
current.sessionMode = updates.sessionMode;
|
|
532
|
+
if (updates.metadata !== undefined)
|
|
533
|
+
current.metadata = updates.metadata;
|
|
534
|
+
if ('agentSessionId' in updates)
|
|
535
|
+
current.agentSessionId = updates.agentSessionId ?? undefined;
|
|
536
|
+
this.writeSessionIfChanged(current.channel, current.channelId, prev, current);
|
|
537
|
+
}
|
|
538
|
+
getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId, selfId, channelType, peerType) {
|
|
539
|
+
const chatDir = this.ensureResolvedChatDir(channel, channelId);
|
|
540
|
+
const threadIndex = readThreadIndex(chatDir);
|
|
541
|
+
const existingMetaId = threadIndex[threadId];
|
|
542
|
+
if (existingMetaId) {
|
|
543
|
+
const metaPath = path.join(chatDir, '_threads', `${existingMetaId}.jsonl`);
|
|
544
|
+
const existing = this.readMetaLatest(metaPath);
|
|
545
|
+
if (existing) {
|
|
546
|
+
const validSessionId = this.validateSessionFile(existing);
|
|
547
|
+
if (metadata) {
|
|
548
|
+
existing.metadata = { ...(existing.metadata || {}), ...metadata };
|
|
549
|
+
existing.updatedAt = Date.now();
|
|
550
|
+
this.appendMeta(channel, channelId, existing);
|
|
551
|
+
}
|
|
552
|
+
return { ...existing, agentSessionId: validSessionId };
|
|
665
553
|
}
|
|
666
|
-
return { ...this.rowToSession(existing), agentSessionId: validSessionId };
|
|
667
554
|
}
|
|
668
|
-
//
|
|
669
|
-
const activeMain = this.
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
`).get(channel, channelId);
|
|
673
|
-
const projectPath = activeMain?.project_path || defaultProjectPath;
|
|
674
|
-
// 创建新话题会话
|
|
675
|
-
const inheritedChatType = activeMain?.chat_type || 'private';
|
|
555
|
+
// Inherit project path & chatType from active main session
|
|
556
|
+
const activeMain = this.readActive(channel, channelId);
|
|
557
|
+
const projectPath = (activeMain && !activeMain.threadId ? activeMain.projectPath : undefined) || defaultProjectPath;
|
|
558
|
+
const inheritedChatType = (activeMain && !activeMain.threadId ? activeMain.chatType : undefined) || 'private';
|
|
676
559
|
const session = {
|
|
677
|
-
id:
|
|
560
|
+
id: generateSessionId(),
|
|
678
561
|
channel,
|
|
562
|
+
channelType: channelType || channel,
|
|
679
563
|
channelId,
|
|
564
|
+
selfId,
|
|
680
565
|
projectPath,
|
|
681
566
|
threadId,
|
|
682
567
|
agentId: agentId || 'claude',
|
|
683
568
|
chatType: inheritedChatType,
|
|
684
|
-
sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
|
|
569
|
+
sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType, peerType),
|
|
685
570
|
metadata,
|
|
686
571
|
name: name || '话题会话',
|
|
687
572
|
createdAt: Date.now(),
|
|
688
|
-
updatedAt: Date.now()
|
|
573
|
+
updatedAt: Date.now(),
|
|
689
574
|
};
|
|
690
|
-
this.
|
|
575
|
+
this.appendMeta(channel, channelId, session);
|
|
576
|
+
threadIndex[threadId] = session.id;
|
|
577
|
+
writeThreadIndex(chatDir, threadIndex);
|
|
691
578
|
this.eventBus.publish({
|
|
692
579
|
type: 'session:created',
|
|
693
580
|
sessionId: session.id,
|
|
@@ -695,48 +582,45 @@ export class SessionManager {
|
|
|
695
582
|
channelId,
|
|
696
583
|
projectPath,
|
|
697
584
|
name: session.name,
|
|
698
|
-
timestamp: Date.now()
|
|
585
|
+
timestamp: Date.now(),
|
|
699
586
|
});
|
|
700
587
|
return session;
|
|
701
588
|
}
|
|
702
589
|
async switchProject(channel, channelId, newProjectPath, currentAgentId) {
|
|
703
590
|
const agentId = currentAgentId || 'claude';
|
|
704
591
|
logger.info(`[SessionManager] switchProject: channel=${channel} channelId=${channelId} newPath=${newProjectPath} agent=${agentId}`);
|
|
705
|
-
// 1. 继承当前 chatType(在 deactivate 之前读取)
|
|
706
592
|
const inheritedChatType = this.getActiveChatType(channel, channelId);
|
|
707
|
-
|
|
708
|
-
this.
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
WHERE channel = ? AND channel_id = ? AND project_path = ? AND agent_id = ? AND thread_id = '' AND deleted_at IS NULL
|
|
713
|
-
ORDER BY updated_at DESC LIMIT 1
|
|
714
|
-
`).get(channel, channelId, newProjectPath, agentId);
|
|
593
|
+
const chatDir = this.ensureResolvedChatDir(channel, channelId);
|
|
594
|
+
const allSessions = this.findAllSessionsInChat(chatDir, false);
|
|
595
|
+
const target = allSessions
|
|
596
|
+
.filter(s => s.projectPath === newProjectPath && (s.agentId || 'claude') === agentId && !s.threadId)
|
|
597
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
|
715
598
|
if (target) {
|
|
716
599
|
const validSessionId = this.validateSessionFile(target);
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
this.
|
|
721
|
-
|
|
722
|
-
return { ...this.rowToSession(target), agentSessionId: validSessionId, metadata };
|
|
600
|
+
target.agentSessionId = validSessionId;
|
|
601
|
+
target.updatedAt = Date.now();
|
|
602
|
+
this.appendMeta(channel, channelId, target);
|
|
603
|
+
this.writeActive(channel, channelId, target);
|
|
604
|
+
return target;
|
|
723
605
|
}
|
|
724
|
-
// 4. 创建新会话
|
|
725
606
|
const session = {
|
|
726
|
-
id:
|
|
607
|
+
id: generateSessionId(),
|
|
727
608
|
channel,
|
|
609
|
+
channelType: this.inferChannelType(channel, channelId),
|
|
728
610
|
channelId,
|
|
611
|
+
selfId: this.inferSelfId(channel, channelId),
|
|
729
612
|
projectPath: newProjectPath,
|
|
730
613
|
threadId: '',
|
|
731
614
|
agentId,
|
|
732
615
|
chatType: inheritedChatType,
|
|
733
616
|
sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
|
|
734
|
-
metadata: {
|
|
617
|
+
metadata: {},
|
|
735
618
|
name: '默认会话',
|
|
736
619
|
createdAt: Date.now(),
|
|
737
|
-
updatedAt: Date.now()
|
|
620
|
+
updatedAt: Date.now(),
|
|
738
621
|
};
|
|
739
|
-
this.
|
|
622
|
+
this.appendMeta(channel, channelId, session);
|
|
623
|
+
this.writeActive(channel, channelId, session);
|
|
740
624
|
this.eventBus.publish({
|
|
741
625
|
type: 'session:created',
|
|
742
626
|
sessionId: session.id,
|
|
@@ -744,63 +628,62 @@ export class SessionManager {
|
|
|
744
628
|
channelId,
|
|
745
629
|
projectPath: newProjectPath,
|
|
746
630
|
name: session.name,
|
|
747
|
-
timestamp: Date.now()
|
|
631
|
+
timestamp: Date.now(),
|
|
748
632
|
});
|
|
749
633
|
return session;
|
|
750
634
|
}
|
|
751
635
|
async updateAgentSessionId(channel, channelId, agentSessionId) {
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
636
|
+
const active = this.readActive(channel, channelId);
|
|
637
|
+
if (!active)
|
|
638
|
+
return;
|
|
639
|
+
const prev = JSON.parse(JSON.stringify(active));
|
|
640
|
+
active.agentSessionId = agentSessionId;
|
|
641
|
+
this.writeSessionIfChanged(channel, channelId, prev, active);
|
|
758
642
|
}
|
|
759
643
|
async updateAgentSessionIdBySessionId(sessionId, agentSessionId) {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
644
|
+
const loaded = this.loadSessionForUpdate(sessionId);
|
|
645
|
+
if (!loaded)
|
|
646
|
+
return;
|
|
647
|
+
const { current, prev } = loaded;
|
|
648
|
+
current.agentSessionId = agentSessionId;
|
|
649
|
+
const wrote = this.writeSessionIfChanged(current.channel, current.channelId, prev, current);
|
|
650
|
+
if (wrote) {
|
|
651
|
+
logger.info(`[SessionManager] Updating agent_session_id: sessionId=${sessionId}, agentSessionId=${agentSessionId}`);
|
|
652
|
+
}
|
|
767
653
|
}
|
|
768
654
|
async switchAgent(channel, channelId, projectPath, newAgentId) {
|
|
769
|
-
// 1. 继承当前 chatType(在 deactivate 之前读取)
|
|
770
655
|
const inheritedChatType = this.getActiveChatType(channel, channelId);
|
|
771
|
-
|
|
772
|
-
this.
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
WHERE channel = ? AND channel_id = ? AND project_path = ? AND agent_id = ? AND thread_id = '' AND deleted_at IS NULL
|
|
777
|
-
ORDER BY updated_at DESC LIMIT 1
|
|
778
|
-
`).get(channel, channelId, projectPath, newAgentId);
|
|
656
|
+
const chatDir = this.ensureResolvedChatDir(channel, channelId);
|
|
657
|
+
const allSessions = this.findAllSessionsInChat(chatDir, false);
|
|
658
|
+
const target = allSessions
|
|
659
|
+
.filter(s => s.projectPath === projectPath && (s.agentId || 'claude') === newAgentId && !s.threadId)
|
|
660
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
|
779
661
|
if (target) {
|
|
780
662
|
const validSessionId = this.validateSessionFile(target);
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
this.
|
|
785
|
-
|
|
786
|
-
return { ...this.rowToSession(target), agentSessionId: validSessionId, metadata };
|
|
663
|
+
target.agentSessionId = validSessionId;
|
|
664
|
+
target.updatedAt = Date.now();
|
|
665
|
+
this.appendMeta(channel, channelId, target);
|
|
666
|
+
this.writeActive(channel, channelId, target);
|
|
667
|
+
return target;
|
|
787
668
|
}
|
|
788
|
-
// 4. 创建新会话(与 switchProject 保持一致)
|
|
789
669
|
const session = {
|
|
790
|
-
id:
|
|
670
|
+
id: generateSessionId(),
|
|
791
671
|
channel,
|
|
672
|
+
channelType: this.inferChannelType(channel, channelId),
|
|
792
673
|
channelId,
|
|
674
|
+
selfId: this.inferSelfId(channel, channelId),
|
|
793
675
|
projectPath,
|
|
794
676
|
threadId: '',
|
|
795
677
|
agentId: newAgentId,
|
|
796
678
|
chatType: inheritedChatType,
|
|
797
679
|
sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
|
|
798
|
-
metadata: {
|
|
680
|
+
metadata: {},
|
|
799
681
|
name: '默认会话',
|
|
800
682
|
createdAt: Date.now(),
|
|
801
|
-
updatedAt: Date.now()
|
|
683
|
+
updatedAt: Date.now(),
|
|
802
684
|
};
|
|
803
|
-
this.
|
|
685
|
+
this.appendMeta(channel, channelId, session);
|
|
686
|
+
this.writeActive(channel, channelId, session);
|
|
804
687
|
this.eventBus.publish({
|
|
805
688
|
type: 'session:created',
|
|
806
689
|
sessionId: session.id,
|
|
@@ -808,144 +691,229 @@ export class SessionManager {
|
|
|
808
691
|
channelId,
|
|
809
692
|
projectPath,
|
|
810
693
|
name: session.name,
|
|
811
|
-
timestamp: Date.now()
|
|
694
|
+
timestamp: Date.now(),
|
|
812
695
|
});
|
|
813
696
|
return session;
|
|
814
697
|
}
|
|
815
698
|
async clearActiveSession(channel, channelId) {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
}
|
|
823
|
-
/** 查找 owner 在目标通道的私聊 channelId(用于跨通道文件投递) */
|
|
699
|
+
const active = this.readActive(channel, channelId);
|
|
700
|
+
if (!active)
|
|
701
|
+
return;
|
|
702
|
+
const prev = JSON.parse(JSON.stringify(active));
|
|
703
|
+
active.agentSessionId = undefined;
|
|
704
|
+
this.writeSessionIfChanged(channel, channelId, prev, active);
|
|
705
|
+
}
|
|
824
706
|
getOwnerChatId(targetChannel, ownerPeerId) {
|
|
825
|
-
const
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
707
|
+
const chatDirs = scanChatDirs(this.sessionsDir);
|
|
708
|
+
let bestMatch;
|
|
709
|
+
for (const { channelId, dirPath } of chatDirs) {
|
|
710
|
+
// Check active.json first
|
|
711
|
+
const active = readJsonFile(path.join(dirPath, 'active.json'));
|
|
712
|
+
// 按 channel 实例名匹配(active.json 里有;缺失就跳过这个 chat)
|
|
713
|
+
if (!active || active.channel !== targetChannel)
|
|
714
|
+
continue;
|
|
715
|
+
const candidates = [active];
|
|
716
|
+
// Also scan meta files
|
|
717
|
+
for (const metaFile of scanMetaFiles(dirPath)) {
|
|
718
|
+
const file = readLastJsonlLine(path.join(dirPath, metaFile));
|
|
719
|
+
if (file)
|
|
720
|
+
candidates.push(file);
|
|
721
|
+
}
|
|
722
|
+
for (const cand of candidates) {
|
|
723
|
+
if (cand.chatType !== 'private')
|
|
724
|
+
continue;
|
|
725
|
+
if (cand.metadata?.peerId !== ownerPeerId)
|
|
726
|
+
continue;
|
|
727
|
+
if (!bestMatch || cand.updatedAt > bestMatch.updatedAt) {
|
|
728
|
+
bestMatch = { channelId, updatedAt: cand.updatedAt };
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return bestMatch?.channelId;
|
|
833
733
|
}
|
|
834
734
|
async getSessionById(sessionId) {
|
|
835
|
-
const
|
|
836
|
-
if (!
|
|
735
|
+
const found = this.findSessionFileById(sessionId);
|
|
736
|
+
if (!found)
|
|
837
737
|
return undefined;
|
|
838
|
-
return this.
|
|
738
|
+
return this.readMetaLatest(found.metaPath);
|
|
839
739
|
}
|
|
840
740
|
async getActiveSession(channel, channelId) {
|
|
841
|
-
|
|
842
|
-
SELECT * FROM sessions
|
|
843
|
-
WHERE channel = ? AND channel_id = ? AND json_extract(metadata, '$.isActive') = true AND deleted_at IS NULL
|
|
844
|
-
`).get(channel, channelId);
|
|
845
|
-
if (!row)
|
|
846
|
-
return undefined;
|
|
847
|
-
return this.rowToSession(row);
|
|
741
|
+
return this.readActive(channel, channelId);
|
|
848
742
|
}
|
|
849
|
-
/**
|
|
850
|
-
* 查询话题会话(不创建)
|
|
851
|
-
*/
|
|
852
743
|
async getThreadSession(channel, channelId, threadId) {
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
744
|
+
const chatDir = this.resolveChatDir(channel, channelId);
|
|
745
|
+
const threadIndex = readThreadIndex(chatDir);
|
|
746
|
+
const metaId = threadIndex[threadId];
|
|
747
|
+
if (!metaId)
|
|
748
|
+
return undefined;
|
|
749
|
+
const metaPath = path.join(chatDir, '_threads', `${metaId}.jsonl`);
|
|
750
|
+
const session = this.readMetaLatest(metaPath);
|
|
751
|
+
if (!session)
|
|
858
752
|
return undefined;
|
|
859
|
-
const validSessionId = this.validateSessionFile(
|
|
860
|
-
return { ...
|
|
753
|
+
const validSessionId = this.validateSessionFile(session);
|
|
754
|
+
return { ...session, agentSessionId: validSessionId };
|
|
861
755
|
}
|
|
862
756
|
async listSessions(channel, channelId) {
|
|
863
|
-
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
WHERE channel = ? AND channel_id = ? AND deleted_at IS NULL
|
|
867
|
-
ORDER BY updated_at DESC
|
|
868
|
-
`).all(channel, channelId);
|
|
869
|
-
return rows.map(row => this.rowToSession(row));
|
|
757
|
+
const chatDir = this.resolveChatDir(channel, channelId);
|
|
758
|
+
const sessions = this.findAllSessionsInChat(chatDir, true);
|
|
759
|
+
return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
870
760
|
}
|
|
871
761
|
async getSessionByProjectPath(channel, channelId, projectPath) {
|
|
872
|
-
const
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
LIMIT 1
|
|
877
|
-
`).get(channel, channelId, projectPath);
|
|
878
|
-
if (!row)
|
|
762
|
+
const chatDir = this.resolveChatDir(channel, channelId);
|
|
763
|
+
const sessions = this.findAllSessionsInChat(chatDir, false);
|
|
764
|
+
const matched = sessions.filter(s => s.projectPath === projectPath);
|
|
765
|
+
if (matched.length === 0)
|
|
879
766
|
return undefined;
|
|
880
|
-
|
|
767
|
+
matched.sort((a, b) => {
|
|
768
|
+
const aProc = a.processingState ? 1 : 0;
|
|
769
|
+
const bProc = b.processingState ? 1 : 0;
|
|
770
|
+
if (aProc !== bProc)
|
|
771
|
+
return bProc - aProc;
|
|
772
|
+
return b.updatedAt - a.updatedAt;
|
|
773
|
+
});
|
|
774
|
+
return matched[0];
|
|
881
775
|
}
|
|
882
776
|
async getSessionByName(channel, channelId, name) {
|
|
883
|
-
const
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
`).get(channel, channelId, name);
|
|
887
|
-
if (!row)
|
|
888
|
-
return undefined;
|
|
889
|
-
return this.rowToSession(row);
|
|
777
|
+
const chatDir = this.resolveChatDir(channel, channelId);
|
|
778
|
+
const sessions = this.findAllSessionsInChat(chatDir, true);
|
|
779
|
+
return sessions.find(s => s.name === name);
|
|
890
780
|
}
|
|
891
781
|
async switchToSession(channel, channelId, targetSessionId) {
|
|
892
|
-
|
|
893
|
-
const
|
|
894
|
-
|
|
895
|
-
`).get(targetSessionId, channel, channelId);
|
|
782
|
+
const chatDir = this.resolveChatDir(channel, channelId);
|
|
783
|
+
const sessions = this.findAllSessionsInChat(chatDir, true);
|
|
784
|
+
const target = sessions.find(s => s.id === targetSessionId);
|
|
896
785
|
if (!target)
|
|
897
786
|
return null;
|
|
898
|
-
|
|
899
|
-
this.
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
metadata.isActive = true;
|
|
903
|
-
this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
|
|
904
|
-
.run(JSON.stringify(metadata), Date.now(), targetSessionId);
|
|
905
|
-
return { ...this.rowToSession(target), metadata, updatedAt: Date.now() };
|
|
787
|
+
target.updatedAt = Date.now();
|
|
788
|
+
this.appendMeta(channel, channelId, target);
|
|
789
|
+
this.writeActive(channel, channelId, target);
|
|
790
|
+
return target;
|
|
906
791
|
}
|
|
907
792
|
updateMetadata(sessionId, metadata) {
|
|
908
|
-
|
|
909
|
-
|
|
793
|
+
const loaded = this.loadSessionForUpdate(sessionId);
|
|
794
|
+
if (!loaded)
|
|
795
|
+
return;
|
|
796
|
+
const { current, prev } = loaded;
|
|
797
|
+
current.metadata = metadata;
|
|
798
|
+
this.writeSessionIfChanged(current.channel, current.channelId, prev, current);
|
|
910
799
|
}
|
|
911
800
|
async renameSession(sessionId, newName) {
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
801
|
+
const loaded = this.loadSessionForUpdate(sessionId);
|
|
802
|
+
if (!loaded)
|
|
803
|
+
return false;
|
|
804
|
+
const { current, prev } = loaded;
|
|
805
|
+
current.name = newName;
|
|
806
|
+
this.writeSessionIfChanged(current.channel, current.channelId, prev, current);
|
|
807
|
+
return true;
|
|
916
808
|
}
|
|
917
809
|
async unbindSession(sessionId) {
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
810
|
+
const found = this.findSessionFileById(sessionId);
|
|
811
|
+
if (!found)
|
|
812
|
+
return false;
|
|
813
|
+
const trashDir = path.join(found.chatDir, '_trash');
|
|
814
|
+
fs.mkdirSync(trashDir, { recursive: true });
|
|
815
|
+
const trashPath = path.join(trashDir, path.basename(found.metaPath));
|
|
816
|
+
try {
|
|
817
|
+
fs.renameSync(found.metaPath, trashPath);
|
|
818
|
+
}
|
|
819
|
+
catch (e) {
|
|
820
|
+
if (e.code !== 'ENOENT')
|
|
821
|
+
throw e;
|
|
822
|
+
}
|
|
823
|
+
// If thread session, remove from thread-index
|
|
824
|
+
if (found.isThread) {
|
|
825
|
+
const threadIndex = readThreadIndex(found.chatDir);
|
|
826
|
+
for (const [tid, mid] of Object.entries(threadIndex)) {
|
|
827
|
+
if (mid === sessionId) {
|
|
828
|
+
delete threadIndex[tid];
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
writeThreadIndex(found.chatDir, threadIndex);
|
|
833
|
+
}
|
|
834
|
+
// Clear active.json if it pointed to this session
|
|
835
|
+
const activePath = path.join(found.chatDir, 'active.json');
|
|
836
|
+
const active = readJsonFile(activePath);
|
|
837
|
+
if (active && active.id === sessionId) {
|
|
838
|
+
try {
|
|
839
|
+
fs.unlinkSync(activePath);
|
|
840
|
+
}
|
|
841
|
+
catch (e) {
|
|
842
|
+
if (e.code !== 'ENOENT')
|
|
843
|
+
throw e;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return true;
|
|
922
847
|
}
|
|
923
848
|
async softDeleteSession(channelId) {
|
|
924
|
-
this.
|
|
925
|
-
|
|
926
|
-
|
|
849
|
+
const chatDirs = scanChatDirs(this.sessionsDir);
|
|
850
|
+
for (const { channelId: cid, dirPath } of chatDirs) {
|
|
851
|
+
if (cid !== channelId)
|
|
852
|
+
continue;
|
|
853
|
+
const trashDir = path.join(dirPath, '_trash');
|
|
854
|
+
fs.mkdirSync(trashDir, { recursive: true });
|
|
855
|
+
for (const metaFile of scanMetaFiles(dirPath)) {
|
|
856
|
+
const src = path.join(dirPath, metaFile);
|
|
857
|
+
const dst = path.join(trashDir, metaFile);
|
|
858
|
+
try {
|
|
859
|
+
fs.renameSync(src, dst);
|
|
860
|
+
}
|
|
861
|
+
catch (e) {
|
|
862
|
+
if (e.code !== 'ENOENT')
|
|
863
|
+
throw e;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
const threadsDir = path.join(dirPath, '_threads');
|
|
867
|
+
for (const metaFile of scanMetaFiles(threadsDir)) {
|
|
868
|
+
const src = path.join(threadsDir, metaFile);
|
|
869
|
+
const dst = path.join(trashDir, metaFile);
|
|
870
|
+
try {
|
|
871
|
+
fs.renameSync(src, dst);
|
|
872
|
+
}
|
|
873
|
+
catch (e) {
|
|
874
|
+
if (e.code !== 'ENOENT')
|
|
875
|
+
throw e;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
// Clear active.json
|
|
879
|
+
const activePath = path.join(dirPath, 'active.json');
|
|
880
|
+
try {
|
|
881
|
+
fs.unlinkSync(activePath);
|
|
882
|
+
}
|
|
883
|
+
catch (e) {
|
|
884
|
+
if (e.code !== 'ENOENT')
|
|
885
|
+
throw e;
|
|
886
|
+
}
|
|
887
|
+
// Clear thread index
|
|
888
|
+
try {
|
|
889
|
+
fs.unlinkSync(path.join(threadsDir, 'thread-index.json'));
|
|
890
|
+
}
|
|
891
|
+
catch (e) {
|
|
892
|
+
if (e.code !== 'ENOENT')
|
|
893
|
+
throw e;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
927
896
|
}
|
|
928
897
|
async createNewSession(channel, channelId, projectPath, name, agentId) {
|
|
929
|
-
// 继承当前 chatType(在 deactivate 之前读取)
|
|
930
898
|
const inheritedChatType = this.getActiveChatType(channel, channelId);
|
|
931
|
-
// 取消当前活跃会话
|
|
932
|
-
this.deactivateAllMetadata(channel, channelId);
|
|
933
|
-
// 创建新会话
|
|
934
899
|
const session = {
|
|
935
|
-
id:
|
|
900
|
+
id: generateSessionId(),
|
|
936
901
|
channel,
|
|
902
|
+
channelType: this.inferChannelType(channel, channelId),
|
|
937
903
|
channelId,
|
|
904
|
+
selfId: this.inferSelfId(channel, channelId),
|
|
938
905
|
projectPath,
|
|
939
906
|
threadId: '',
|
|
940
907
|
agentId: agentId || 'claude',
|
|
941
908
|
chatType: inheritedChatType,
|
|
942
909
|
sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
|
|
943
|
-
metadata: {
|
|
910
|
+
metadata: {},
|
|
944
911
|
name: name || '默认会话',
|
|
945
912
|
createdAt: Date.now(),
|
|
946
|
-
updatedAt: Date.now()
|
|
913
|
+
updatedAt: Date.now(),
|
|
947
914
|
};
|
|
948
|
-
this.
|
|
915
|
+
this.appendMeta(channel, channelId, session);
|
|
916
|
+
this.writeActive(channel, channelId, session);
|
|
949
917
|
this.eventBus.publish({
|
|
950
918
|
type: 'session:created',
|
|
951
919
|
sessionId: session.id,
|
|
@@ -953,32 +921,30 @@ export class SessionManager {
|
|
|
953
921
|
channelId,
|
|
954
922
|
projectPath,
|
|
955
923
|
name: session.name,
|
|
956
|
-
timestamp: Date.now()
|
|
924
|
+
timestamp: Date.now(),
|
|
957
925
|
});
|
|
958
926
|
return session;
|
|
959
927
|
}
|
|
960
|
-
/**
|
|
961
|
-
* 基于现有会话创建分支会话
|
|
962
|
-
*/
|
|
963
928
|
async createForkedSession(sourceSession, forkedAgentSessionId, name) {
|
|
964
|
-
// 取消当前活跃会话
|
|
965
|
-
this.deactivateAllMetadata(sourceSession.channel, sourceSession.channelId);
|
|
966
929
|
const session = {
|
|
967
|
-
id:
|
|
930
|
+
id: generateSessionId(),
|
|
968
931
|
channel: sourceSession.channel,
|
|
932
|
+
channelType: sourceSession.channelType || sourceSession.channel,
|
|
969
933
|
channelId: sourceSession.channelId,
|
|
934
|
+
selfId: sourceSession.selfId,
|
|
970
935
|
projectPath: sourceSession.projectPath,
|
|
971
936
|
threadId: sourceSession.threadId || '',
|
|
972
937
|
agentId: sourceSession.agentId || 'claude',
|
|
973
938
|
chatType: sourceSession.chatType || 'private',
|
|
974
939
|
sessionMode: sourceSession.sessionMode || 'interactive',
|
|
975
940
|
agentSessionId: forkedAgentSessionId,
|
|
976
|
-
metadata: {
|
|
941
|
+
metadata: {},
|
|
977
942
|
name: name || `${sourceSession.name || '会话'}-分支`,
|
|
978
943
|
createdAt: Date.now(),
|
|
979
|
-
updatedAt: Date.now()
|
|
944
|
+
updatedAt: Date.now(),
|
|
980
945
|
};
|
|
981
|
-
this.
|
|
946
|
+
this.appendMeta(sourceSession.channel, sourceSession.channelId, session);
|
|
947
|
+
this.writeActive(sourceSession.channel, sourceSession.channelId, session);
|
|
982
948
|
this.eventBus.publish({
|
|
983
949
|
type: 'session:created',
|
|
984
950
|
sessionId: session.id,
|
|
@@ -986,7 +952,7 @@ export class SessionManager {
|
|
|
986
952
|
channelId: sourceSession.channelId,
|
|
987
953
|
projectPath: sourceSession.projectPath,
|
|
988
954
|
name: session.name,
|
|
989
|
-
timestamp: Date.now()
|
|
955
|
+
timestamp: Date.now(),
|
|
990
956
|
});
|
|
991
957
|
return session;
|
|
992
958
|
}
|
|
@@ -1014,18 +980,12 @@ export class SessionManager {
|
|
|
1014
980
|
return null;
|
|
1015
981
|
return adapter.readLastUserMessage(projectPath, agentSessionId);
|
|
1016
982
|
}
|
|
1017
|
-
/**
|
|
1018
|
-
* 获取会话文件信息(回合数 + 标题)
|
|
1019
|
-
*/
|
|
1020
983
|
getSessionFileInfo(projectPath, agentSessionId, agentId) {
|
|
1021
984
|
const adapter = this.getFileAdapter(agentId);
|
|
1022
985
|
if (!adapter)
|
|
1023
986
|
return { turns: 0 };
|
|
1024
987
|
return adapter.getFileInfo(projectPath, agentSessionId);
|
|
1025
988
|
}
|
|
1026
|
-
/**
|
|
1027
|
-
* 列出 SDK 侧的会话列表(用于名称同步)
|
|
1028
|
-
*/
|
|
1029
989
|
async listSdkSessions(projectPath, agentId) {
|
|
1030
990
|
const adapter = this.getFileAdapter(agentId);
|
|
1031
991
|
if (!adapter?.listSdkSessions)
|
|
@@ -1033,42 +993,39 @@ export class SessionManager {
|
|
|
1033
993
|
return adapter.listSdkSessions(projectPath);
|
|
1034
994
|
}
|
|
1035
995
|
async getSessionByUuidPrefix(channel, channelId, uuidPrefix) {
|
|
1036
|
-
const
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
if (rows.length === 0)
|
|
996
|
+
const chatDir = this.resolveChatDir(channel, channelId);
|
|
997
|
+
const sessions = this.findAllSessionsInChat(chatDir, true);
|
|
998
|
+
const matched = sessions.filter(s => s.agentSessionId && s.agentSessionId.startsWith(uuidPrefix));
|
|
999
|
+
if (matched.length === 0)
|
|
1041
1000
|
return undefined;
|
|
1042
|
-
if (
|
|
1001
|
+
if (matched.length > 1) {
|
|
1043
1002
|
logger.warn(`Multiple sessions found with UUID prefix: ${uuidPrefix}`);
|
|
1044
1003
|
}
|
|
1045
|
-
return
|
|
1004
|
+
return matched[0];
|
|
1046
1005
|
}
|
|
1047
1006
|
async importCliSession(channel, channelId, projectPath, agentSessionId, agentId = 'claude') {
|
|
1048
|
-
// 继承当前 chatType(在 deactivate 之前读取)
|
|
1049
1007
|
const inheritedChatType = this.getActiveChatType(channel, channelId);
|
|
1050
|
-
// 取消当前活跃会话
|
|
1051
|
-
this.deactivateAllMetadata(channel, channelId);
|
|
1052
|
-
// 从 CLI 会话文件读取标题
|
|
1053
1008
|
const fileInfo = this.getSessionFileInfo(projectPath, agentSessionId, agentId);
|
|
1054
1009
|
const name = fileInfo.title || `CLI会话-${new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`;
|
|
1055
|
-
// 创建新会话记录
|
|
1056
1010
|
const session = {
|
|
1057
|
-
id:
|
|
1011
|
+
id: generateSessionId(),
|
|
1058
1012
|
channel,
|
|
1013
|
+
channelType: this.inferChannelType(channel, channelId),
|
|
1059
1014
|
channelId,
|
|
1015
|
+
selfId: this.inferSelfId(channel, channelId),
|
|
1060
1016
|
projectPath,
|
|
1061
1017
|
threadId: '',
|
|
1062
1018
|
agentId,
|
|
1063
1019
|
chatType: inheritedChatType,
|
|
1064
1020
|
sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
|
|
1065
1021
|
agentSessionId,
|
|
1066
|
-
metadata: {
|
|
1022
|
+
metadata: {},
|
|
1067
1023
|
name,
|
|
1068
1024
|
createdAt: Date.now(),
|
|
1069
|
-
updatedAt: Date.now()
|
|
1025
|
+
updatedAt: Date.now(),
|
|
1070
1026
|
};
|
|
1071
|
-
this.
|
|
1027
|
+
this.appendMeta(channel, channelId, session);
|
|
1028
|
+
this.writeActive(channel, channelId, session);
|
|
1072
1029
|
this.eventBus.publish({
|
|
1073
1030
|
type: 'session:created',
|
|
1074
1031
|
sessionId: session.id,
|
|
@@ -1076,111 +1033,117 @@ export class SessionManager {
|
|
|
1076
1033
|
channelId,
|
|
1077
1034
|
projectPath,
|
|
1078
1035
|
name,
|
|
1079
|
-
timestamp: Date.now()
|
|
1036
|
+
timestamp: Date.now(),
|
|
1080
1037
|
});
|
|
1081
1038
|
return session;
|
|
1082
1039
|
}
|
|
1083
|
-
//
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1040
|
+
// ─── Health status ───
|
|
1041
|
+
healthFilePath(channel, channelId) {
|
|
1042
|
+
return path.join(this.ensureResolvedChatDir(channel, channelId), 'health.jsonl');
|
|
1043
|
+
}
|
|
1044
|
+
/** Find the chat dir containing a given session id */
|
|
1045
|
+
chatDirForSession(sessionId) {
|
|
1046
|
+
const found = this.findSessionFileById(sessionId);
|
|
1047
|
+
if (!found)
|
|
1048
|
+
return undefined;
|
|
1049
|
+
// 从 meta jsonl 最后一行读 channel 实例名 + channelId
|
|
1050
|
+
const meta = readLastJsonlLine(found.metaPath);
|
|
1051
|
+
if (!meta)
|
|
1052
|
+
return undefined;
|
|
1053
|
+
return { channel: meta.channel, channelId: meta.channelId, dirPath: found.chatDir };
|
|
1054
|
+
}
|
|
1087
1055
|
async getHealthStatus(sessionId) {
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
if (!row) {
|
|
1092
|
-
// 首次查询,创建默认记录
|
|
1093
|
-
const now = Date.now();
|
|
1094
|
-
this.db.prepare(`
|
|
1095
|
-
INSERT INTO session_health (session_id, consecutive_errors, safe_mode, last_success_time, created_at, updated_at)
|
|
1096
|
-
VALUES (?, 0, 0, ?, ?, ?)
|
|
1097
|
-
`).run(sessionId, now, now, now);
|
|
1098
|
-
return {
|
|
1099
|
-
consecutiveErrors: 0,
|
|
1100
|
-
safeMode: false,
|
|
1101
|
-
lastSuccessTime: now
|
|
1102
|
-
};
|
|
1056
|
+
const chatInfo = this.chatDirForSession(sessionId);
|
|
1057
|
+
if (!chatInfo) {
|
|
1058
|
+
return { consecutiveErrors: 0, safeMode: false, lastSuccessTime: Date.now() };
|
|
1103
1059
|
}
|
|
1060
|
+
const healthPath = path.join(chatInfo.dirPath, 'health.jsonl');
|
|
1061
|
+
const records = readAllJsonlLines(healthPath).filter(r => r.sessionId === sessionId);
|
|
1062
|
+
let consecutiveErrors = 0;
|
|
1063
|
+
let lastError;
|
|
1064
|
+
let lastErrorType;
|
|
1065
|
+
let lastSuccessTime = 0;
|
|
1066
|
+
// Walk from tail to count consecutive errors
|
|
1067
|
+
for (let i = records.length - 1; i >= 0; i--) {
|
|
1068
|
+
const rec = records[i];
|
|
1069
|
+
if (rec.type === 'error') {
|
|
1070
|
+
consecutiveErrors++;
|
|
1071
|
+
if (lastError === undefined) {
|
|
1072
|
+
lastError = rec.error;
|
|
1073
|
+
lastErrorType = rec.errorType;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
else {
|
|
1077
|
+
// success or reset breaks the streak
|
|
1078
|
+
if (rec.type === 'success' && rec.at > lastSuccessTime)
|
|
1079
|
+
lastSuccessTime = rec.at;
|
|
1080
|
+
break;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
// Find most recent success time across all records
|
|
1084
|
+
if (lastSuccessTime === 0) {
|
|
1085
|
+
for (let i = records.length - 1; i >= 0; i--) {
|
|
1086
|
+
if (records[i].type === 'success' && records[i].at > lastSuccessTime) {
|
|
1087
|
+
lastSuccessTime = records[i].at;
|
|
1088
|
+
break;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
if (lastSuccessTime === 0)
|
|
1093
|
+
lastSuccessTime = Date.now();
|
|
1104
1094
|
return {
|
|
1105
|
-
consecutiveErrors
|
|
1106
|
-
lastError
|
|
1107
|
-
lastErrorType
|
|
1108
|
-
safeMode:
|
|
1109
|
-
lastSuccessTime
|
|
1095
|
+
consecutiveErrors,
|
|
1096
|
+
lastError,
|
|
1097
|
+
lastErrorType,
|
|
1098
|
+
safeMode: false,
|
|
1099
|
+
lastSuccessTime,
|
|
1110
1100
|
};
|
|
1111
1101
|
}
|
|
1112
|
-
/** 当前处于安全模式的会话数 */
|
|
1113
|
-
getSafeModeSessionCount() {
|
|
1114
|
-
const row = this.db.prepare(`SELECT COUNT(*) as count FROM session_health WHERE safe_mode = 1`).get();
|
|
1115
|
-
return row?.count ?? 0;
|
|
1116
|
-
}
|
|
1117
|
-
/**
|
|
1118
|
-
* 记录成功响应(重置错误计数)
|
|
1119
|
-
*/
|
|
1120
1102
|
async recordSuccess(sessionId) {
|
|
1103
|
+
const chatInfo = this.chatDirForSession(sessionId);
|
|
1104
|
+
if (!chatInfo)
|
|
1105
|
+
return;
|
|
1121
1106
|
const now = Date.now();
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
last_success_time = ?,
|
|
1130
|
-
updated_at = ?
|
|
1131
|
-
`).run(sessionId, now, now, now, now, now);
|
|
1107
|
+
const record = {
|
|
1108
|
+
type: 'success',
|
|
1109
|
+
sessionId,
|
|
1110
|
+
at: now,
|
|
1111
|
+
atStr: formatTimestamp(now),
|
|
1112
|
+
};
|
|
1113
|
+
appendJsonl(path.join(chatInfo.dirPath, 'health.jsonl'), record);
|
|
1132
1114
|
}
|
|
1133
|
-
/**
|
|
1134
|
-
* 记录错误(增加计数)
|
|
1135
|
-
*/
|
|
1136
1115
|
async recordError(sessionId, errorType, errorMessage) {
|
|
1116
|
+
const chatInfo = this.chatDirForSession(sessionId);
|
|
1117
|
+
if (!chatInfo)
|
|
1118
|
+
return 0;
|
|
1137
1119
|
const now = Date.now();
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
return newCount;
|
|
1150
|
-
}
|
|
1151
|
-
/**
|
|
1152
|
-
* 设置安全模式
|
|
1153
|
-
*/
|
|
1154
|
-
async setSafeMode(sessionId, enabled) {
|
|
1155
|
-
const now = Date.now();
|
|
1156
|
-
const health = await this.getHealthStatus(sessionId);
|
|
1157
|
-
this.db.prepare(`
|
|
1158
|
-
INSERT INTO session_health (session_id, consecutive_errors, safe_mode, last_success_time, created_at, updated_at)
|
|
1159
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
1160
|
-
ON CONFLICT(session_id) DO UPDATE SET
|
|
1161
|
-
safe_mode = ?,
|
|
1162
|
-
updated_at = ?
|
|
1163
|
-
`).run(sessionId, health.consecutiveErrors, enabled ? 1 : 0, health.lastSuccessTime, now, now, enabled ? 1 : 0, now);
|
|
1120
|
+
const record = {
|
|
1121
|
+
type: 'error',
|
|
1122
|
+
sessionId,
|
|
1123
|
+
errorType,
|
|
1124
|
+
error: errorMessage,
|
|
1125
|
+
at: now,
|
|
1126
|
+
atStr: formatTimestamp(now),
|
|
1127
|
+
};
|
|
1128
|
+
appendJsonl(path.join(chatInfo.dirPath, 'health.jsonl'), record);
|
|
1129
|
+
const status = await this.getHealthStatus(sessionId);
|
|
1130
|
+
return status.consecutiveErrors;
|
|
1164
1131
|
}
|
|
1165
|
-
/**
|
|
1166
|
-
* 重置健康状态(用于修复后)
|
|
1167
|
-
*/
|
|
1168
1132
|
async resetHealthStatus(sessionId) {
|
|
1133
|
+
const chatInfo = this.chatDirForSession(sessionId);
|
|
1134
|
+
if (!chatInfo)
|
|
1135
|
+
return;
|
|
1169
1136
|
const now = Date.now();
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
safe_mode = 0,
|
|
1178
|
-
updated_at = ?
|
|
1179
|
-
`).run(sessionId, now, now, now, now);
|
|
1137
|
+
const record = {
|
|
1138
|
+
type: 'reset',
|
|
1139
|
+
sessionId,
|
|
1140
|
+
at: now,
|
|
1141
|
+
atStr: formatTimestamp(now),
|
|
1142
|
+
};
|
|
1143
|
+
appendJsonl(path.join(chatInfo.dirPath, 'health.jsonl'), record);
|
|
1180
1144
|
}
|
|
1181
1145
|
close() {
|
|
1182
1146
|
for (const adapter of this.fileAdapters.values())
|
|
1183
1147
|
adapter.close?.();
|
|
1184
|
-
this.db.close();
|
|
1185
1148
|
}
|
|
1186
1149
|
}
|