evolclaw 2.8.2 → 3.0.0

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