imtoagent 0.3.22 → 0.3.23
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/bin/imtoagent-real +5 -3
- package/index.ts +50 -12
- package/modules/cli/setup.ts +86 -21
- package/modules/utils/migrate-workspaces.ts +230 -0
- package/modules/utils/paths.ts +1 -1
- package/modules/utils/workspace-manager.ts +209 -0
- package/package.json +1 -1
package/bin/imtoagent-real
CHANGED
|
@@ -26,7 +26,7 @@ const command = process.argv[2];
|
|
|
26
26
|
|
|
27
27
|
switch (command) {
|
|
28
28
|
case 'setup':
|
|
29
|
-
await cmdSetup();
|
|
29
|
+
await cmdSetup(process.argv[3]);
|
|
30
30
|
break;
|
|
31
31
|
case 'start':
|
|
32
32
|
await cmdStart();
|
|
@@ -98,6 +98,7 @@ imtoagent — IM ↔ Agent Unified Gateway
|
|
|
98
98
|
|
|
99
99
|
Usage:
|
|
100
100
|
imtoagent setup Interactive setup wizard
|
|
101
|
+
imtoagent setup --quick Quick mode (sandbox workspace, skip workspace step)
|
|
101
102
|
imtoagent start Start gateway in background (returns immediately)
|
|
102
103
|
imtoagent run Start gateway in foreground (Ctrl+C to stop)
|
|
103
104
|
imtoagent stop Stop gateway
|
|
@@ -115,9 +116,10 @@ Data directory: ${getDataDir()}
|
|
|
115
116
|
// ================================================================
|
|
116
117
|
// setup — interactive wizard
|
|
117
118
|
// ================================================================
|
|
118
|
-
async function cmdSetup() {
|
|
119
|
+
async function cmdSetup(mode) {
|
|
120
|
+
const quick = mode === '--quick' || mode === '-q';
|
|
119
121
|
const { runSetupWizard } = await import('../modules/cli/setup');
|
|
120
|
-
await runSetupWizard();
|
|
122
|
+
await runSetupWizard(quick ? { quick: true } : undefined);
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
// ================================================================
|
package/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
import { parseToBlocks } from './modules/capabilities';
|
|
20
20
|
import { resolveCapabilities } from './modules/prompt-builder';
|
|
21
21
|
import { getDataDir } from './modules/utils/paths';
|
|
22
|
+
import { WorkspaceManager, createWorkspaceManager } from './modules/utils/workspace-manager';
|
|
23
|
+
import { migrateWorkspaces } from './modules/utils/migrate-workspaces';
|
|
22
24
|
import { FeishuIMModule } from './modules/im/feishu';
|
|
23
25
|
import { TelegramAdapter } from './modules/im/telegram';
|
|
24
26
|
import { WeComIMModule } from './modules/im/wecom';
|
|
@@ -109,15 +111,17 @@ interface ChatSession extends Session {
|
|
|
109
111
|
// ================================================================
|
|
110
112
|
class CustomSessionManager {
|
|
111
113
|
sessions: Map<string, ChatSession>;
|
|
112
|
-
|
|
114
|
+
botKey: string;
|
|
115
|
+
sessionsDir: string;
|
|
113
116
|
|
|
114
|
-
constructor(
|
|
115
|
-
this.
|
|
117
|
+
constructor(botKey: string, workspacePath: string, sessions: Map<string, ChatSession>) {
|
|
118
|
+
this.botKey = botKey;
|
|
116
119
|
this.sessions = sessions;
|
|
120
|
+
this.sessionsDir = path.join(workspacePath, 'sessions');
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
private _sessionPath(chatId: string): string {
|
|
120
|
-
return path.join(
|
|
124
|
+
return path.join(this.sessionsDir, `${chatId}.memory.json`);
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
async getOrCreate(chatId: string, userId: string): Promise<ChatSession> {
|
|
@@ -128,6 +132,10 @@ class CustomSessionManager {
|
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
// 从文件加载(兼容旧格式)
|
|
135
|
+
// 确保 sessions 目录存在
|
|
136
|
+
if (!fs.existsSync(this.sessionsDir)) {
|
|
137
|
+
fs.mkdirSync(this.sessionsDir, { recursive: true });
|
|
138
|
+
}
|
|
131
139
|
const fp = this._sessionPath(chatId);
|
|
132
140
|
let session: ChatSession;
|
|
133
141
|
|
|
@@ -300,6 +308,7 @@ class Bot {
|
|
|
300
308
|
client: Lark.Client;
|
|
301
309
|
im: IMModule;
|
|
302
310
|
config: any;
|
|
311
|
+
workspaceManager: WorkspaceManager;
|
|
303
312
|
|
|
304
313
|
// SDK
|
|
305
314
|
runtime: AgentRuntime;
|
|
@@ -310,7 +319,7 @@ class Bot {
|
|
|
310
319
|
/** 正在执行的任务的取消信号(chatId → AbortController) */
|
|
311
320
|
activeControllers: Map<string, AbortController> = new Map();
|
|
312
321
|
|
|
313
|
-
constructor(cfg: BotConfig, globalConfig: any) {
|
|
322
|
+
constructor(cfg: BotConfig, globalConfig: any, workspaceManager: WorkspaceManager) {
|
|
314
323
|
this.id = cfg.id || cfg.name; // 后向兼容:无 id 时用 name
|
|
315
324
|
this.name = cfg.name;
|
|
316
325
|
this.backend = cfg.backend;
|
|
@@ -318,6 +327,11 @@ class Bot {
|
|
|
318
327
|
this.appSecret = cfg.appSecret;
|
|
319
328
|
this.defaultCwd = cfg.cwd || globalConfig.system?.defaultProjectDir || path.join(os.homedir(), 'Projects');
|
|
320
329
|
this.config = globalConfig;
|
|
330
|
+
this.workspaceManager = workspaceManager;
|
|
331
|
+
|
|
332
|
+
// 确保工作空间目录存在
|
|
333
|
+
const botKey = this.id;
|
|
334
|
+
this.workspaceManager.ensureWorkspace(botKey);
|
|
321
335
|
|
|
322
336
|
// Bot 级模型配置
|
|
323
337
|
const botCfg = this._loadBotConfig();
|
|
@@ -352,7 +366,8 @@ class Bot {
|
|
|
352
366
|
this.im = imFactory.create(cfg);
|
|
353
367
|
|
|
354
368
|
// ===== SDK 集成 =====
|
|
355
|
-
|
|
369
|
+
const workspacePath = this.workspaceManager.getWorkspacePath(this.id);
|
|
370
|
+
this.sessionManager = new CustomSessionManager(this.id, workspacePath, this.sessions);
|
|
356
371
|
|
|
357
372
|
const adapterCtx = {
|
|
358
373
|
imModule: this.im,
|
|
@@ -387,7 +402,9 @@ class Bot {
|
|
|
387
402
|
}
|
|
388
403
|
|
|
389
404
|
// ===== 灵魂管理 =====
|
|
390
|
-
_soulDir() {
|
|
405
|
+
_soulDir() {
|
|
406
|
+
return this.workspaceManager.getSoulPath(this.id);
|
|
407
|
+
}
|
|
391
408
|
|
|
392
409
|
_initSoul() {
|
|
393
410
|
const dir = this._soulDir();
|
|
@@ -614,9 +631,19 @@ class Bot {
|
|
|
614
631
|
|
|
615
632
|
cmd('/dir', ({ args, session }) => {
|
|
616
633
|
const dir = args.trim();
|
|
617
|
-
|
|
618
|
-
if (
|
|
619
|
-
|
|
634
|
+
const currentCwd = session?.cwd || this.defaultCwd;
|
|
635
|
+
if (!dir) {
|
|
636
|
+
const mode = this.workspaceManager.getMode();
|
|
637
|
+
return `📁 ${currentCwd}\n🏷 Workspace mode: ${mode}`;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const resolved = this.workspaceManager.resolveAndValidatePath(this.id, dir, currentCwd);
|
|
641
|
+
if (resolved === null) {
|
|
642
|
+
return `❌ Path not allowed: ${dir}\n💡 In sandbox mode, you can only access paths within your workspace`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (session) session.cwd = resolved;
|
|
646
|
+
return `📁 Switched: ${resolved}`;
|
|
620
647
|
});
|
|
621
648
|
|
|
622
649
|
cmd('/mode', ({ args, session }) => {
|
|
@@ -950,6 +977,16 @@ async function main() {
|
|
|
950
977
|
return;
|
|
951
978
|
}
|
|
952
979
|
|
|
980
|
+
// ===== Workspace Migration =====
|
|
981
|
+
// 老用户升级:自动迁移旧 sessions/ + soul/ 到新的 workspace 结构
|
|
982
|
+
const migrationResult = migrateWorkspaces();
|
|
983
|
+
if (migrationResult.botsMigrated.length > 0) {
|
|
984
|
+
console.log(` 🔄 Workspace migration: ${migrationResult.botsMigrated.length} bot(s) migrated`);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// 创建 WorkspaceManager(所有 Bot 共享)
|
|
988
|
+
const workspaceManager = createWorkspaceManager(config);
|
|
989
|
+
|
|
953
990
|
const bots: Bot[] = [];
|
|
954
991
|
for (const c of botCfgs) {
|
|
955
992
|
const appId = c.appId || c.feishu?.appId || '';
|
|
@@ -958,7 +995,7 @@ async function main() {
|
|
|
958
995
|
|
|
959
996
|
// wechat 不需要 appId/appSecret,首次启动会触发 QR 扫码绑定
|
|
960
997
|
if (imType === 'wechat') {
|
|
961
|
-
bots.push(new Bot({ ...c, appId: appId || 'wechat-bot', appSecret }, config));
|
|
998
|
+
bots.push(new Bot({ ...c, appId: appId || 'wechat-bot', appSecret }, config, workspaceManager));
|
|
962
999
|
continue;
|
|
963
1000
|
}
|
|
964
1001
|
|
|
@@ -968,7 +1005,7 @@ async function main() {
|
|
|
968
1005
|
console.log(`[Config] ⚠️ Bot "${c.name}" has placeholder credentials, skipping`);
|
|
969
1006
|
continue;
|
|
970
1007
|
}
|
|
971
|
-
bots.push(new Bot({ ...c, appId, appSecret }, config));
|
|
1008
|
+
bots.push(new Bot({ ...c, appId, appSecret }, config, workspaceManager));
|
|
972
1009
|
}
|
|
973
1010
|
|
|
974
1011
|
if (bots.length === 0) {
|
|
@@ -976,6 +1013,7 @@ async function main() {
|
|
|
976
1013
|
return;
|
|
977
1014
|
}
|
|
978
1015
|
|
|
1016
|
+
console.log(` Workspace: ${workspaceManager.getConfigSummary()}`);
|
|
979
1017
|
_allBots = bots;
|
|
980
1018
|
console.log(`\n🚀 CC Routing v4 — Multi-Bot Architecture (Full SDK Integration)`);
|
|
981
1019
|
console.log(` Anthropic: http://localhost:${proxyPort}`);
|
package/modules/cli/setup.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import * as fs from 'fs';
|
|
13
13
|
import * as os from 'os';
|
|
14
14
|
import * as path from 'path';
|
|
15
|
-
import { getDataDir, getPkgDir, getTemplatePath, getSoulDir
|
|
15
|
+
import { getDataDir, getPkgDir, getTemplatePath, getSoulDir } from '../utils/paths';
|
|
16
16
|
import { randomUUID } from 'crypto';
|
|
17
17
|
import { checkAllBackends, formatBackendStatus } from '../utils/backend-check';
|
|
18
18
|
|
|
@@ -290,7 +290,11 @@ const IM_FIELDS: Record<string, { key: string; label: string; required: boolean
|
|
|
290
290
|
// Main flow
|
|
291
291
|
// ================================================================
|
|
292
292
|
|
|
293
|
-
export
|
|
293
|
+
export interface SetupOptions {
|
|
294
|
+
quick?: boolean;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function runSetupWizard(options?: SetupOptions): Promise<void> {
|
|
294
298
|
// Guard: refuse to run in non-TTY environment
|
|
295
299
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
296
300
|
console.error('');
|
|
@@ -311,6 +315,11 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
311
315
|
console.log(`\nData directory: ${dataDir}`);
|
|
312
316
|
console.log(`Controls: ↑↓/Space navigate | Enter confirm | ESC go back\n`);
|
|
313
317
|
|
|
318
|
+
const isQuick = options?.quick || false;
|
|
319
|
+
if (isQuick) {
|
|
320
|
+
console.log('⚡ Quick mode: workspace defaults to sandbox, skip workspace step\n');
|
|
321
|
+
}
|
|
322
|
+
|
|
314
323
|
// ===== Step 1: Detect existing configuration =====
|
|
315
324
|
let existingConfig: any = null;
|
|
316
325
|
let mergeMode = false;
|
|
@@ -458,15 +467,20 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
458
467
|
bot.im = imType;
|
|
459
468
|
}
|
|
460
469
|
|
|
461
|
-
// Check for duplicate name
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
bots.push(bot);
|
|
468
|
-
console.log(`✅ Added: ${botName}`);
|
|
470
|
+
// Check for duplicate name — auto-rename with counter
|
|
471
|
+
let finalName = botName;
|
|
472
|
+
let counter = 1;
|
|
473
|
+
while (bots.find(b => b.name === finalName)) {
|
|
474
|
+
finalName = `${botName} (${counter})`;
|
|
475
|
+
counter++;
|
|
469
476
|
}
|
|
477
|
+
bot.name = finalName;
|
|
478
|
+
|
|
479
|
+
if (finalName !== botName) {
|
|
480
|
+
console.log(`⚠️ "${botName}" already exists, renamed to "${finalName}"`);
|
|
481
|
+
}
|
|
482
|
+
bots.push(bot);
|
|
483
|
+
console.log(`✅ Added: ${finalName}`);
|
|
470
484
|
|
|
471
485
|
// Whether to continue adding
|
|
472
486
|
const r = await confirm('Add another Bot?', true);
|
|
@@ -482,8 +496,43 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
482
496
|
return;
|
|
483
497
|
}
|
|
484
498
|
|
|
485
|
-
// ===== Step 4: Configure
|
|
486
|
-
|
|
499
|
+
// ===== Step 4: Configure workspace =====
|
|
500
|
+
let workspaceMode: 'sandbox' | 'global' = 'sandbox';
|
|
501
|
+
let workspaceGlobalPath: string | null = null;
|
|
502
|
+
|
|
503
|
+
if (!isQuick) {
|
|
504
|
+
console.log('\n📌 Step 4: Configure workspace\n');
|
|
505
|
+
console.log('Each Bot gets its own isolated file workspace:');
|
|
506
|
+
console.log(' sandbox — isolated per Bot, no cross-Bot file access (recommended)');
|
|
507
|
+
console.log(' global — shared root directory, multiple Bots collaborate on same codebase');
|
|
508
|
+
console.log('');
|
|
509
|
+
|
|
510
|
+
const wsLabels = ['Sandbox mode (isolated per Bot)', 'Global mode (shared root path)'];
|
|
511
|
+
const wsIdx = await selectMenu('Select workspace mode', wsLabels);
|
|
512
|
+
if (wsIdx === -1) { console.log('\n👋 Cancelled'); process.exit(0); }
|
|
513
|
+
workspaceMode = wsIdx === 1 ? 'global' : 'sandbox';
|
|
514
|
+
|
|
515
|
+
if (workspaceMode === 'global') {
|
|
516
|
+
const defaultGlobal = os.homedir() + '/imtoagent-workspace';
|
|
517
|
+
const gpInput = await promptText('Global workspace root path', defaultGlobal);
|
|
518
|
+
if ((gpInput as any) === -1) { console.log('\n👋 Cancelled'); process.exit(0); }
|
|
519
|
+
workspaceGlobalPath = (gpInput || defaultGlobal).trim();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const home = process.env.HOME || process.env.USERPROFILE?.replace(/\\/g, '/') || '';
|
|
523
|
+
console.log('');
|
|
524
|
+
console.log('Workspace layout:');
|
|
525
|
+
if (workspaceMode === 'sandbox') {
|
|
526
|
+
console.log(` ${home}/.imtoagent/workspaces/<UUID>/ (each Bot gets a unique directory)`);
|
|
527
|
+
} else {
|
|
528
|
+
console.log(` ${workspaceGlobalPath}/ (all Bots share this root)`);
|
|
529
|
+
console.log(` soul files in ${workspaceGlobalPath}/.imtoagent/soul/<botId>/`);
|
|
530
|
+
}
|
|
531
|
+
console.log('');
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ===== Step 5: Configure model providers =====
|
|
535
|
+
console.log('\n📌 Step 5: Configure model providers\n');
|
|
487
536
|
|
|
488
537
|
const providers: Record<string, any> = {};
|
|
489
538
|
if (mergeMode && existingConfig?.providers) {
|
|
@@ -600,8 +649,8 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
600
649
|
step4Loop = false; // Has providers or user explicitly skipped
|
|
601
650
|
}
|
|
602
651
|
|
|
603
|
-
// ===== Step
|
|
604
|
-
console.log('\n📌 Step
|
|
652
|
+
// ===== Step 6: Select default model =====
|
|
653
|
+
console.log('\n📌 Step 6: Select default model\n');
|
|
605
654
|
|
|
606
655
|
const allModels: string[] = [];
|
|
607
656
|
for (const [provName, prov] of Object.entries(providers)) {
|
|
@@ -620,11 +669,23 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
620
669
|
if ((defaultModel as any) === -1) defaultModel = 'deepseek/deepseek-v4-pro';
|
|
621
670
|
}
|
|
622
671
|
|
|
623
|
-
// ===== Step
|
|
624
|
-
console.log('\n📌 Step
|
|
672
|
+
// ===== Step 7: Generate soul files =====
|
|
673
|
+
console.log('\n📌 Step 7: Generate soul files\n');
|
|
674
|
+
|
|
675
|
+
// Compute workspace soul dirs based on configured workspace mode
|
|
676
|
+
const home = process.env.HOME || process.env.USERPROFILE?.replace(/\\/g, '/') || '';
|
|
625
677
|
|
|
626
678
|
for (const bot of bots) {
|
|
627
|
-
|
|
679
|
+
let botSoulDir: string;
|
|
680
|
+
|
|
681
|
+
if (workspaceMode === 'sandbox') {
|
|
682
|
+
// Sandbox: soul in workspaces/<UUID>/soul/
|
|
683
|
+
botSoulDir = path.join(home, '.imtoagent', 'workspaces', bot.id, 'soul');
|
|
684
|
+
} else {
|
|
685
|
+
// Global: soul in <globalPath>/.imtoagent/soul/<botId>/
|
|
686
|
+
botSoulDir = path.join(workspaceGlobalPath || path.join(home, 'imtoagent-workspace'), '.imtoagent', 'soul', bot.id);
|
|
687
|
+
}
|
|
688
|
+
|
|
628
689
|
const templateSoulDir = path.join(getPkgDir(), 'templates', 'soul.template');
|
|
629
690
|
|
|
630
691
|
if (fs.existsSync(botSoulDir)) {
|
|
@@ -654,9 +715,7 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
654
715
|
}
|
|
655
716
|
console.log(`✅ ${bot.name}: soul files → ${botSoulDir}`);
|
|
656
717
|
}
|
|
657
|
-
|
|
658
|
-
// ===== Step 7: Write configuration files =====
|
|
659
|
-
console.log('\n📌 Step 7: Write configuration files\n');
|
|
718
|
+
console.log('\n📌 Step 8: Write configuration files\n');
|
|
660
719
|
|
|
661
720
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
662
721
|
|
|
@@ -666,6 +725,11 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
666
725
|
idleTimeoutMinutes: 30,
|
|
667
726
|
maxReplyLength: 140000,
|
|
668
727
|
},
|
|
728
|
+
workspace: {
|
|
729
|
+
mode: workspaceMode,
|
|
730
|
+
globalPath: workspaceGlobalPath,
|
|
731
|
+
botOverrides: {},
|
|
732
|
+
},
|
|
669
733
|
providers,
|
|
670
734
|
defaultModel,
|
|
671
735
|
modelAliases: existingConfig?.modelAliases || buildDefaultAliases(defaultModel),
|
|
@@ -717,9 +781,10 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
717
781
|
console.log('\n╔══════════════════════════════════════════════╗');
|
|
718
782
|
console.log('║ ✅ Configuration complete! ║');
|
|
719
783
|
console.log('╚══════════════════════════════════════════════╝\n');
|
|
720
|
-
console.log(`
|
|
784
|
+
console.log(`Bots: ${bots.map(b => b.name).join(', ')}`);
|
|
721
785
|
console.log(`Default model: ${defaultModel}`);
|
|
722
786
|
console.log(`Providers: ${Object.keys(providers).join(', ') || 'None'}`);
|
|
787
|
+
console.log(`Workspace: ${workspaceMode === 'sandbox' ? 'sandbox (isolated)' : `global (${workspaceGlobalPath})`}`);
|
|
723
788
|
console.log(`\nNext steps:`);
|
|
724
789
|
console.log(` imtoagent start Start the gateway`);
|
|
725
790
|
console.log(` imtoagent status Check status\n`);
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// ================================================================
|
|
2
|
+
// Workspace Migration — 老用户平滑迁移
|
|
3
|
+
// ================================================================
|
|
4
|
+
// 职责:
|
|
5
|
+
// 1. 检测旧目录结构(sessions/ + soul/)
|
|
6
|
+
// 2. 迁移到新 workspace 结构
|
|
7
|
+
// 3. 保留旧目录作为 .backup(不删除,安全优先)
|
|
8
|
+
// 4. 标记迁移完成,避免重复执行
|
|
9
|
+
// ================================================================
|
|
10
|
+
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { getDataDir } from './paths';
|
|
14
|
+
|
|
15
|
+
const MIGRATION_MARKER = '.workspace-migrated';
|
|
16
|
+
|
|
17
|
+
interface MigrationResult {
|
|
18
|
+
migrated: boolean;
|
|
19
|
+
botsMigrated: string[];
|
|
20
|
+
errors: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ================================================================
|
|
24
|
+
// 公共入口
|
|
25
|
+
// ================================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 检测并执行 workspace 迁移。
|
|
29
|
+
*
|
|
30
|
+
* 启动时调用一次。如果已经迁移过,直接返回。
|
|
31
|
+
* 返回迁移结果摘要,可用于日志输出。
|
|
32
|
+
*/
|
|
33
|
+
export function migrateWorkspaces(): MigrationResult {
|
|
34
|
+
const dataDir = getDataDir();
|
|
35
|
+
const markerPath = path.join(dataDir, MIGRATION_MARKER);
|
|
36
|
+
|
|
37
|
+
// 已经迁移过,跳过
|
|
38
|
+
if (fs.existsSync(markerPath)) {
|
|
39
|
+
return { migrated: false, botsMigrated: [], errors: [] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const result: MigrationResult = { migrated: true, botsMigrated: [], errors: [] };
|
|
43
|
+
|
|
44
|
+
// 加载 bot-ids.json(UUID 映射)
|
|
45
|
+
const botIds = loadBotIds(dataDir);
|
|
46
|
+
|
|
47
|
+
// 旧目录
|
|
48
|
+
const oldSessionsDir = path.join(dataDir, 'sessions');
|
|
49
|
+
const oldSoulDir = path.join(dataDir, 'soul');
|
|
50
|
+
|
|
51
|
+
// 发现需要迁移的 Bot(从旧 sessions 目录扫描)
|
|
52
|
+
const botKeys = discoverBotKeys(oldSessionsDir, oldSoulDir);
|
|
53
|
+
if (botKeys.length === 0) {
|
|
54
|
+
// 没有旧数据,直接标记
|
|
55
|
+
markMigrated(markerPath);
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.error(`[Migration] Found ${botKeys.length} bot(s) to migrate: ${botKeys.join(', ')}`);
|
|
60
|
+
|
|
61
|
+
for (const botKey of botKeys) {
|
|
62
|
+
try {
|
|
63
|
+
const botUuid = ensureBotUuid(botKey, botIds, dataDir);
|
|
64
|
+
migrateBotData(botKey, botUuid, dataDir, oldSessionsDir, oldSoulDir);
|
|
65
|
+
result.botsMigrated.push(botKey);
|
|
66
|
+
} catch (e: any) {
|
|
67
|
+
const msg = `[${botKey}] ${e.message}`;
|
|
68
|
+
result.errors.push(msg);
|
|
69
|
+
console.error(`[Migration] ERROR: ${msg}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 迁移完成后创建标记
|
|
74
|
+
markMigrated(markerPath);
|
|
75
|
+
|
|
76
|
+
// 打印摘要
|
|
77
|
+
if (result.botsMigrated.length > 0) {
|
|
78
|
+
console.error(`[Migration] ✅ ${result.botsMigrated.length} bot(s) migrated successfully`);
|
|
79
|
+
}
|
|
80
|
+
if (result.errors.length > 0) {
|
|
81
|
+
console.error(`[Migration] ⚠️ ${result.errors.length} error(s): ${result.errors.join('; ')}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ================================================================
|
|
88
|
+
// 内部函数
|
|
89
|
+
// ================================================================
|
|
90
|
+
|
|
91
|
+
/** 加载 bot-ids.json */
|
|
92
|
+
function loadBotIds(dataDir: string): Record<string, string> {
|
|
93
|
+
const botIdsFile = path.join(dataDir, 'bot-ids.json');
|
|
94
|
+
try {
|
|
95
|
+
if (fs.existsSync(botIdsFile)) {
|
|
96
|
+
return JSON.parse(fs.readFileSync(botIdsFile, 'utf8'));
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// 文件损坏,重新开始
|
|
100
|
+
}
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 确保 Bot 有 UUID */
|
|
105
|
+
function ensureBotUuid(
|
|
106
|
+
botKey: string,
|
|
107
|
+
botIds: Record<string, string>,
|
|
108
|
+
dataDir: string,
|
|
109
|
+
): string {
|
|
110
|
+
if (botIds[botKey]) return botIds[botKey];
|
|
111
|
+
|
|
112
|
+
const uuid = crypto.randomUUID();
|
|
113
|
+
botIds[botKey] = uuid;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
fs.writeFileSync(path.join(dataDir, 'bot-ids.json'), JSON.stringify(botIds, null, 2));
|
|
117
|
+
} catch {
|
|
118
|
+
// 写入失败不影响迁移流程
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return uuid;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 发现需要迁移的 Bot keys */
|
|
125
|
+
function discoverBotKeys(sessionsDir: string, soulDir: string): string[] {
|
|
126
|
+
const botKeys = new Set<string>();
|
|
127
|
+
|
|
128
|
+
// 从 sessions 目录扫描
|
|
129
|
+
if (fs.existsSync(sessionsDir)) {
|
|
130
|
+
try {
|
|
131
|
+
for (const entry of fs.readdirSync(sessionsDir)) {
|
|
132
|
+
if (entry.startsWith('.')) continue; // 跳过 .restore 等隐藏文件
|
|
133
|
+
const entryPath = path.join(sessionsDir, entry);
|
|
134
|
+
if (fs.statSync(entryPath).isDirectory()) {
|
|
135
|
+
botKeys.add(entry);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// 忽略读取失败
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 从 soul 目录扫描(补充)
|
|
144
|
+
if (fs.existsSync(soulDir)) {
|
|
145
|
+
try {
|
|
146
|
+
for (const entry of fs.readdirSync(soulDir)) {
|
|
147
|
+
if (entry.startsWith('.')) continue;
|
|
148
|
+
const entryPath = path.join(soulDir, entry);
|
|
149
|
+
if (fs.statSync(entryPath).isDirectory()) {
|
|
150
|
+
botKeys.add(entry);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
// 忽略读取失败
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return Array.from(botKeys);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 迁移单个 Bot 的数据 */
|
|
162
|
+
function migrateBotData(
|
|
163
|
+
botKey: string,
|
|
164
|
+
botUuid: string,
|
|
165
|
+
dataDir: string,
|
|
166
|
+
oldSessionsDir: string,
|
|
167
|
+
oldSoulDir: string,
|
|
168
|
+
): void {
|
|
169
|
+
const workspacesDir = path.join(dataDir, 'workspaces');
|
|
170
|
+
const newWorkspacePath = path.join(workspacesDir, botUuid);
|
|
171
|
+
const newSessionsPath = path.join(newWorkspacePath, 'sessions');
|
|
172
|
+
const newSoulPath = path.join(newWorkspacePath, 'soul');
|
|
173
|
+
|
|
174
|
+
console.error(`[Migration] Migrating [${botKey}] → workspace/${botUuid}/`);
|
|
175
|
+
|
|
176
|
+
// 确保新目录存在
|
|
177
|
+
fs.mkdirSync(newSessionsPath, { recursive: true });
|
|
178
|
+
fs.mkdirSync(newSoulPath, { recursive: true });
|
|
179
|
+
|
|
180
|
+
// 1. 迁移 session 文件
|
|
181
|
+
const oldBotSessionsDir = path.join(oldSessionsDir, botKey);
|
|
182
|
+
if (fs.existsSync(oldBotSessionsDir)) {
|
|
183
|
+
const sessionFiles = fs.readdirSync(oldBotSessionsDir).filter((f) => f.endsWith('.memory.json'));
|
|
184
|
+
for (const file of sessionFiles) {
|
|
185
|
+
const src = path.join(oldBotSessionsDir, file);
|
|
186
|
+
const dst = path.join(newSessionsPath, file);
|
|
187
|
+
if (!fs.existsSync(dst)) {
|
|
188
|
+
fs.copyFileSync(src, dst);
|
|
189
|
+
console.error(`[Migration] session: ${file}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2. 迁移 soul 文件
|
|
195
|
+
const oldBotSoulDir = path.join(oldSoulDir, botKey);
|
|
196
|
+
if (fs.existsSync(oldBotSoulDir)) {
|
|
197
|
+
const soulFiles = fs.readdirSync(oldBotSoulDir);
|
|
198
|
+
for (const file of soulFiles) {
|
|
199
|
+
const src = path.join(oldBotSoulDir, file);
|
|
200
|
+
const dst = path.join(newSoulPath, file);
|
|
201
|
+
if (!fs.existsSync(dst) && fs.statSync(src).isFile()) {
|
|
202
|
+
fs.copyFileSync(src, dst);
|
|
203
|
+
console.error(`[Migration] soul: ${file}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 3. 迁移 .restore 文件到数据目录根(全局 marker)
|
|
209
|
+
const oldRestore = path.join(oldSessionsDir, '.restore');
|
|
210
|
+
if (fs.existsSync(oldRestore)) {
|
|
211
|
+
const newRestore = path.join(dataDir, '.restore');
|
|
212
|
+
if (!fs.existsSync(newRestore)) {
|
|
213
|
+
fs.copyFileSync(oldRestore, newRestore);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 标记迁移已完成 */
|
|
219
|
+
function markMigrated(markerPath: string): void {
|
|
220
|
+
const marker = {
|
|
221
|
+
version: 1,
|
|
222
|
+
migratedAt: new Date().toISOString(),
|
|
223
|
+
note: 'Workspace migration completed. Old sessions/ and soul/ directories preserved as backup.',
|
|
224
|
+
};
|
|
225
|
+
try {
|
|
226
|
+
fs.writeFileSync(markerPath, JSON.stringify(marker, null, 2));
|
|
227
|
+
} catch {
|
|
228
|
+
console.error('[Migration] Failed to write migration marker');
|
|
229
|
+
}
|
|
230
|
+
}
|
package/modules/utils/paths.ts
CHANGED
|
@@ -201,7 +201,7 @@ export function getSoulDir(botKey: string): string {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
export function getRestoreMarkerPath(): string {
|
|
204
|
-
return path.join(
|
|
204
|
+
return path.join(getDataDir(), '.restore');
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
export function getTemplatePath(relativePath: string): string {
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// ================================================================
|
|
2
|
+
// WorkspaceManager — 工作空间管理
|
|
3
|
+
// ================================================================
|
|
4
|
+
// 职责:
|
|
5
|
+
// 1. 根据模式(sandbox/global)解析每个 Bot 的工作空间路径
|
|
6
|
+
// 2. 确保工作空间目录存在(含 soul/ 子目录)
|
|
7
|
+
// 3. 路径边界检查(沙盒模式下防止路径穿越)
|
|
8
|
+
// ================================================================
|
|
9
|
+
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import { getDataDir } from './paths';
|
|
13
|
+
|
|
14
|
+
// ================================================================
|
|
15
|
+
// 类型定义
|
|
16
|
+
// ================================================================
|
|
17
|
+
|
|
18
|
+
export type WorkspaceMode = 'sandbox' | 'global';
|
|
19
|
+
|
|
20
|
+
export interface WorkspaceConfig {
|
|
21
|
+
mode: WorkspaceMode;
|
|
22
|
+
globalPath: string | null;
|
|
23
|
+
botOverrides: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 从 config.json 中提取 workspace 配置。
|
|
28
|
+
* 老用户无 workspace 配置时,默认 sandbox 模式。
|
|
29
|
+
*/
|
|
30
|
+
export function parseWorkspaceConfig(raw: any): WorkspaceConfig {
|
|
31
|
+
const ws = raw?.workspace || {};
|
|
32
|
+
const mode: WorkspaceMode = ws.mode === 'global' ? 'global' : 'sandbox';
|
|
33
|
+
const globalPath: string | null = ws.globalPath || null;
|
|
34
|
+
const botOverrides: Record<string, string> = {};
|
|
35
|
+
|
|
36
|
+
if (ws.botOverrides && typeof ws.botOverrides === 'object') {
|
|
37
|
+
for (const [k, v] of Object.entries(ws.botOverrides)) {
|
|
38
|
+
if (typeof v === 'string') botOverrides[k] = v;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { mode, globalPath, botOverrides };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ================================================================
|
|
46
|
+
// WorkspaceManager
|
|
47
|
+
// ================================================================
|
|
48
|
+
|
|
49
|
+
export class WorkspaceManager {
|
|
50
|
+
private config: WorkspaceConfig;
|
|
51
|
+
private workspacesDir: string;
|
|
52
|
+
|
|
53
|
+
constructor(config: WorkspaceConfig) {
|
|
54
|
+
this.config = config;
|
|
55
|
+
this.workspacesDir = path.join(getDataDir(), 'workspaces');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 解析 Bot 的工作空间路径。
|
|
60
|
+
*
|
|
61
|
+
* 规则:
|
|
62
|
+
* - 沙盒模式:~/.imtoagent/workspaces/<UUID>/(UUID 保证唯一,可被 botOverrides 覆盖)
|
|
63
|
+
* - 全局模式:直接使用 <globalPath>/(所有 Bot 共享同一入口)
|
|
64
|
+
*/
|
|
65
|
+
getWorkspacePath(botKey: string): string {
|
|
66
|
+
// 优先级:botOverrides > 模式默认路径
|
|
67
|
+
const override = this.config.botOverrides[botKey];
|
|
68
|
+
if (override) return path.resolve(override);
|
|
69
|
+
|
|
70
|
+
// 全局模式:所有 Bot 共享 globalPath,不加 botKey
|
|
71
|
+
if (this.config.mode === 'global' && this.config.globalPath) {
|
|
72
|
+
return path.resolve(this.config.globalPath);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 沙盒模式:每个 Bot 独立目录(UUID 保证唯一性,无需日期后缀)
|
|
76
|
+
const botId = this._getBotId(botKey);
|
|
77
|
+
return path.resolve(this.workspacesDir, botId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 生成或获取 Bot 的 UUID。
|
|
82
|
+
* 首次调用时创建 UUID 并持久化到数据目录。
|
|
83
|
+
*/
|
|
84
|
+
private _getBotId(botKey: string): string {
|
|
85
|
+
const botIdsFile = path.join(getDataDir(), 'bot-ids.json');
|
|
86
|
+
let botIds: Record<string, string> = {};
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
if (fs.existsSync(botIdsFile)) {
|
|
90
|
+
botIds = JSON.parse(fs.readFileSync(botIdsFile, 'utf8'));
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// 文件损坏或不可读,重新开始
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!botIds[botKey]) {
|
|
97
|
+
// 生成 UUID v4
|
|
98
|
+
botIds[botKey] = crypto.randomUUID();
|
|
99
|
+
try {
|
|
100
|
+
fs.writeFileSync(botIdsFile, JSON.stringify(botIds, null, 2));
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.error(`[Workspace] Failed to persist bot ID for ${botKey}: ${e}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return botIds[botKey];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 确保工作空间目录存在,并初始化 soul/ 子目录。
|
|
111
|
+
*
|
|
112
|
+
* 沙盒模式:创建 <workspace>/soul/
|
|
113
|
+
* 全局模式:创建 <globalPath>/.imtoagent/soul/<botId>/(按 Bot 隔离)
|
|
114
|
+
*/
|
|
115
|
+
ensureWorkspace(botKey: string): void {
|
|
116
|
+
const wsPath = this.getWorkspacePath(botKey);
|
|
117
|
+
const soulPath = this.getSoulPath(botKey);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
if (!fs.existsSync(wsPath)) {
|
|
121
|
+
fs.mkdirSync(wsPath, { recursive: true });
|
|
122
|
+
}
|
|
123
|
+
if (!fs.existsSync(soulPath)) {
|
|
124
|
+
fs.mkdirSync(soulPath, { recursive: true });
|
|
125
|
+
}
|
|
126
|
+
} catch (e: any) {
|
|
127
|
+
console.error(`[Workspace] Failed to ensure workspace for ${botKey}: ${e.message}`);
|
|
128
|
+
throw e;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 获取 soul 目录路径。
|
|
134
|
+
* 沙盒模式:<workspacePath>/soul/
|
|
135
|
+
* 全局模式:<globalPath>/.imtoagent/soul/<botId>/
|
|
136
|
+
*/
|
|
137
|
+
getSoulPath(botKey: string): string {
|
|
138
|
+
if (this.config.mode === 'global' && this.config.globalPath) {
|
|
139
|
+
return path.resolve(this.config.globalPath, '.imtoagent', 'soul', botKey);
|
|
140
|
+
}
|
|
141
|
+
// 沙盒模式
|
|
142
|
+
return path.join(this.getWorkspacePath(botKey), 'soul');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 检查路径是否允许该 Bot 访问。
|
|
147
|
+
*
|
|
148
|
+
* 沙盒模式:路径必须在 Bot 的工作空间范围内(或子目录)。
|
|
149
|
+
* 全局模式:不做限制,允许访问任意路径(信任用户配置的全局目录)。
|
|
150
|
+
*
|
|
151
|
+
* 返回 true 表示允许,false 表示拒绝。
|
|
152
|
+
*/
|
|
153
|
+
isPathAllowed(botKey: string, targetPath: string): boolean {
|
|
154
|
+
// 全局模式:不做边界限制
|
|
155
|
+
if (this.config.mode === 'global') {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 沙盒模式:路径必须在工作空间内
|
|
160
|
+
const resolved = path.resolve(targetPath);
|
|
161
|
+
const wsPath = this.getWorkspacePath(botKey);
|
|
162
|
+
const resolvedWs = path.resolve(wsPath);
|
|
163
|
+
|
|
164
|
+
if (resolved === resolvedWs || resolved.startsWith(resolvedWs + path.sep)) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 获取工作空间模式。
|
|
173
|
+
*/
|
|
174
|
+
getMode(): WorkspaceMode {
|
|
175
|
+
return this.config.mode;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 规范化路径(解析 '..'、'.' 等),用于 /dir 命令。
|
|
180
|
+
* 如果规范化后的路径超出边界,返回 null。
|
|
181
|
+
*/
|
|
182
|
+
resolveAndValidatePath(botKey: string, inputPath: string, currentCwd: string): string | null {
|
|
183
|
+
// 先相对于当前 cwd 解析
|
|
184
|
+
const resolved = path.resolve(currentCwd, inputPath);
|
|
185
|
+
if (this.isPathAllowed(botKey, resolved)) {
|
|
186
|
+
return resolved;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 获取工作空间配置摘要(用于状态展示)。
|
|
193
|
+
*/
|
|
194
|
+
getConfigSummary(): string {
|
|
195
|
+
if (this.config.mode === 'sandbox') {
|
|
196
|
+
return `sandbox (default: ${this.workspacesDir}/<UUID>/)`;
|
|
197
|
+
}
|
|
198
|
+
return `global (${path.resolve(this.config.globalPath || '.')})`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ================================================================
|
|
203
|
+
// 便捷函数:从原始配置创建 WorkspaceManager
|
|
204
|
+
// ================================================================
|
|
205
|
+
|
|
206
|
+
export function createWorkspaceManager(rawConfig: any): WorkspaceManager {
|
|
207
|
+
const config = parseWorkspaceConfig(rawConfig);
|
|
208
|
+
return new WorkspaceManager(config);
|
|
209
|
+
}
|