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.
- package/README.md +234 -0
- package/bin/imtoagent +453 -0
- package/index.ts +1129 -0
- package/modules/agent/claude-adapter.ts +258 -0
- package/modules/agent/claude.ts +160 -0
- package/modules/agent/codex-adapter.ts +232 -0
- package/modules/agent/codex-exec-server.ts +513 -0
- package/modules/agent/codex.ts +275 -0
- package/modules/agent/opencode-adapter.ts +308 -0
- package/modules/agent/opencode.ts +247 -0
- package/modules/bot-context.ts +26 -0
- package/modules/capabilities.ts +189 -0
- package/modules/cli/setup.ts +424 -0
- package/modules/core/config.ts +275 -0
- package/modules/core/error.ts +124 -0
- package/modules/core/index.ts +39 -0
- package/modules/core/runtime.ts +282 -0
- package/modules/core/session.ts +256 -0
- package/modules/core/stats.ts +92 -0
- package/modules/core/types.ts +250 -0
- package/modules/im/feishu.ts +731 -0
- package/modules/im/telegram.ts +639 -0
- package/modules/im/wechat.ts +1094 -0
- package/modules/im/wecom.ts +603 -0
- package/modules/media/feishu-inbound-adapter.ts +108 -0
- package/modules/media/index.ts +27 -0
- package/modules/media/media-store.ts +273 -0
- package/modules/media/resolver.ts +178 -0
- package/modules/media/telegram-inbound-adapter.ts +124 -0
- package/modules/media/types.ts +76 -0
- package/modules/prompt-builder.ts +123 -0
- package/modules/proxy/anthropic-proxy.ts +1083 -0
- package/modules/proxy/codex-proxy.ts +657 -0
- package/modules/rate-limiter.ts +58 -0
- package/modules/types.ts +144 -0
- package/modules/utils/backend-check.ts +121 -0
- package/modules/utils/paths.ts +218 -0
- package/package.json +53 -0
- package/scripts/postinstall.ts +70 -0
- package/templates/config.template.json +57 -0
- package/templates/opencode.template.json +28 -0
- package/templates/providers.template.json +19 -0
- package/templates/soul.template/identity.md +6 -0
- package/templates/soul.template/profile.md +11 -0
- package/templates/soul.template/rules.md +7 -0
- package/templates/soul.template/skills.md +3 -0
- package/templates/soul.template/workspace.md +4 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1129 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CC 路由 v4 — 多 Bot 架构(SDK 完整接入版)
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
|
|
8
|
+
// ===== 重启信号文件路径(统一固定,不依赖 getDataDir) =====
|
|
9
|
+
const RESTART_SIGNAL_PATH = path.join(process.env.HOME!, '.imtoagent', '.restart_requested');
|
|
10
|
+
|
|
11
|
+
import * as Lark from '@larksuiteoapi/node-sdk';
|
|
12
|
+
import {
|
|
13
|
+
sharedState, loadProviders, getProviderConfig, saveActiveModel,
|
|
14
|
+
loadSessionConfig, saveSessionConfig,
|
|
15
|
+
saveSessionMemory, loadSessionMemory, deleteSessionMemory, listPersistedSessions,
|
|
16
|
+
resolveModel, ModelAliases, SessionMemoryData
|
|
17
|
+
} from './modules/proxy/anthropic-proxy';
|
|
18
|
+
import { parseToBlocks } from './modules/capabilities';
|
|
19
|
+
import { resolveCapabilities } from './modules/prompt-builder';
|
|
20
|
+
import { getDataDir } from './modules/utils/paths';
|
|
21
|
+
import { FeishuIMModule } from './modules/im/feishu';
|
|
22
|
+
import { TelegramAdapter } from './modules/im/telegram';
|
|
23
|
+
import { WeComIMModule } from './modules/im/wecom';
|
|
24
|
+
import { WeChatIMModule } from './modules/im/wechat';
|
|
25
|
+
import type { IMModule } from './modules/types';
|
|
26
|
+
|
|
27
|
+
// ================================================================
|
|
28
|
+
// IM 注册表 — 新增 IM 只需加一行注册,不改 Bot 构造函数
|
|
29
|
+
// ================================================================
|
|
30
|
+
interface IMFactory {
|
|
31
|
+
create(cfg: BotConfig): IMModule;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const IM_REGISTRY = new Map<string, IMFactory>();
|
|
35
|
+
|
|
36
|
+
function registerIM(type: string, factory: IMFactory) {
|
|
37
|
+
IM_REGISTRY.set(type, factory);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 注册飞书
|
|
41
|
+
registerIM('feishu', {
|
|
42
|
+
create(cfg: BotConfig) {
|
|
43
|
+
return new FeishuIMModule({ appId: cfg.appId, appSecret: cfg.appSecret });
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// 注册 Telegram
|
|
48
|
+
registerIM('telegram', {
|
|
49
|
+
create(cfg: BotConfig) {
|
|
50
|
+
return new TelegramAdapter({ token: cfg.appId, proxy: (cfg as any).proxy });
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// 注册企业微信
|
|
55
|
+
registerIM('wecom', {
|
|
56
|
+
create(cfg: BotConfig) {
|
|
57
|
+
return new WeComIMModule({
|
|
58
|
+
corpId: cfg.appId,
|
|
59
|
+
corpSecret: cfg.appSecret,
|
|
60
|
+
agentId: (cfg as any).agentId,
|
|
61
|
+
token: (cfg as any).token,
|
|
62
|
+
encodingAESKey: (cfg as any).encodingAESKey,
|
|
63
|
+
receiveId: (cfg as any).receiveId,
|
|
64
|
+
webhookPort: (cfg as any).webhookPort,
|
|
65
|
+
webhookPath: (cfg as any).webhookPath,
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// 注册个人微信
|
|
71
|
+
registerIM('wechat', {
|
|
72
|
+
create(cfg: BotConfig) {
|
|
73
|
+
return new WeChatIMModule({
|
|
74
|
+
botId: (cfg as any).botId,
|
|
75
|
+
botToken: (cfg as any).botToken,
|
|
76
|
+
ilinkUserId: (cfg as any).ilinkUserId,
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
import { startAnthropicProxy, stopAnthropicProxy } from './modules/proxy/anthropic-proxy';
|
|
81
|
+
import { getProxyUsage, resetProxyUsage, initCodexProxyConfig } from './modules/proxy/codex-proxy';
|
|
82
|
+
import { initOpenCodeConfig } from './modules/agent/opencode';
|
|
83
|
+
import { checkRateLimit, setRateLimitConfig } from './modules/rate-limiter';
|
|
84
|
+
import { setCurrentBot } from './modules/bot-context';
|
|
85
|
+
import { getDataDir, getSessionsDir, getSoulDir, getRestoreMarkerPath } from './modules/utils/paths';
|
|
86
|
+
|
|
87
|
+
// ===== SDK 核心 =====
|
|
88
|
+
import { AgentRuntime, FileSessionManager, DefaultErrorHandler, DefaultStatsTracker } from './modules/core';
|
|
89
|
+
import { ClaudeAdapter } from './modules/agent/claude-adapter';
|
|
90
|
+
import { CodexAdapter } from './modules/agent/codex-adapter';
|
|
91
|
+
import { OpenCodeAdapter } from './modules/agent/opencode-adapter';
|
|
92
|
+
import type { CallStats, Session, AgentAdapter, MessageAttachment } from './modules/core/types';
|
|
93
|
+
import { startOpenCodeServer, stopOpenCodeServer } from './modules/agent/opencode-adapter';
|
|
94
|
+
|
|
95
|
+
// ===== 全局活跃请求计数 =====
|
|
96
|
+
let activeRequests = 0;
|
|
97
|
+
|
|
98
|
+
// ================================================================
|
|
99
|
+
// 解析飞书消息内容
|
|
100
|
+
// ================================================================
|
|
101
|
+
function parseMessage(content: string): string {
|
|
102
|
+
try { return (JSON.parse(content).text || '').trim(); }
|
|
103
|
+
catch { return content.trim(); }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ================================================================
|
|
107
|
+
// ChatSession — 继承 SDK Session,兼容旧命令
|
|
108
|
+
// ================================================================
|
|
109
|
+
interface ChatSession extends Session {
|
|
110
|
+
_raw?: any; // 保留原始加载数据
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ================================================================
|
|
114
|
+
// 自定义 SessionManager — 桥接 Bot.sessions 和 SDK
|
|
115
|
+
// ================================================================
|
|
116
|
+
class CustomSessionManager {
|
|
117
|
+
sessions: Map<string, ChatSession>;
|
|
118
|
+
botName: string;
|
|
119
|
+
|
|
120
|
+
constructor(botName: string, sessions: Map<string, ChatSession>) {
|
|
121
|
+
this.botName = botName;
|
|
122
|
+
this.sessions = sessions;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private _sessionPath(chatId: string): string {
|
|
126
|
+
return path.join(getSessionsDir(), this.botName, `${chatId}.memory.json`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async getOrCreate(chatId: string, userId: string): Promise<ChatSession> {
|
|
130
|
+
const existing = this.sessions.get(chatId);
|
|
131
|
+
if (existing) {
|
|
132
|
+
existing.lastUsed = Date.now();
|
|
133
|
+
return existing;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 从文件加载(兼容旧格式)
|
|
137
|
+
const fp = this._sessionPath(chatId);
|
|
138
|
+
let session: ChatSession;
|
|
139
|
+
|
|
140
|
+
if (fs.existsSync(fp)) {
|
|
141
|
+
try {
|
|
142
|
+
const raw = fs.readFileSync(fp, 'utf-8');
|
|
143
|
+
const data = JSON.parse(raw);
|
|
144
|
+
const EMPTY_STATS: CallStats = {
|
|
145
|
+
calls: 0, totalTurns: 0, totalInputTokens: 0,
|
|
146
|
+
totalOutputTokens: 0, totalCostUSD: 0, totalDurationMs: 0,
|
|
147
|
+
};
|
|
148
|
+
session = {
|
|
149
|
+
chatId: data.chatId || chatId,
|
|
150
|
+
userId: data.userId || userId,
|
|
151
|
+
cwd: data.cwd,
|
|
152
|
+
startFresh: data.startFresh || false,
|
|
153
|
+
backendSessionId: data.sdkSessionId || data.codexThreadId || data.ocSessionId || data.backendSessionId,
|
|
154
|
+
metadata: {
|
|
155
|
+
sdkSessionId: data.sdkSessionId,
|
|
156
|
+
codexThreadId: data.codexThreadId,
|
|
157
|
+
ocSessionId: data.ocSessionId,
|
|
158
|
+
...(data.metadata || {}),
|
|
159
|
+
},
|
|
160
|
+
stats: data.stats || { ...EMPTY_STATS },
|
|
161
|
+
lastUsed: data.lastUsed || Date.now(),
|
|
162
|
+
running: false,
|
|
163
|
+
permissionMode: data.permissionMode,
|
|
164
|
+
codexMode: data.codexMode,
|
|
165
|
+
recentMessages: data.recentMessages || [],
|
|
166
|
+
};
|
|
167
|
+
} catch (e: any) {
|
|
168
|
+
console.error(`[Session] 加载失败 ${chatId}: ${e.message}`);
|
|
169
|
+
session = this._newSession(chatId, userId);
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
session = this._newSession(chatId, userId);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.sessions.set(chatId, session);
|
|
176
|
+
return session;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private _newSession(chatId: string, userId: string): ChatSession {
|
|
180
|
+
const EMPTY_STATS: CallStats = {
|
|
181
|
+
calls: 0, totalTurns: 0, totalInputTokens: 0,
|
|
182
|
+
totalOutputTokens: 0, totalCostUSD: 0, totalDurationMs: 0,
|
|
183
|
+
};
|
|
184
|
+
return {
|
|
185
|
+
chatId, userId, startFresh: false,
|
|
186
|
+
backendSessionId: undefined, metadata: {},
|
|
187
|
+
stats: { ...EMPTY_STATS },
|
|
188
|
+
lastUsed: Date.now(), running: false, recentMessages: [],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
persist(_botName: string, session: Session): void {
|
|
193
|
+
const cs = session as ChatSession;
|
|
194
|
+
const fp = this._sessionPath(session.chatId);
|
|
195
|
+
const dir = path.dirname(fp);
|
|
196
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
197
|
+
|
|
198
|
+
const output: Record<string, any> = {
|
|
199
|
+
chatId: session.chatId,
|
|
200
|
+
userId: session.userId,
|
|
201
|
+
cwd: session.cwd,
|
|
202
|
+
startFresh: session.startFresh,
|
|
203
|
+
stats: session.stats,
|
|
204
|
+
lastUsed: session.lastUsed,
|
|
205
|
+
recentMessages: session.recentMessages || [],
|
|
206
|
+
running: session.running,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
if (session.backendSessionId) output.backendSessionId = session.backendSessionId;
|
|
210
|
+
if (session.metadata) {
|
|
211
|
+
if (session.metadata.sdkSessionId) output.sdkSessionId = session.metadata.sdkSessionId;
|
|
212
|
+
if (session.metadata.codexThreadId) output.codexThreadId = session.metadata.codexThreadId;
|
|
213
|
+
if (session.metadata.ocSessionId) output.ocSessionId = session.metadata.ocSessionId;
|
|
214
|
+
if (session.permissionMode) output.permissionMode = session.permissionMode;
|
|
215
|
+
if (session.codexMode) output.codexMode = session.codexMode;
|
|
216
|
+
}
|
|
217
|
+
output.metadata = session.metadata;
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
fs.writeFileSync(fp, JSON.stringify(output, null, 2));
|
|
221
|
+
} catch (e: any) {
|
|
222
|
+
console.error(`[Session] 持久化失败 ${session.chatId}: ${e.message}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
delete(_botName: string, chatId: string): void {
|
|
227
|
+
this.sessions.delete(chatId);
|
|
228
|
+
try {
|
|
229
|
+
const fp = this._sessionPath(chatId);
|
|
230
|
+
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
cleanupIdle(timeoutMs: number): void {
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
const toRemove: string[] = [];
|
|
237
|
+
for (const [chatId, session] of this.sessions.entries()) {
|
|
238
|
+
if (now - session.lastUsed > timeoutMs && !session.running) {
|
|
239
|
+
toRemove.push(chatId);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const chatId of toRemove) {
|
|
243
|
+
this.sessions.delete(chatId);
|
|
244
|
+
console.log(`[Session] 清理空闲 ${chatId.slice(-8)}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
listActive(): Session[] {
|
|
249
|
+
return Array.from(this.sessions.values());
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ================================================================
|
|
254
|
+
// 工具函数
|
|
255
|
+
// ================================================================
|
|
256
|
+
function levenshteinDistance(a: string, b: string): number {
|
|
257
|
+
const m = Array(b.length + 1).fill(null).map(() => Array(a.length + 1).fill(null));
|
|
258
|
+
for (let i = 0; i <= a.length; i++) m[0][i] = i;
|
|
259
|
+
for (let j = 0; j <= b.length; j++) m[j][0] = j;
|
|
260
|
+
for (let j = 1; j <= b.length; j++)
|
|
261
|
+
for (let i = 1; i <= a.length; i++)
|
|
262
|
+
m[j][i] = Math.min(m[j][i-1]+1, m[j-1][i]+1, m[j-1][i-1]+(a[i-1]===b[j-1]?0:1));
|
|
263
|
+
return m[b.length][a.length];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function findSimilarCommand(input: string, cmds: Map<string, any>): string[] {
|
|
267
|
+
return [...cmds.keys()].filter(c => levenshteinDistance(input, c) <= 2 && levenshteinDistance(input, c) > 0).slice(0, 3);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ================================================================
|
|
271
|
+
// 命令类型
|
|
272
|
+
// ================================================================
|
|
273
|
+
interface CommandCtx {
|
|
274
|
+
chatId: string;
|
|
275
|
+
args: string;
|
|
276
|
+
session: ChatSession | undefined;
|
|
277
|
+
}
|
|
278
|
+
type CommandHandler = (ctx: CommandCtx) => Promise<string> | string;
|
|
279
|
+
|
|
280
|
+
// ================================================================
|
|
281
|
+
// BotConfig
|
|
282
|
+
// ================================================================
|
|
283
|
+
interface BotConfig {
|
|
284
|
+
name: string;
|
|
285
|
+
appId: string;
|
|
286
|
+
appSecret: string;
|
|
287
|
+
backend: 'claude' | 'codex' | 'opencode';
|
|
288
|
+
cwd?: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ================================================================
|
|
292
|
+
// Bot 类 — SDK 完整接入版
|
|
293
|
+
// ================================================================
|
|
294
|
+
class Bot {
|
|
295
|
+
name: string;
|
|
296
|
+
backend: 'claude' | 'codex' | 'opencode';
|
|
297
|
+
appId: string;
|
|
298
|
+
appSecret: string;
|
|
299
|
+
defaultCwd: string;
|
|
300
|
+
activeModel: string;
|
|
301
|
+
modelAliases: ModelAliases;
|
|
302
|
+
modelPresets: Record<string, string>;
|
|
303
|
+
soul: string;
|
|
304
|
+
client: Lark.Client;
|
|
305
|
+
im: IMModule;
|
|
306
|
+
config: any;
|
|
307
|
+
|
|
308
|
+
// SDK
|
|
309
|
+
runtime: AgentRuntime;
|
|
310
|
+
sessionManager: CustomSessionManager;
|
|
311
|
+
sessions: Map<string, ChatSession> = new Map();
|
|
312
|
+
commands: Map<string, CommandHandler> = new Map();
|
|
313
|
+
adapter: AgentAdapter;
|
|
314
|
+
|
|
315
|
+
constructor(cfg: BotConfig, globalConfig: any) {
|
|
316
|
+
this.name = cfg.name;
|
|
317
|
+
this.backend = cfg.backend;
|
|
318
|
+
this.appId = cfg.appId;
|
|
319
|
+
this.appSecret = cfg.appSecret;
|
|
320
|
+
this.defaultCwd = cfg.cwd || globalConfig.system?.defaultProjectDir || '/Users/keyi/Projects';
|
|
321
|
+
this.config = globalConfig;
|
|
322
|
+
|
|
323
|
+
// Bot 级模型配置
|
|
324
|
+
const botCfg = this._loadBotConfig();
|
|
325
|
+
this.activeModel = botCfg.activeModel || globalConfig.defaultModel || 'deepseek/deepseek-v4-pro';
|
|
326
|
+
this.modelAliases = botCfg.modelAliases || globalConfig.modelAliases || {};
|
|
327
|
+
this.modelPresets = botCfg.modelPresets || {
|
|
328
|
+
fast: 'deepseek/deepseek-v4-flash',
|
|
329
|
+
pro: 'deepseek/deepseek-v4-pro',
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// 灵魂
|
|
333
|
+
this._initSoul();
|
|
334
|
+
this.soul = this._loadSoul();
|
|
335
|
+
|
|
336
|
+
// IM 适配器工厂
|
|
337
|
+
const imType = cfg.im || 'feishu';
|
|
338
|
+
|
|
339
|
+
// Lark.Client 仅飞书需要
|
|
340
|
+
if (imType === 'feishu') {
|
|
341
|
+
this.client = new Lark.Client({
|
|
342
|
+
appId: this.appId,
|
|
343
|
+
appSecret: this.appSecret,
|
|
344
|
+
loggerLevel: Lark.LoggerLevel.info,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const imFactory = IM_REGISTRY.get(imType);
|
|
349
|
+
if (!imFactory) {
|
|
350
|
+
const known = [...IM_REGISTRY.keys()].join(', ');
|
|
351
|
+
throw new Error(`不支持的 IM 类型: ${imType}(已注册: ${known})`);
|
|
352
|
+
}
|
|
353
|
+
this.im = imFactory.create(cfg);
|
|
354
|
+
|
|
355
|
+
// ===== SDK 集成 =====
|
|
356
|
+
this.sessionManager = new CustomSessionManager(this.name, this.sessions);
|
|
357
|
+
|
|
358
|
+
const adapterCtx = {
|
|
359
|
+
imModule: this.im,
|
|
360
|
+
botName: this.name,
|
|
361
|
+
modelAliases: this.modelAliases,
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
if (this.backend === 'claude') {
|
|
365
|
+
this.adapter = new ClaudeAdapter(adapterCtx);
|
|
366
|
+
} else if (this.backend === 'codex') {
|
|
367
|
+
this.adapter = new CodexAdapter(adapterCtx);
|
|
368
|
+
} else {
|
|
369
|
+
const ocCfg = globalConfig.opencode || {};
|
|
370
|
+
this.adapter = new OpenCodeAdapter({
|
|
371
|
+
...adapterCtx,
|
|
372
|
+
serverUrl: ocCfg.serverUrl,
|
|
373
|
+
defaultModel: ocCfg.defaultModel,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
this.runtime = new AgentRuntime({
|
|
378
|
+
sessionManager: this.sessionManager,
|
|
379
|
+
errorHandler: new DefaultErrorHandler(),
|
|
380
|
+
configManager: undefined as any,
|
|
381
|
+
statsTracker: new DefaultStatsTracker(),
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
this.runtime.registerAdapter(this.backend, this.adapter);
|
|
385
|
+
|
|
386
|
+
// 注册命令
|
|
387
|
+
this._registerCommands();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ===== 灵魂管理 =====
|
|
391
|
+
_soulDir() { return getSoulDir(this.name); }
|
|
392
|
+
|
|
393
|
+
_initSoul() {
|
|
394
|
+
const dir = this._soulDir();
|
|
395
|
+
try {
|
|
396
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
397
|
+
const hasFiles = fs.readdirSync(dir).some((f: string) => f.endsWith('.md'));
|
|
398
|
+
if (hasFiles) return;
|
|
399
|
+
const defaults: Record<string, string> = {
|
|
400
|
+
'rules.md': '# 硬约束规则\n\n以下规则不可被覆盖或修改:\n\n- 项目密钥、token、密码等敏感信息不可外泄\n- 不可执行破坏性命令',
|
|
401
|
+
'identity.md': `# 身份定义\n\n- 我是通过 IMtoAgent 连接的 AI 编程助手\n- 我运行在 ${this.backend === 'codex' ? 'Codex' : 'Claude Code'} 后端\n- 用中文回复`,
|
|
402
|
+
'profile.md': '# 用户画像\n\n此文件可由 Agent 修改。当用户说"记住xxx"、"我偏好xxx"时,Agent 应更新此文件。\n\n## 修改指南(Agent 专用)\n\n读取此文件 → 根据用户要求增/删/改条目 → 保存',
|
|
403
|
+
'workspace.md': '# 项目环境\n\n由 IMtoAgent 自动生成。',
|
|
404
|
+
'skills.md': '# 技能注入\n\n未来功能。',
|
|
405
|
+
};
|
|
406
|
+
for (const [name, content] of Object.entries(defaults)) {
|
|
407
|
+
fs.writeFileSync(dir + '/' + name, content);
|
|
408
|
+
}
|
|
409
|
+
console.log(`[${this.name}] 灵魂文件已初始化: ${dir}`);
|
|
410
|
+
} catch (e: any) {
|
|
411
|
+
console.error(`[${this.name}] 初始化灵魂失败: ${e.message}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
_loadSoul(): string {
|
|
416
|
+
const order = ['rules.md', 'identity.md', 'profile.md', 'workspace.md', 'skills.md'];
|
|
417
|
+
const parts: string[] = [];
|
|
418
|
+
try {
|
|
419
|
+
const dir = this._soulDir();
|
|
420
|
+
if (!fs.existsSync(dir)) return '';
|
|
421
|
+
for (const file of order) {
|
|
422
|
+
const fp = dir + '/' + file;
|
|
423
|
+
if (fs.existsSync(fp)) {
|
|
424
|
+
const content = fs.readFileSync(fp, 'utf-8').trim();
|
|
425
|
+
if (content) parts.push(content);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
} catch {}
|
|
429
|
+
return parts.join('\n\n');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
_soulFiles(): string[] {
|
|
433
|
+
try {
|
|
434
|
+
const dir = this._soulDir();
|
|
435
|
+
if (!fs.existsSync(dir)) return [];
|
|
436
|
+
return fs.readdirSync(dir).filter((f: string) => f.endsWith('.md')).sort();
|
|
437
|
+
} catch { return []; }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ===== Bot 配置 =====
|
|
441
|
+
_botConfigPath() { return path.join(getSessionsDir(), this.name, '_bot.json'); }
|
|
442
|
+
|
|
443
|
+
_loadBotConfig() {
|
|
444
|
+
try {
|
|
445
|
+
const p = this._botConfigPath();
|
|
446
|
+
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
447
|
+
} catch {}
|
|
448
|
+
return {};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
_saveBotConfig() {
|
|
452
|
+
try {
|
|
453
|
+
const dir = path.dirname(this._botConfigPath());
|
|
454
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
455
|
+
fs.writeFileSync(this._botConfigPath(), JSON.stringify({
|
|
456
|
+
activeModel: this.activeModel,
|
|
457
|
+
modelAliases: this.modelAliases,
|
|
458
|
+
modelPresets: this.modelPresets,
|
|
459
|
+
}, null, 2));
|
|
460
|
+
} catch (e: any) {
|
|
461
|
+
console.error(`[${this.name}] 保存配置失败:`, e.message);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ===== 命令注册 =====
|
|
466
|
+
_registerCommands() {
|
|
467
|
+
const cmd = (name: string, handler: CommandHandler) => this.commands.set(name, handler);
|
|
468
|
+
|
|
469
|
+
cmd('/help', () => {
|
|
470
|
+
let out = '📋 **CC 快捷命令**\n\n';
|
|
471
|
+
out += '/status — 状态\n/info — 配置\n/stats — 统计\n';
|
|
472
|
+
out += '/model — 模型切换\n/providers — 供应商\n';
|
|
473
|
+
out += '/dir — 目录\n/clear — 清空\n';
|
|
474
|
+
if (this.backend === 'claude') out += '/mode — 权限\n';
|
|
475
|
+
else if (this.backend === 'codex') out += '/mode — 模式(auto/plan)\n';
|
|
476
|
+
out += '/memory — 概览\n/soul — 灵魂\n/reload — 重载';
|
|
477
|
+
return out;
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
cmd('/status', ({ session }) =>
|
|
481
|
+
session?.running
|
|
482
|
+
? `✅ ${this.backend} 运行中 | ${this.activeModel} | ${session.stats.calls} 次调用`
|
|
483
|
+
: `⏸ ${this.backend} 空闲 | ${this.activeModel}`);
|
|
484
|
+
|
|
485
|
+
cmd('/info', ({ session }) =>
|
|
486
|
+
`🤖 ${this.name} (${this.backend})\n模型: ${this.activeModel}\n目录: ${session?.cwd || this.defaultCwd}\n会话数: ${this.sessions.size}`);
|
|
487
|
+
|
|
488
|
+
cmd('/stats', ({ session }) => {
|
|
489
|
+
if (!session || session.stats.calls === 0) return '📊 暂无调用';
|
|
490
|
+
const s = session.stats;
|
|
491
|
+
return `📊 调用 ${s.calls} 次 | ${s.totalTurns} 轮\nToken: ${s.totalInputTokens.toLocaleString()}入 + ${s.totalOutputTokens.toLocaleString()}出\n费用: $${s.totalCostUSD.toFixed(4)} | 耗时 ${(s.totalDurationMs/1000).toFixed(0)}s`;
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
cmd('/clear', ({ session }) => {
|
|
495
|
+
if (session) {
|
|
496
|
+
session.startFresh = true;
|
|
497
|
+
return '🗑 已清空对话(下次消息将开启全新会话)';
|
|
498
|
+
}
|
|
499
|
+
return '✅ 无活跃对话';
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
cmd('/model', ({ args }) => {
|
|
503
|
+
const raw = args.trim();
|
|
504
|
+
if (!raw) {
|
|
505
|
+
let out = `🤖 当前: ${this.activeModel}`;
|
|
506
|
+
if ((this.backend === 'claude' || this.backend === 'opencode') && this.modelAliases) {
|
|
507
|
+
if (this.backend === 'claude') {
|
|
508
|
+
out += '\n\n🎭 角色映射 (Claude 内部角色 → 模型):';
|
|
509
|
+
for (const role of ['default', 'sonnet', 'opus', 'haiku', 'best']) {
|
|
510
|
+
const spec = this.modelAliases[role as keyof ModelAliases];
|
|
511
|
+
if (spec) out += `\n• ${role} → ${spec}`;
|
|
512
|
+
}
|
|
513
|
+
out += '\n💡 /model sonnet 供应商/模型 可修改角色映射';
|
|
514
|
+
} else if (this.backend === 'opencode') {
|
|
515
|
+
out += '\n\n🎭 角色映射:';
|
|
516
|
+
const spec = (this.modelAliases as any).opencode;
|
|
517
|
+
if (spec) out += `\n• opencode → ${spec}`;
|
|
518
|
+
out += '\n💡 /model opencode 供应商/模型 可修改映射';
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const presets = Object.entries(this.modelPresets || {});
|
|
522
|
+
if (presets.length > 0) {
|
|
523
|
+
out += '\n\n⚡ 快捷切换:';
|
|
524
|
+
for (const [alias, spec] of presets) {
|
|
525
|
+
const mark = spec === this.activeModel ? ' ✅' : '';
|
|
526
|
+
out += `\n• /model ${alias} → ${spec}${mark}`;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
out += '\n\n📋 /model add <别名> <模型> — 添加预设';
|
|
530
|
+
out += '\n🗑 /model del <别名> — 删除预设';
|
|
531
|
+
out += '\n🔀 /model 供应商/模型 — 直接切换';
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (raw.startsWith('add ')) {
|
|
536
|
+
const rest = raw.slice(4).trim();
|
|
537
|
+
const space = rest.indexOf(' ');
|
|
538
|
+
if (space < 0) return '❌ 用法: /model add <别名> <供应商/模型>';
|
|
539
|
+
const alias = rest.slice(0, space).trim();
|
|
540
|
+
const spec = rest.slice(space + 1).trim();
|
|
541
|
+
if (!this.modelPresets) this.modelPresets = {};
|
|
542
|
+
this.modelPresets[alias] = spec;
|
|
543
|
+
this._saveBotConfig();
|
|
544
|
+
return `✅ 预设已添加: ${alias} → ${spec}`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (raw.startsWith('del ')) {
|
|
548
|
+
const alias = raw.slice(4).trim();
|
|
549
|
+
if (!this.modelPresets || !this.modelPresets[alias]) return `❌ 未找到预设: ${alias}`;
|
|
550
|
+
delete this.modelPresets[alias];
|
|
551
|
+
this._saveBotConfig();
|
|
552
|
+
return `🗑 已删除预设: ${alias}`;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// 角色别名
|
|
556
|
+
if ((this.backend === 'claude' && ['default', 'sonnet', 'opus', 'haiku', 'best'].includes(raw)) ||
|
|
557
|
+
(this.backend === 'opencode' && raw === 'opencode')) {
|
|
558
|
+
const spec = (this.modelAliases as any)[raw] || this.modelAliases[raw as keyof typeof this.modelAliases];
|
|
559
|
+
if (spec) return `🎭 ${raw} → ${spec}\n💡 修改: /model ${raw} 供应商/模型名`;
|
|
560
|
+
return `❌ 未设置角色: ${raw}`;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// 预设
|
|
564
|
+
if (this.modelPresets && this.modelPresets[raw]) {
|
|
565
|
+
const spec = this.modelPresets[raw];
|
|
566
|
+
const cfg = getProviderConfig(spec);
|
|
567
|
+
if (!cfg) return `❌ 预设目标无效: ${spec}`;
|
|
568
|
+
this.activeModel = spec;
|
|
569
|
+
this.modelAliases.default = spec;
|
|
570
|
+
this._saveBotConfig();
|
|
571
|
+
return `🤖 已切换: ${spec} (${raw})`;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// 角色映射修改 (Claude/OpenCode)
|
|
575
|
+
if (this.backend === 'claude' || this.backend === 'opencode') {
|
|
576
|
+
const space = raw.indexOf(' ');
|
|
577
|
+
if (space > 0) {
|
|
578
|
+
const role = raw.slice(0, space).trim();
|
|
579
|
+
const spec = raw.slice(space + 1).trim();
|
|
580
|
+
const validRoles = this.backend === 'opencode' ? ['opencode'] : ['default', 'sonnet', 'opus', 'haiku', 'best'];
|
|
581
|
+
if (validRoles.includes(role)) {
|
|
582
|
+
const cfg = getProviderConfig(spec);
|
|
583
|
+
if (!cfg) return `❌ 未知模型: ${spec}`;
|
|
584
|
+
(this.modelAliases as any)[role] = spec;
|
|
585
|
+
if (role === 'default') this.activeModel = spec;
|
|
586
|
+
this._saveBotConfig();
|
|
587
|
+
return `🎭 ${role} → ${spec} (已更新)`;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// 直接切换
|
|
593
|
+
const cfg = getProviderConfig(raw);
|
|
594
|
+
if (!cfg) return `❌ 未知模型: ${raw}\n💡 用 /model 查看预设`;
|
|
595
|
+
this.activeModel = raw;
|
|
596
|
+
this.modelAliases.default = raw;
|
|
597
|
+
this._saveBotConfig();
|
|
598
|
+
return `🤖 已切换: ${raw}`;
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
cmd('/providers', () => {
|
|
602
|
+
const providers = loadProviders();
|
|
603
|
+
const list = Object.entries(providers).map(([name, p]: [string, any]) =>
|
|
604
|
+
`• **${name}**: ${(p.models || []).join(', ')}`
|
|
605
|
+
).join('\n');
|
|
606
|
+
return `📡 **可用供应商**\n\n${list}\n\n当前: ${this.activeModel}`;
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
cmd('/dir', ({ args, session }) => {
|
|
610
|
+
const dir = args.trim();
|
|
611
|
+
if (!dir) return `📁 ${session?.cwd || this.defaultCwd}`;
|
|
612
|
+
if (session) session.cwd = dir;
|
|
613
|
+
return `📁 已切换: ${dir}`;
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
cmd('/mode', ({ args, session }) => {
|
|
617
|
+
const mode = args.trim();
|
|
618
|
+
if (this.backend === 'claude') {
|
|
619
|
+
if (!mode) return `🔐 当前权限: ${session?.permissionMode || 'bypassPermissions'}\n可选: bypassPermissions | default | plan`;
|
|
620
|
+
if (!['bypassPermissions', 'default', 'plan'].includes(mode))
|
|
621
|
+
return `❌ 无效: ${mode}\n可选: bypassPermissions | default | plan`;
|
|
622
|
+
if (session) { session.permissionMode = mode; return `🔐 已切换: ${mode}`; }
|
|
623
|
+
return '❌ 无活跃会话';
|
|
624
|
+
}
|
|
625
|
+
if (!mode) {
|
|
626
|
+
const current = session?.codexMode || 'auto';
|
|
627
|
+
return `🔧 当前模式: ${current}\n可选: auto (直接执行) | plan (先计划后执行)`;
|
|
628
|
+
}
|
|
629
|
+
if (!['auto', 'plan'].includes(mode))
|
|
630
|
+
return `❌ 无效: ${mode}\n可选: auto | plan`;
|
|
631
|
+
if (session) { session.codexMode = mode; return `🔧 已切换: ${mode}`; }
|
|
632
|
+
return '❌ 无活跃会话';
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
cmd('/memory', ({ session }) => {
|
|
636
|
+
if (!session) return '📦 无活跃会话';
|
|
637
|
+
const s = session.stats;
|
|
638
|
+
return `🧠 ${this.name} (${this.backend})\nstartFresh: ${session.startFresh || false}\nsdkSession: ${session.metadata?.sdkSessionId?.slice(-8) || session.backendSessionId?.slice(-8) || '无'}\n调用: ${s.calls} | 轮数: ${s.totalTurns}\nToken: ${s.totalInputTokens.toLocaleString()}入 + ${s.totalOutputTokens.toLocaleString()}出\n费用: $${s.totalCostUSD.toFixed(4)}`;
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
cmd('/soul', ({ args }) => {
|
|
642
|
+
if (args.trim() === 'reload') {
|
|
643
|
+
this._initSoul();
|
|
644
|
+
this.soul = this._loadSoul();
|
|
645
|
+
return `🧠 灵魂已重载 (${this.soul.length} 字符)`;
|
|
646
|
+
}
|
|
647
|
+
const files = this._soulFiles();
|
|
648
|
+
if (files.length === 0) return `🧠 未配置灵魂\n💡 创建 ${this._soulDir()}/ 目录下的 .md 文件`;
|
|
649
|
+
let out = `🧠 灵魂文件 (${this.soul.length} 字符):\n`;
|
|
650
|
+
for (const f of files) {
|
|
651
|
+
const fp = this._soulDir() + '/' + f;
|
|
652
|
+
try {
|
|
653
|
+
const s = fs.statSync(fp);
|
|
654
|
+
const tag = f === 'rules.md' ? ' 🔒' : f === 'profile.md' ? ' ✏️' : '';
|
|
655
|
+
out += `\n• ${f} (${s.size}B)${tag}`;
|
|
656
|
+
} catch { out += `\n• ${f}`; }
|
|
657
|
+
}
|
|
658
|
+
out += '\n\n💡 /soul reload — 重新加载';
|
|
659
|
+
out += '\n🔒 rules=只读 | ✏️ profile=Agent可改';
|
|
660
|
+
return out;
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
cmd('/reload', async () => {
|
|
664
|
+
await gracefulReload('/reload');
|
|
665
|
+
return '🔄 热重载中...';
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async tryHandleCommand(chatId: string, text: string, session: ChatSession | undefined): Promise<string | null> {
|
|
670
|
+
if (!text.startsWith('/')) return null;
|
|
671
|
+
const space = text.indexOf(' ');
|
|
672
|
+
const cmdName = space >= 0 ? text.slice(0, space).toLowerCase() : text.toLowerCase();
|
|
673
|
+
const args = space >= 0 ? text.slice(space + 1) : '';
|
|
674
|
+
const handler = this.commands.get(cmdName);
|
|
675
|
+
if (handler) return handler({ chatId, args, session });
|
|
676
|
+
const similar = findSimilarCommand(cmdName, this.commands);
|
|
677
|
+
if (similar.length > 0)
|
|
678
|
+
return `❌ 未知命令: ${cmdName}\n💡 ${similar.map(s => `\`${s}\``).join('、')}?\n输入 /help 查看`;
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ===== 消息处理 — SDK 完整接入 =====
|
|
683
|
+
async handleMessage(chatId: string, text: string, userId: string, attachments?: MessageAttachment[]) {
|
|
684
|
+
activeRequests++;
|
|
685
|
+
try {
|
|
686
|
+
// 限流
|
|
687
|
+
const rlResult = checkRateLimit(chatId);
|
|
688
|
+
if (!rlResult.allowed) {
|
|
689
|
+
await this.reply(chatId, `⚠️ 请求过于频繁,请等待 ${rlResult.retryAfter} 秒后再试`);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// 获取/创建会话
|
|
694
|
+
const session = await this.sessionManager.getOrCreate(chatId, userId);
|
|
695
|
+
session.lastUsed = Date.now();
|
|
696
|
+
|
|
697
|
+
// 命令处理
|
|
698
|
+
const cmdResp = await this.tryHandleCommand(chatId, text, session);
|
|
699
|
+
if (cmdResp !== null) {
|
|
700
|
+
await this.reply(chatId, cmdResp);
|
|
701
|
+
this.sessionManager.persist(this.name, session);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// 最近消息
|
|
706
|
+
session.recentMessages.push(text);
|
|
707
|
+
if (session.recentMessages.length > 5) session.recentMessages = session.recentMessages.slice(-5);
|
|
708
|
+
|
|
709
|
+
// 构建系统提示词
|
|
710
|
+
const systemPrompt = this.soul ? buildSystemPromptWithSoul(this.soul, this.name, this.im) : undefined;
|
|
711
|
+
|
|
712
|
+
// SDK Runtime 处理
|
|
713
|
+
const result = await this.runtime.processMessage({
|
|
714
|
+
chatId, text, userId, attachments,
|
|
715
|
+
workingDir: session.cwd || this.defaultCwd,
|
|
716
|
+
model: this.activeModel,
|
|
717
|
+
systemPrompt,
|
|
718
|
+
reply: async (t: string) => this.reply(chatId, t),
|
|
719
|
+
sendProgress: async (t: string) => this.sendProgress(chatId, t),
|
|
720
|
+
sendBlocks: async (blocks) => this.sendFormattedReplyDirect(chatId, blocks),
|
|
721
|
+
imCaps: this.im.getCapabilities(),
|
|
722
|
+
}, this.adapter, this.name);
|
|
723
|
+
|
|
724
|
+
// Agent 自主重启信号检测
|
|
725
|
+
if (result?.restart) {
|
|
726
|
+
setTimeout(async () => {
|
|
727
|
+
await gracefulReload(`Agent 请求重启: ${result.reason}`);
|
|
728
|
+
}, 200);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
} catch (e: any) {
|
|
732
|
+
console.error(`[${this.name}] handleMessage 异常: ${e.message}`);
|
|
733
|
+
await this.reply(chatId, `❌ ${e.message}`);
|
|
734
|
+
} finally {
|
|
735
|
+
activeRequests--;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async reply(chatId: string, text: string) {
|
|
740
|
+
const maxLen = this.config.system?.maxReplyLength || 140000;
|
|
741
|
+
await this.im.reply(chatId, text, maxLen);
|
|
742
|
+
console.log(`[${this.name}] 回复 chat=${chatId.slice(-8)} len=${Math.min(text.length, maxLen)}`);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async sendProgress(chatId: string, text: string) {
|
|
746
|
+
await this.im.sendProgress(chatId, text);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async sendFormattedReplyDirect(chatId: string, blocks: any[]) {
|
|
750
|
+
if (this.im?.sendBlocks) {
|
|
751
|
+
await this.im.sendBlocks(chatId, blocks);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ===== 系统提示词构建(不依赖 prompt-builder 的旧接口) =====
|
|
757
|
+
import { buildSystemPrompt } from './modules/prompt-builder';
|
|
758
|
+
|
|
759
|
+
function buildSystemPromptWithSoul(soul: string, botName: string, imModule: IMModule | null): string {
|
|
760
|
+
const base = buildSystemPrompt({ imModule, botName });
|
|
761
|
+
|
|
762
|
+
// 注入 Agent 自主重启能力说明(信号文件路径固定)
|
|
763
|
+
const restartInstruction = `\n\n## 网关重启能力\n\n如果你判断需要重启 IMtoAgent 网关(如配置变更、检测到异常状态需重置),请执行以下命令:\n\n\`\`\`bash\necho '{"reason": "<简短原因>", "timestamp": '"$(date +%s)"'}' > ${process.env.HOME}/.imtoagent/.restart_requested\n\`\`\`\n\n规则:\n- 该信号文件会被 Runtime 自动检测并消费,用户不会看到\n- 你的回复内容会先正常发送给用户,然后网关执行重启\n- 仅在确实需要时使用,不要随意触发\n- 如果你不需要重启,忽略此指令即可`;
|
|
764
|
+
|
|
765
|
+
let combined = `${base}${restartInstruction}`;
|
|
766
|
+
if (soul) {
|
|
767
|
+
combined += `\n\n---\n\n# 用户自定义指令 (IMtoAgent Soul)\n\n${soul}`;
|
|
768
|
+
}
|
|
769
|
+
return combined;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// ================================================================
|
|
773
|
+
// 空闲清理
|
|
774
|
+
// ================================================================
|
|
775
|
+
function cleanupIdleSessions(bots: Bot[]) {
|
|
776
|
+
const IDLE = 30 * 60 * 1000;
|
|
777
|
+
for (const bot of bots) {
|
|
778
|
+
bot.sessionManager.cleanupIdle(IDLE);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// ================================================================
|
|
783
|
+
// 全局引用
|
|
784
|
+
// ================================================================
|
|
785
|
+
let _allBots: Bot[] = [];
|
|
786
|
+
|
|
787
|
+
// ================================================================
|
|
788
|
+
// 热重载
|
|
789
|
+
// ================================================================
|
|
790
|
+
async function gracefulReload(reason: string) {
|
|
791
|
+
console.log(`[Reload] 🔄 ${reason}`);
|
|
792
|
+
|
|
793
|
+
// 1. 保存 session 快照(用于重启后通知)
|
|
794
|
+
const sessionsDir = getSessionsDir();
|
|
795
|
+
const botSnapshots: Record<string, { chats: { chatId: string; lastUsed: number }[] }> = {};
|
|
796
|
+
try {
|
|
797
|
+
if (fs.existsSync(sessionsDir)) {
|
|
798
|
+
for (const botDir of fs.readdirSync(sessionsDir)) {
|
|
799
|
+
const botPath = sessionsDir + '/' + botDir;
|
|
800
|
+
if (!fs.statSync(botPath).isDirectory()) continue;
|
|
801
|
+
const chats: { chatId: string; lastUsed: number }[] = [];
|
|
802
|
+
for (const f of fs.readdirSync(botPath)) {
|
|
803
|
+
if (!f.endsWith('.memory.json')) continue;
|
|
804
|
+
try {
|
|
805
|
+
const m = JSON.parse(fs.readFileSync(botPath + '/' + f, 'utf-8'));
|
|
806
|
+
chats.push({ chatId: m.chatId, lastUsed: m.lastUsed || 0 });
|
|
807
|
+
} catch {}
|
|
808
|
+
}
|
|
809
|
+
chats.sort((a, b) => b.lastUsed - a.lastUsed);
|
|
810
|
+
botSnapshots[botDir] = { chats: chats.slice(0, 3) };
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
} catch {}
|
|
814
|
+
|
|
815
|
+
// 2. 写 restore marker(新进程启动后读取并通知用户)
|
|
816
|
+
const marker = getRestoreMarkerPath();
|
|
817
|
+
try { fs.writeFileSync(marker, JSON.stringify({ timestamp: Date.now(), reason, bots: botSnapshots })); } catch {}
|
|
818
|
+
|
|
819
|
+
// 3. 优雅清理
|
|
820
|
+
await stopAnthropicProxy();
|
|
821
|
+
await stopOpenCodeServer();
|
|
822
|
+
for (const bot of _allBots) bot.im.stop();
|
|
823
|
+
await new Promise(r => setTimeout(r, 500));
|
|
824
|
+
|
|
825
|
+
// 4. 退出,daemon.sh 会自动拉起新进程
|
|
826
|
+
console.log('[Reload] 清理完成,退出中...');
|
|
827
|
+
process.exit(0);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
process.on('SIGHUP', () => gracefulReload('SIGHUP'));
|
|
831
|
+
|
|
832
|
+
// ================================================================
|
|
833
|
+
// 主入口
|
|
834
|
+
// ================================================================
|
|
835
|
+
async function main() {
|
|
836
|
+
const CONFIG_PATH = path.join(getDataDir(), 'config.json');
|
|
837
|
+
|
|
838
|
+
// 首次部署:配置文件不存在或未初始化
|
|
839
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
840
|
+
console.log('');
|
|
841
|
+
console.log('⚠️ 首次部署:请先配置 imtoagent');
|
|
842
|
+
console.log('');
|
|
843
|
+
console.log(` 配置文件: ${CONFIG_PATH}`);
|
|
844
|
+
console.log('');
|
|
845
|
+
console.log(' 1. 编辑 config.json,填入你的 API 凭证');
|
|
846
|
+
console.log(' 2. 重新运行 imtoagent');
|
|
847
|
+
console.log('');
|
|
848
|
+
console.log(' 参考模板: templates/config.template.json');
|
|
849
|
+
console.log('');
|
|
850
|
+
process.exit(0);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
854
|
+
const config = JSON.parse(raw);
|
|
855
|
+
|
|
856
|
+
// 检测是否是未编辑的模板(凭证还是占位符)
|
|
857
|
+
const hasPlaceholder = Object.values(config.providers || {}).some((p: any) =>
|
|
858
|
+
p.apiKey?.startsWith('YOUR_') || !p.apiKey
|
|
859
|
+
);
|
|
860
|
+
if (hasPlaceholder) {
|
|
861
|
+
console.log('');
|
|
862
|
+
console.log('⚠️ 配置未完成:请将 config.json 中的 YOUR_* 替换为真实的 API 凭证');
|
|
863
|
+
console.log(` 配置文件: ${CONFIG_PATH}`);
|
|
864
|
+
console.log('');
|
|
865
|
+
process.exit(0);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const DEFAULT_PROJECT_DIR = config.system?.defaultProjectDir || '/Users/keyi/Projects';
|
|
869
|
+
|
|
870
|
+
if (config.modelAliases) sharedState.modelAliases = config.modelAliases;
|
|
871
|
+
const { providers: _providers, defaultModel: DEFAULT_MODEL_SPEC } = loadProviders();
|
|
872
|
+
const defaultCfg = getProviderConfig(DEFAULT_MODEL_SPEC);
|
|
873
|
+
if (defaultCfg) sharedState.activeConfig = defaultCfg;
|
|
874
|
+
|
|
875
|
+
process.env.ANTHROPIC_BASE_URL = 'http://localhost:18899';
|
|
876
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
877
|
+
delete process.env.ANTHROPIC_AUTH_TOKEN;
|
|
878
|
+
delete process.env.ANTHROPIC_MODEL;
|
|
879
|
+
|
|
880
|
+
// 清理残留的重启信号(上次崩溃遗留的旧信号,超过 1 分钟视为残留)
|
|
881
|
+
try {
|
|
882
|
+
if (fs.existsSync(RESTART_SIGNAL_PATH)) {
|
|
883
|
+
const old = JSON.parse(fs.readFileSync(RESTART_SIGNAL_PATH, 'utf-8'));
|
|
884
|
+
const age = Date.now() - (old.timestamp || 0);
|
|
885
|
+
if (age > 60000) {
|
|
886
|
+
console.log(`[Startup] 清理残留重启信号: ${old.reason}(${Math.floor(age / 1000)}s 前)`);
|
|
887
|
+
fs.unlinkSync(RESTART_SIGNAL_PATH);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
} catch {}
|
|
891
|
+
|
|
892
|
+
let proxyPort = 0;
|
|
893
|
+
try { proxyPort = await startAnthropicProxy(18899); } catch (e: any) {
|
|
894
|
+
console.error(`❌ Anthropic Proxy :18899 启动失败: ${e.message}`);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
try {
|
|
898
|
+
const codexCfg = config.codex || {};
|
|
899
|
+
let apiKey = '';
|
|
900
|
+
for (const name of Object.keys(config.providers || {})) {
|
|
901
|
+
apiKey = config.providers[name].apiKey || '';
|
|
902
|
+
if (apiKey) break;
|
|
903
|
+
}
|
|
904
|
+
initCodexProxyConfig({
|
|
905
|
+
model: codexCfg.model || 'deepseek-v4-pro',
|
|
906
|
+
reportedModel: codexCfg.reportedModel || 'gpt-5.5',
|
|
907
|
+
upstream: codexCfg.upstream || 'https://api.deepseek.com/v1/chat/completions',
|
|
908
|
+
apiKey,
|
|
909
|
+
});
|
|
910
|
+
const ocCfg = config.opencode || {};
|
|
911
|
+
initOpenCodeConfig({
|
|
912
|
+
serverUrl: ocCfg.serverUrl || 'http://localhost:4096',
|
|
913
|
+
defaultModel: ocCfg.defaultModel || { providerID: 'anthropic', modelID: 'claude-sonnet-4-5' },
|
|
914
|
+
});
|
|
915
|
+
const rlCfg = config.rateLimit || {};
|
|
916
|
+
if (rlCfg.enabled !== false) {
|
|
917
|
+
setRateLimitConfig({
|
|
918
|
+
maxRequests: rlCfg.maxRequests || 30,
|
|
919
|
+
windowMs: rlCfg.windowMs || 60000,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
} catch (e: any) {
|
|
923
|
+
console.error(`[Config] 初始化子模块配置失败: ${e.message}`);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
// 自动启动 OpenCode 服务(如果配置了 OpenCode bot)
|
|
928
|
+
const hasOpenCodeBot = (config.bots || []).some((b: any) => b.backend === 'opencode');
|
|
929
|
+
if (hasOpenCodeBot) {
|
|
930
|
+
try {
|
|
931
|
+
await startOpenCodeServer();
|
|
932
|
+
} catch (e: any) {
|
|
933
|
+
console.error(`[OpenCode] 启动失败: ${e.message}`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (!proxyPort) {
|
|
938
|
+
console.error('❌ 所有 Proxy 启动失败,无法继续');
|
|
939
|
+
process.exit(1);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const botCfgs: any[] = config.bots || [];
|
|
943
|
+
if (botCfgs.length === 0) {
|
|
944
|
+
console.log('💡 config.json 中未配置 bots,仅启动代理');
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const bots: Bot[] = [];
|
|
949
|
+
for (const c of botCfgs) {
|
|
950
|
+
const appId = c.appId || c.feishu?.appId || '';
|
|
951
|
+
const appSecret = c.appSecret || c.feishu?.appSecret || '';
|
|
952
|
+
const imType = c.im || 'feishu';
|
|
953
|
+
|
|
954
|
+
// wechat 不需要 appId/appSecret,首次启动会触发 QR 扫码绑定
|
|
955
|
+
if (imType === 'wechat') {
|
|
956
|
+
bots.push(new Bot({ ...c, appId: appId || 'wechat-bot', appSecret }, config));
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Telegram/其他非飞书 IM 只需要 appId,不需要 appSecret
|
|
961
|
+
const needsSecret = imType === 'feishu';
|
|
962
|
+
if (!appId || (needsSecret && !appSecret) || appId.startsWith('YOUR_') || appSecret.startsWith('YOUR_')) {
|
|
963
|
+
console.log(`[Config] ⚠️ Bot "${c.name}" 凭证为占位符,跳过`);
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
bots.push(new Bot({ ...c, appId, appSecret }, config));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (bots.length === 0) {
|
|
970
|
+
console.log('⚠️ 没有配置有效凭证的 Bot,仅启动代理');
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
_allBots = bots;
|
|
975
|
+
console.log(`\n🚀 CC 路由 v4 — 多 Bot 架构 (SDK 完整接入)`);
|
|
976
|
+
console.log(` Anthropic: http://localhost:${proxyPort}`);
|
|
977
|
+
console.log(` Bots:`);
|
|
978
|
+
|
|
979
|
+
for (const bot of bots) {
|
|
980
|
+
bot.im.start(async (chatId, text, userId, attachments) => {
|
|
981
|
+
const attDesc = attachments?.length
|
|
982
|
+
? ` +${attachments.length} attachments(${attachments.map(a => a.type).join(',')})`
|
|
983
|
+
: '';
|
|
984
|
+
console.log(`[${bot.name}] 收到 chat=${chatId.slice(-8)} "${text.slice(0, 80)}"${attDesc}`);
|
|
985
|
+
setCurrentBot({ botName: bot.name, caps: bot.im.getCapabilities(), modelAliases: bot.modelAliases });
|
|
986
|
+
bot.handleMessage(chatId, text, userId, attachments).catch((e: Error) =>
|
|
987
|
+
console.error(`[${bot.name}] handleMessage unhandled:`, e.message)
|
|
988
|
+
);
|
|
989
|
+
});
|
|
990
|
+
console.log(` - ${bot.name}: ${bot.backend} ✅ (appId=${bot.appId.slice(-8)}…) [SDK]`);
|
|
991
|
+
}
|
|
992
|
+
console.log('');
|
|
993
|
+
|
|
994
|
+
// 自动生成 workspace.md
|
|
995
|
+
const updateWorkspace = (bot: Bot) => {
|
|
996
|
+
try {
|
|
997
|
+
const dir = bot._soulDir();
|
|
998
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
999
|
+
const cwd = bot.defaultCwd;
|
|
1000
|
+
let gitBranch = '', gitStatus = '';
|
|
1001
|
+
try {
|
|
1002
|
+
gitBranch = require('child_process').execSync('git branch --show-current', { cwd, timeout: 3000 }).toString().trim();
|
|
1003
|
+
gitStatus = require('child_process').execSync('git status --short', { cwd, timeout: 3000 }).toString().trim();
|
|
1004
|
+
} catch {}
|
|
1005
|
+
const content = [
|
|
1006
|
+
'# 项目环境', '', `- 工作目录: ${cwd}`,
|
|
1007
|
+
gitBranch ? `- Git 分支: ${gitBranch}` : '',
|
|
1008
|
+
gitStatus ? `- 未提交变更:\n\`\`\`\n${gitStatus.slice(0, 500)}\n\`\`\`` : '',
|
|
1009
|
+
'', '> 此文件由 IMtoAgent 自动生成,启动时和切换目录时更新。',
|
|
1010
|
+
].filter(Boolean).join('\n');
|
|
1011
|
+
fs.writeFileSync(dir + '/workspace.md', content);
|
|
1012
|
+
} catch {}
|
|
1013
|
+
};
|
|
1014
|
+
for (const bot of bots) updateWorkspace(bot);
|
|
1015
|
+
|
|
1016
|
+
// 启动时清除 Claude 后端 Bot 的旧 SDK session ID
|
|
1017
|
+
// 避免 --resume 恢复重启前残留的 Claude CLI 子进程 session
|
|
1018
|
+
for (const bot of bots) {
|
|
1019
|
+
if (bot.backend !== 'claude') continue;
|
|
1020
|
+
const botDir = path.join(getSessionsDir(), bot.name);
|
|
1021
|
+
try {
|
|
1022
|
+
if (fs.existsSync(botDir)) {
|
|
1023
|
+
for (const file of fs.readdirSync(botDir)) {
|
|
1024
|
+
if (!file.endsWith('.memory.json')) continue;
|
|
1025
|
+
const fp = path.join(botDir, file);
|
|
1026
|
+
try {
|
|
1027
|
+
const data = JSON.parse(fs.readFileSync(fp, 'utf-8'));
|
|
1028
|
+
let changed = false;
|
|
1029
|
+
if (data.sdkSessionId) { delete data.sdkSessionId; changed = true; }
|
|
1030
|
+
if (data.backendSessionId) { delete data.backendSessionId; changed = true; }
|
|
1031
|
+
if (data.metadata?.sdkSessionId) { delete data.metadata.sdkSessionId; changed = true; }
|
|
1032
|
+
if (changed) {
|
|
1033
|
+
fs.writeFileSync(fp, JSON.stringify(data, null, 2));
|
|
1034
|
+
console.log(`[Startup] 已清除 ${bot.name}/${file} 的旧 SDK session ID`);
|
|
1035
|
+
}
|
|
1036
|
+
} catch {}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
} catch (e: any) { console.error(`[Startup] 清除 ${bot.name} session: ${e.message}`); }
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// 重启后汇报
|
|
1043
|
+
if (process.env.CC_RESTORE === '1') {
|
|
1044
|
+
const marker = getRestoreMarkerPath();
|
|
1045
|
+
const tryRestore = async () => {
|
|
1046
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
1047
|
+
try {
|
|
1048
|
+
if (!fs.existsSync(marker)) return;
|
|
1049
|
+
const data = JSON.parse(fs.readFileSync(marker, 'utf-8'));
|
|
1050
|
+
const reason = data.reason || '未知';
|
|
1051
|
+
const uptime = Date.now() - (data.timestamp || Date.now());
|
|
1052
|
+
const summary = `🔄 IMtoAgent 已重启\n原因: ${reason}\n耗时: ${(uptime / 1000).toFixed(1)}s`;
|
|
1053
|
+
let sent = 0;
|
|
1054
|
+
for (const bot of bots) {
|
|
1055
|
+
const snap = data.bots?.[bot.name];
|
|
1056
|
+
if (!snap?.chats?.length) continue;
|
|
1057
|
+
for (const { chatId } of snap.chats) {
|
|
1058
|
+
try { await bot.reply(chatId, summary); sent++; break; }
|
|
1059
|
+
catch (e: any) { console.error(`[Restore] ${bot.name} 发送失败(attempt ${attempt}): ${e.message}`); }
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (sent > 0 || attempt >= 4) { try { fs.unlinkSync(marker); } catch {} return; }
|
|
1063
|
+
} catch {}
|
|
1064
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
setTimeout(tryRestore, 4000);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// 空闲清理
|
|
1071
|
+
setInterval(() => cleanupIdleSessions(bots), 5 * 60 * 1000);
|
|
1072
|
+
|
|
1073
|
+
// 优雅关闭
|
|
1074
|
+
async function gracefulShutdown(signal: string) {
|
|
1075
|
+
console.log(`[Shutdown] 收到 ${signal},优雅关闭中...`);
|
|
1076
|
+
// 先 abort 所有适配器的活跃子进程(如 Claude CLI)
|
|
1077
|
+
for (const bot of bots) {
|
|
1078
|
+
try { if (bot.adapter && typeof (bot.adapter as any).cleanup === 'function') (bot.adapter as any).cleanup(); } catch {}
|
|
1079
|
+
}
|
|
1080
|
+
for (const bot of bots) bot.im.stop();
|
|
1081
|
+
|
|
1082
|
+
// 立即关闭代理,让正在等待上游响应的请求快速失败
|
|
1083
|
+
// 这样 handleMessage 的 catch/finally 才能执行,activeRequests 才能递减
|
|
1084
|
+
await stopAnthropicProxy();
|
|
1085
|
+
await stopOpenCodeServer();
|
|
1086
|
+
|
|
1087
|
+
console.log('[Shutdown] 持久化所有 session...');
|
|
1088
|
+
for (const bot of bots) {
|
|
1089
|
+
for (const [chatId, session] of bot.sessions.entries()) {
|
|
1090
|
+
try { bot.sessionManager.persist(bot.name, session); } catch {}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
const DRAIN_TIMEOUT = 10_000;
|
|
1094
|
+
const start = Date.now();
|
|
1095
|
+
while (activeRequests > 0 && Date.now() - start < DRAIN_TIMEOUT) {
|
|
1096
|
+
console.log(`[Shutdown] 等待 ${activeRequests} 个请求完成...`);
|
|
1097
|
+
await new Promise(r => setTimeout(r, 500));
|
|
1098
|
+
}
|
|
1099
|
+
if (activeRequests > 0) {
|
|
1100
|
+
console.warn(`[Shutdown] ⚠️ 超时,仍有 ${activeRequests} 个请求未完成,强制退出`);
|
|
1101
|
+
} else {
|
|
1102
|
+
console.log('[Shutdown] 所有请求已完成');
|
|
1103
|
+
}
|
|
1104
|
+
console.log('[Shutdown] 所有服务已关闭');
|
|
1105
|
+
process.exit(0);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
1109
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
process.on('uncaughtException', (err) => { console.error(`[uncaught] ${err.message}`); console.error(err.stack); });
|
|
1113
|
+
|
|
1114
|
+
let _rejectionCount = 0;
|
|
1115
|
+
let _rejectionLastMinute = 0;
|
|
1116
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
1117
|
+
const now = Date.now();
|
|
1118
|
+
if (now - _rejectionLastMinute > 60000) { _rejectionCount = 0; _rejectionLastMinute = now; }
|
|
1119
|
+
_rejectionCount++;
|
|
1120
|
+
if (_rejectionCount > 5) return;
|
|
1121
|
+
if (reason instanceof Error) {
|
|
1122
|
+
console.error(`[unhandled #${_rejectionCount}] ${reason.message}`);
|
|
1123
|
+
console.error(reason.stack?.split('\n').slice(0, 4).join('\n'));
|
|
1124
|
+
} else {
|
|
1125
|
+
console.error(`[unhandled #${_rejectionCount}] ${String(reason)} (type=${typeof reason})`);
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
main().catch((err) => { console.error(`[启动失败] ${err.message}`); process.exit(1); });
|