imtoagent 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +234 -0
  2. package/bin/imtoagent +453 -0
  3. package/index.ts +1129 -0
  4. package/modules/agent/claude-adapter.ts +258 -0
  5. package/modules/agent/claude.ts +160 -0
  6. package/modules/agent/codex-adapter.ts +232 -0
  7. package/modules/agent/codex-exec-server.ts +513 -0
  8. package/modules/agent/codex.ts +275 -0
  9. package/modules/agent/opencode-adapter.ts +308 -0
  10. package/modules/agent/opencode.ts +247 -0
  11. package/modules/bot-context.ts +26 -0
  12. package/modules/capabilities.ts +189 -0
  13. package/modules/cli/setup.ts +424 -0
  14. package/modules/core/config.ts +275 -0
  15. package/modules/core/error.ts +124 -0
  16. package/modules/core/index.ts +39 -0
  17. package/modules/core/runtime.ts +282 -0
  18. package/modules/core/session.ts +256 -0
  19. package/modules/core/stats.ts +92 -0
  20. package/modules/core/types.ts +250 -0
  21. package/modules/im/feishu.ts +731 -0
  22. package/modules/im/telegram.ts +639 -0
  23. package/modules/im/wechat.ts +1094 -0
  24. package/modules/im/wecom.ts +603 -0
  25. package/modules/media/feishu-inbound-adapter.ts +108 -0
  26. package/modules/media/index.ts +27 -0
  27. package/modules/media/media-store.ts +273 -0
  28. package/modules/media/resolver.ts +178 -0
  29. package/modules/media/telegram-inbound-adapter.ts +124 -0
  30. package/modules/media/types.ts +76 -0
  31. package/modules/prompt-builder.ts +123 -0
  32. package/modules/proxy/anthropic-proxy.ts +1083 -0
  33. package/modules/proxy/codex-proxy.ts +657 -0
  34. package/modules/rate-limiter.ts +58 -0
  35. package/modules/types.ts +144 -0
  36. package/modules/utils/backend-check.ts +121 -0
  37. package/modules/utils/paths.ts +218 -0
  38. package/package.json +53 -0
  39. package/scripts/postinstall.ts +70 -0
  40. package/templates/config.template.json +57 -0
  41. package/templates/opencode.template.json +28 -0
  42. package/templates/providers.template.json +19 -0
  43. package/templates/soul.template/identity.md +6 -0
  44. package/templates/soul.template/profile.md +11 -0
  45. package/templates/soul.template/rules.md +7 -0
  46. package/templates/soul.template/skills.md +3 -0
  47. package/templates/soul.template/workspace.md +4 -0
@@ -0,0 +1,124 @@
1
+ // ================================================================
2
+ // ErrorHandler — 错误处理策略
3
+ // ================================================================
4
+ // 统一管理后端调用错误,提供重试、降级、用户友好提示
5
+ // ================================================================
6
+
7
+ import type { ErrorAction, ErrorContext, ErrorHandler } from './types';
8
+
9
+ // ================================================================
10
+ // DefaultErrorHandler
11
+ // ================================================================
12
+
13
+ export class DefaultErrorHandler implements ErrorHandler {
14
+ /**
15
+ * 处理错误,返回处理动作
16
+ *
17
+ * 策略:
18
+ * - 网络超时 → 自动重试 (最多 2 次)
19
+ * - 429 限流 → 等待后重试
20
+ * - 401 认证失败 → 直接返回用户提示
21
+ * - 5xx 服务端错误 → 重试
22
+ * - 其他 → 返回用户友好消息
23
+ */
24
+ async handle(chatId: string, error: Error, ctx: ErrorContext): Promise<ErrorAction> {
25
+ const errMsg = error.message || String(error);
26
+ const backend = ctx.backend;
27
+
28
+ console.error(`[Error] ${backend} 调用失败 (attempt ${ctx.attempt}): ${errMsg}`);
29
+
30
+ // 提取 HTTP 状态码
31
+ const statusCode = this.extractStatusCode(error);
32
+ const isTimeout = this.isTimeoutError(error);
33
+
34
+ // 重试策略
35
+ if (ctx.attempt < 2) {
36
+ // 网络超时、5xx → 重试
37
+ if (isTimeout || statusCode >= 500) {
38
+ console.log(`[Error] 将重试 ${backend} 调用 (${ctx.attempt + 1}/2)`);
39
+ return { type: 'retry', maxAttempts: 2 };
40
+ }
41
+
42
+ // 429 限流 → 重试
43
+ if (statusCode === 429) {
44
+ const retryAfter = this.extractRetryAfter(error);
45
+ if (retryAfter > 0) {
46
+ console.log(`[Error] 429 限流,等待 ${retryAfter}ms 后重试`);
47
+ await this.sleep(retryAfter);
48
+ }
49
+ return { type: 'retry', maxAttempts: 2 };
50
+ }
51
+ }
52
+
53
+ // 超过重试次数或不满足重试条件 → 返回用户提示
54
+ const userMessage = this.getUserMessage(error, backend, ctx.attempt);
55
+ return { type: 'reply', message: userMessage };
56
+ }
57
+
58
+ /** 从错误中提取 HTTP 状态码 */
59
+ private extractStatusCode(error: Error): number {
60
+ const msg = error.message || '';
61
+ const match = msg.match(/status[:\s]+(\d+)/i) || msg.match(/(\d{3})\s/);
62
+ if (match) return parseInt(match[1]);
63
+
64
+ // 尝试从 error 对象中获取
65
+ const anyErr = error as any;
66
+ if (anyErr?.status) return anyErr.status;
67
+ if (anyErr?.response?.status) return anyErr.response.status;
68
+ if (anyErr?.statusCode) return anyErr.statusCode;
69
+
70
+ return 0;
71
+ }
72
+
73
+ /** 判断是否为超时错误 */
74
+ private isTimeoutError(error: Error): boolean {
75
+ const msg = error.message.toLowerCase();
76
+ return msg.includes('timeout') || msg.includes('timed out') || msg.includes('etimedout')
77
+ || msg.includes('econnreset') || msg.includes('econnrefused')
78
+ || msg.includes('socket hang up') || msg.includes('network error');
79
+ }
80
+
81
+ /** 提取 Retry-After 毫秒数 */
82
+ private extractRetryAfter(error: Error): number {
83
+ const msg = error.message;
84
+ const match = msg.match(/retry[-_\s]?after[:\s]*(\d+)/i);
85
+ if (match) return parseInt(match[1]) * 1000;
86
+
87
+ const anyErr = error as any;
88
+ if (anyErr?.response?.headers?.['retry-after']) {
89
+ return parseInt(anyErr.response.headers['retry-after']) * 1000;
90
+ }
91
+
92
+ // 默认等待 2 秒
93
+ return 2000;
94
+ }
95
+
96
+ /** 生成用户友好的错误消息 */
97
+ private getUserMessage(error: Error, backend: string, attempt: number): string {
98
+ const statusCode = this.extractStatusCode(error);
99
+
100
+ if (statusCode === 401 || statusCode === 403) {
101
+ return `⚠️ ${backend} 后端认证失败,请联系管理员检查配置。`;
102
+ }
103
+
104
+ if (statusCode === 429) {
105
+ return `⚠️ 当前请求过于频繁,请稍后再试。`;
106
+ }
107
+
108
+ if (statusCode >= 500) {
109
+ return `⚠️ ${backend} 服务端暂时不可用,请稍后重试。`;
110
+ }
111
+
112
+ if (this.isTimeoutError(error)) {
113
+ return `⚠️ 请求超时,后端可能正在处理复杂任务。请稍后再试。`;
114
+ }
115
+
116
+ // 通用错误
117
+ const shortMsg = error.message.slice(0, 100);
118
+ return `⚠️ 处理消息时遇到错误:${shortMsg}`;
119
+ }
120
+
121
+ private sleep(ms: number): Promise<void> {
122
+ return new Promise(resolve => setTimeout(resolve, ms));
123
+ }
124
+ }
@@ -0,0 +1,39 @@
1
+ // ================================================================
2
+ // IMtoAgent SDK Core — 入口
3
+ // ================================================================
4
+ // SDK 核心模块统一导出
5
+ // ================================================================
6
+
7
+ // 类型
8
+ export type {
9
+ CallStats,
10
+ Session,
11
+ AgentInput,
12
+ AgentOutput,
13
+ AgentAdapter,
14
+ MessageContext,
15
+ SessionManager,
16
+ ErrorHandler,
17
+ ConfigManager,
18
+ StatsTracker,
19
+ RuntimeConfig,
20
+ ErrorAction,
21
+ ErrorContext,
22
+ BotConfig,
23
+ ProviderConfig,
24
+ } from './types';
25
+
26
+ // Session 管理
27
+ export { FileSessionManager } from './session';
28
+
29
+ // 错误处理
30
+ export { DefaultErrorHandler } from './error';
31
+
32
+ // 统计追踪
33
+ export { DefaultStatsTracker } from './stats';
34
+
35
+ // 配置管理
36
+ export { FileConfigManager } from './config';
37
+
38
+ // 运行时
39
+ export { AgentRuntime } from './runtime';
@@ -0,0 +1,282 @@
1
+ // ================================================================
2
+ // AgentRuntime — 消息处理中枢
3
+ // ================================================================
4
+ // 协调 Session / Stats / Error / Config,调用 Agent 适配器处理消息
5
+ //
6
+ // processMessage 流程:
7
+ // 1. 获取 session(通过 sessionManager.getOrCreate)
8
+ // 2. 检查 startFresh → 清除旧 backendSessionId
9
+ // 3. statsTracker.resetForCall()
10
+ // 4. ctx.sendProgress("💭 思考中...")
11
+ // 5. adapter.handleMessage(input) ← Agent 适配器
12
+ // 6. 成功 → statsTracker.accumulate() + ctx.reply() 或 ctx.sendBlocks()
13
+ // 7. 失败 → errorHandler.handle() → 可能 retry/fallback/reply
14
+ // 8. sessionManager.persist()
15
+ // ================================================================
16
+
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import type {
20
+ AgentAdapter,
21
+ AgentInput,
22
+ AgentOutput,
23
+ MessageContext,
24
+ RuntimeConfig,
25
+ Session,
26
+ ErrorAction,
27
+ ErrorContext,
28
+ } from './types';
29
+ import { parseToBlocks } from '../capabilities';
30
+ import { DEFAULT_TERMINAL_CAPS } from '../prompt-builder';
31
+
32
+ // ================================================================
33
+ // Agent 自主重启 — 文件信号(固定路径,不依赖 dataDir 参数)
34
+ // ================================================================
35
+ const RESTART_SIGNAL_PATH = path.join(process.env.HOME!, '.imtoagent', '.restart_requested');
36
+ const RESTART_COOLDOWN_MS = 5 * 60 * 1000; // 5 分钟
37
+ let lastRestartTime = 0;
38
+
39
+ interface RestartSignal {
40
+ reason: string;
41
+ timestamp: number;
42
+ botName?: string;
43
+ }
44
+
45
+ /**
46
+ * 检测重启信号文件是否存在且包含有效内容。
47
+ */
48
+ function checkRestartSignal(): { triggered: boolean; reason: string } {
49
+ try {
50
+ if (!fs.existsSync(RESTART_SIGNAL_PATH)) return { triggered: false, reason: '' };
51
+ const raw = fs.readFileSync(RESTART_SIGNAL_PATH, 'utf-8').trim();
52
+ let signal: RestartSignal;
53
+ try {
54
+ signal = JSON.parse(raw);
55
+ } catch {
56
+ // 兼容纯文本格式
57
+ signal = { reason: raw, timestamp: Date.now() };
58
+ }
59
+ return { triggered: true, reason: signal.reason || '未知原因' };
60
+ } catch {
61
+ return { triggered: false, reason: '' };
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 消费重启信号:读取后删除文件,确保每次信号只触发一次重启。
67
+ */
68
+ function consumeRestartSignal(): { triggered: boolean; reason: string } {
69
+ const result = checkRestartSignal();
70
+ if (result.triggered) {
71
+ try { fs.unlinkSync(RESTART_SIGNAL_PATH); } catch {}
72
+ }
73
+ return result;
74
+ }
75
+
76
+ // ================================================================
77
+ // AgentRuntime
78
+ // ================================================================
79
+
80
+ export class AgentRuntime {
81
+ private adapters = new Map<string, AgentAdapter>();
82
+ private config: RuntimeConfig;
83
+
84
+ constructor(config: RuntimeConfig) {
85
+ this.config = config;
86
+ }
87
+
88
+ /**
89
+ * 注册 Agent 适配器
90
+ * @param backend 后端标识 ('claude' | 'codex' | 'opencode' 等)
91
+ * @param adapter Agent 适配器实例
92
+ */
93
+ registerAdapter(backend: string, adapter: AgentAdapter): void {
94
+ this.adapters.set(backend, adapter);
95
+ console.log(`[Runtime] 注册适配器: ${backend} → ${adapter.name}`);
96
+ }
97
+
98
+ /**
99
+ * 获取已注册的适配器
100
+ */
101
+ getAdapter(backend: string): AgentAdapter | undefined {
102
+ return this.adapters.get(backend);
103
+ }
104
+
105
+ /**
106
+ * 处理用户消息
107
+ *
108
+ * @param ctx 消息处理上下文
109
+ * @param adapter Agent 适配器
110
+ * @param botName Bot 名称(用于 session 隔离)
111
+ * @returns 如果 Agent 请求重启,返回 { restart: true, reason };否则返回 { restart: false }
112
+ */
113
+ async processMessage(
114
+ ctx: MessageContext,
115
+ adapter: AgentAdapter,
116
+ botName: string
117
+ ): Promise<{ restart: boolean; reason?: string }> {
118
+ const startTime = Date.now();
119
+ let attempt = 0;
120
+ const maxRetries = 2;
121
+
122
+ while (attempt <= maxRetries) {
123
+ attempt++;
124
+
125
+ try {
126
+ // 1. 获取 session
127
+ const session = await this.config.sessionManager.getOrCreate(
128
+ botName,
129
+ ctx.chatId,
130
+ ctx.userId
131
+ );
132
+
133
+ // 2. 检查 startFresh → 清除旧 backendSessionId
134
+ if (session.startFresh) {
135
+ session.backendSessionId = undefined;
136
+ session.metadata = {};
137
+ session.startFresh = false;
138
+ session.running = false;
139
+ console.log(`[Runtime] startFresh: 清除 ${ctx.chatId} 旧会话`);
140
+ }
141
+
142
+ // 3. 重置统计
143
+ this.config.statsTracker.resetForCall(session);
144
+
145
+ // 4. 发送进度提示
146
+ await ctx.sendProgress('💭 思考中...');
147
+
148
+ session.running = true;
149
+
150
+ // 5. 调用 Agent 适配器
151
+ const input: AgentInput = {
152
+ chatId: ctx.chatId,
153
+ text: ctx.text,
154
+ attachments: ctx.attachments,
155
+ session,
156
+ workingDir: ctx.workingDir,
157
+ systemPrompt: ctx.systemPrompt,
158
+ model: ctx.model,
159
+ };
160
+
161
+ const output = await adapter.handleMessage(input);
162
+
163
+ // 6. 处理成功结果
164
+ session.running = false;
165
+
166
+ if (output.error) {
167
+ throw new Error(output.error);
168
+ }
169
+
170
+ // 累加统计
171
+ if (output.usage) {
172
+ const duration = Date.now() - startTime;
173
+ this.config.statsTracker.accumulate(session, {
174
+ inputTokens: output.usage.inputTokens,
175
+ outputTokens: output.usage.outputTokens,
176
+ costUSD: output.usage.costUSD,
177
+ durationMs: output.usage.durationMs || duration,
178
+ numTurns: output.usage.numTurns,
179
+ });
180
+ }
181
+
182
+ // 发送回复 — 优先用 sendBlocks(parseToBlocks → 富文本渲染)
183
+ if (output.text) {
184
+ if (ctx.sendBlocks) {
185
+ const caps = ctx.imCaps || DEFAULT_TERMINAL_CAPS;
186
+ const blocks = parseToBlocks(output.text, caps);
187
+ if (blocks.length === 1 && blocks[0].type === 'text') {
188
+ await ctx.reply(output.text);
189
+ } else {
190
+ await ctx.sendBlocks(blocks);
191
+ }
192
+ } else {
193
+ await ctx.reply(output.text);
194
+ }
195
+ }
196
+
197
+ // 持久化 session
198
+ this.config.sessionManager.persist(botName, session);
199
+
200
+ // 检查 Agent 自主重启信号(在 reply 之后检测,确保消息已发送)
201
+ const signal = consumeRestartSignal();
202
+ if (signal.triggered) {
203
+ const now = Date.now();
204
+ if (now - lastRestartTime < RESTART_COOLDOWN_MS) {
205
+ const remaining = Math.ceil((RESTART_COOLDOWN_MS - (now - lastRestartTime)) / 1000);
206
+ console.log(`[Runtime] ⏳ Agent 请求重启被忽略(冷却中,${remaining}s 后重试): ${signal.reason}`);
207
+ } else {
208
+ lastRestartTime = now;
209
+ console.log(`[Runtime] 🔄 Agent 请求重启: ${signal.reason}`);
210
+ return { restart: true, reason: signal.reason };
211
+ }
212
+ }
213
+
214
+ return { restart: false };
215
+
216
+ } catch (error: any) {
217
+ console.error(`[Runtime] 处理消息失败 (attempt ${attempt}): ${error.message}`);
218
+
219
+ // 7. 错误处理
220
+ const errorCtx: ErrorContext = {
221
+ chatId: ctx.chatId,
222
+ backend: adapter.name,
223
+ attempt,
224
+ };
225
+
226
+ const action = await this.config.errorHandler.handle(ctx.chatId, error, errorCtx);
227
+
228
+ if (action.type === 'retry') {
229
+ console.log(`[Runtime] 重试 ${adapter.name} 调用`);
230
+ continue;
231
+ }
232
+
233
+ if (action.type === 'fallback') {
234
+ console.log(`[Runtime] 降级到 ${action.adapter}`);
235
+ // Phase 2 实现 fallback 逻辑
236
+ const fallbackAdapter = this.adapters.get(action.adapter);
237
+ if (fallbackAdapter) {
238
+ return this.processMessage(ctx, fallbackAdapter, botName);
239
+ }
240
+ }
241
+
242
+ if (action.type === 'reply') {
243
+ await ctx.reply(action.message);
244
+ }
245
+
246
+ // 持久化 session(即使失败也要保存统计)
247
+ try {
248
+ const session = await this.config.sessionManager.getOrCreate(
249
+ botName,
250
+ ctx.chatId,
251
+ ctx.userId
252
+ );
253
+ session.running = false;
254
+ this.config.sessionManager.persist(botName, session);
255
+ } catch {}
256
+
257
+ return { restart: false }; // 不再重试
258
+ }
259
+ }
260
+ }
261
+
262
+ /**
263
+ * 取消正在进行的会话
264
+ */
265
+ async cancelSession(adapterName: string, sessionId: string): Promise<void> {
266
+ const adapter = this.adapters.get(adapterName);
267
+ if (adapter?.cancel) {
268
+ await adapter.cancel(sessionId);
269
+ }
270
+ }
271
+
272
+ /**
273
+ * 健康检查
274
+ */
275
+ async healthCheck(backend: string): Promise<boolean> {
276
+ const adapter = this.adapters.get(backend);
277
+ if (adapter?.healthCheck) {
278
+ return adapter.healthCheck();
279
+ }
280
+ return true; // 默认认为健康
281
+ }
282
+ }
@@ -0,0 +1,256 @@
1
+ // ================================================================
2
+ // SessionManager — 会话生命周期管理
3
+ // ================================================================
4
+ // 从 index.ts 和 anthropic-proxy.ts 迁移 session 逻辑
5
+ // 路径: ~/Desktop/imtoagent/sessions/{botName}/{chatId}.memory.json
6
+ // ================================================================
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ import type { Session, SessionManager, CallStats } from './types';
12
+ import { getSessionsDir } from '../utils/paths';
13
+
14
+ const SESSIONS_BASE = getSessionsDir();
15
+
16
+ /** 默认统计值 */
17
+ const EMPTY_STATS: CallStats = {
18
+ calls: 0,
19
+ totalTurns: 0,
20
+ totalInputTokens: 0,
21
+ totalOutputTokens: 0,
22
+ totalCostUSD: 0,
23
+ totalDurationMs: 0,
24
+ };
25
+
26
+ // ================================================================
27
+ // 旧格式兼容
28
+ // ================================================================
29
+
30
+ /** 旧版 SessionData 格式(modules/types.ts 中的定义) */
31
+ interface LegacySessionData {
32
+ chatId?: string;
33
+ userId: string;
34
+ sdkSessionId?: string;
35
+ codexThreadId?: string;
36
+ ocSessionId?: string;
37
+ cwd?: string;
38
+ permissionMode?: string;
39
+ codexMode?: string;
40
+ startFresh?: boolean;
41
+ stats: CallStats;
42
+ recentMessages: string[];
43
+ lastUsed: number;
44
+ activeModel?: string;
45
+ modelAliases?: Record<string, string>;
46
+ }
47
+
48
+ /**
49
+ * 从旧版 .memory.json 迁移为新版 Session 格式
50
+ * 保持向后兼容,不影响现有会话文件
51
+ */
52
+ function migrateFromLegacy(data: LegacySessionData, chatId: string): Session {
53
+ const metadata: Record<string, any> = {};
54
+
55
+ // 迁移旧版特有 ID 到 metadata
56
+ if (data.sdkSessionId) metadata.sdkSessionId = data.sdkSessionId;
57
+ if (data.codexThreadId) metadata.codexThreadId = data.codexThreadId;
58
+ if (data.ocSessionId) metadata.ocSessionId = data.ocSessionId;
59
+
60
+ // 通用 backendSessionId 优先使用旧版中的值
61
+ const backendSessionId = data.sdkSessionId || data.codexThreadId || data.ocSessionId;
62
+
63
+ return {
64
+ chatId: data.chatId || chatId,
65
+ userId: data.userId,
66
+ cwd: data.cwd,
67
+ startFresh: data.startFresh || false,
68
+ backendSessionId,
69
+ metadata,
70
+ stats: data.stats || { ...EMPTY_STATS },
71
+ lastUsed: data.lastUsed || Date.now(),
72
+ running: false,
73
+ permissionMode: data.permissionMode,
74
+ codexMode: data.codexMode,
75
+ recentMessages: data.recentMessages || [],
76
+ };
77
+ }
78
+
79
+ // ================================================================
80
+ // FileSessionManager
81
+ // ================================================================
82
+
83
+ export class FileSessionManager implements SessionManager {
84
+ /** 内存缓存: botName -> chatId -> Session */
85
+ private cache = new Map<string, Map<string, Session>>();
86
+
87
+ /** 获取 Session 文件路径 */
88
+ private sessionPath(botName: string, chatId: string): string {
89
+ const sessionsBase = getSessionsDir();
90
+ const botDir = path.join(sessionsBase, botName);
91
+ return path.join(botDir, `${chatId}.memory.json`);
92
+ }
93
+
94
+ /** 确保目录存在 */
95
+ private ensureDir(botName: string): void {
96
+ const sessionsBase = getSessionsDir();
97
+ const botDir = path.join(sessionsBase, botName);
98
+ if (!fs.existsSync(botDir)) {
99
+ fs.mkdirSync(botDir, { recursive: true });
100
+ }
101
+ }
102
+
103
+ async getOrCreate(botName: string, chatId: string, userId: string): Promise<Session> {
104
+ // 先查缓存
105
+ const botCache = this.cache.get(botName);
106
+ if (botCache) {
107
+ const cached = botCache.get(chatId);
108
+ if (cached) {
109
+ cached.lastUsed = Date.now();
110
+ return cached;
111
+ }
112
+ }
113
+
114
+ // 从文件加载
115
+ const filePath = this.sessionPath(botName, chatId);
116
+ this.ensureDir(botName);
117
+
118
+ let session: Session;
119
+
120
+ if (fs.existsSync(filePath)) {
121
+ try {
122
+ const raw = fs.readFileSync(filePath, 'utf-8');
123
+ const data = JSON.parse(raw);
124
+
125
+ // 检测是否为旧格式(有 sdkSessionId/codexThreadId/ocSessionId 顶层字段)
126
+ if ('sdkSessionId' in data || 'codexThreadId' in data || 'ocSessionId' in data) {
127
+ session = migrateFromLegacy(data as LegacySessionData, chatId);
128
+ } else if ('metadata' in data && 'stats' in data && 'chatId' in data) {
129
+ // 新格式
130
+ session = {
131
+ ...data,
132
+ startFresh: data.startFresh || false,
133
+ running: data.running || false,
134
+ recentMessages: data.recentMessages || [],
135
+ stats: data.stats || { ...EMPTY_STATS },
136
+ };
137
+ } else {
138
+ // 未知格式,新建
139
+ session = this.createNewSession(chatId, userId);
140
+ }
141
+ } catch (e: any) {
142
+ console.error(`[Session] 加载 ${chatId} 失败: ${e.message},创建新会话`);
143
+ session = this.createNewSession(chatId, userId);
144
+ }
145
+ } else {
146
+ session = this.createNewSession(chatId, userId);
147
+ }
148
+
149
+ // 缓存
150
+ if (!this.cache.has(botName)) {
151
+ this.cache.set(botName, new Map());
152
+ }
153
+ this.cache.get(botName)!.set(chatId, session);
154
+
155
+ return session;
156
+ }
157
+
158
+ private createNewSession(chatId: string, userId: string): Session {
159
+ return {
160
+ chatId,
161
+ userId,
162
+ cwd: undefined,
163
+ startFresh: false,
164
+ backendSessionId: undefined,
165
+ metadata: {},
166
+ stats: { ...EMPTY_STATS },
167
+ lastUsed: Date.now(),
168
+ running: false,
169
+ recentMessages: [],
170
+ };
171
+ }
172
+
173
+ persist(botName: string, session: Session): void {
174
+ this.ensureDir(botName);
175
+
176
+ // 写入时保持旧格式兼容:将 metadata 中的旧 ID 也写入顶层
177
+ const output: Record<string, any> = {
178
+ chatId: session.chatId,
179
+ userId: session.userId,
180
+ cwd: session.cwd,
181
+ startFresh: session.startFresh,
182
+ stats: session.stats,
183
+ lastUsed: session.lastUsed,
184
+ recentMessages: session.recentMessages || [],
185
+ running: session.running,
186
+ };
187
+
188
+ // 通用 backendSessionId
189
+ if (session.backendSessionId) {
190
+ output.backendSessionId = session.backendSessionId;
191
+ }
192
+
193
+ // 向后兼容:将 metadata 中的旧 ID 也写入顶层
194
+ if (session.metadata) {
195
+ if (session.metadata.sdkSessionId) output.sdkSessionId = session.metadata.sdkSessionId;
196
+ if (session.metadata.codexThreadId) output.codexThreadId = session.metadata.codexThreadId;
197
+ if (session.metadata.ocSessionId) output.ocSessionId = session.metadata.ocSessionId;
198
+ // permissionMode / codexMode 也放在顶层
199
+ if (session.permissionMode) output.permissionMode = session.permissionMode;
200
+ if (session.codexMode) output.codexMode = session.codexMode;
201
+ }
202
+
203
+ // metadata 完整保存
204
+ output.metadata = session.metadata;
205
+
206
+ const filePath = this.sessionPath(botName, session.chatId);
207
+ try {
208
+ fs.writeFileSync(filePath, JSON.stringify(output, null, 2));
209
+ } catch (e: any) {
210
+ console.error(`[Session] 持久化 ${session.chatId} 失败: ${e.message}`);
211
+ }
212
+ }
213
+
214
+ delete(botName: string, chatId: string): void {
215
+ // 清除缓存
216
+ const botCache = this.cache.get(botName);
217
+ if (botCache) {
218
+ botCache.delete(chatId);
219
+ }
220
+
221
+ // 删除文件
222
+ const filePath = this.sessionPath(botName, chatId);
223
+ try {
224
+ if (fs.existsSync(filePath)) {
225
+ fs.unlinkSync(filePath);
226
+ }
227
+ } catch (e: any) {
228
+ console.error(`[Session] 删除 ${chatId} 失败: ${e.message}`);
229
+ }
230
+ }
231
+
232
+ cleanupIdle(botName: string, timeoutMs: number): void {
233
+ const botCache = this.cache.get(botName);
234
+ if (!botCache) return;
235
+
236
+ const now = Date.now();
237
+ const toRemove: string[] = [];
238
+
239
+ for (const [chatId, session] of botCache) {
240
+ if (now - session.lastUsed > timeoutMs && !session.running) {
241
+ toRemove.push(chatId);
242
+ }
243
+ }
244
+
245
+ for (const chatId of toRemove) {
246
+ botCache.delete(chatId);
247
+ console.log(`[Session] 清理空闲会话: ${chatId}`);
248
+ }
249
+ }
250
+
251
+ listActive(botName: string): Session[] {
252
+ const botCache = this.cache.get(botName);
253
+ if (!botCache) return [];
254
+ return Array.from(botCache.values());
255
+ }
256
+ }