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.
@@ -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
- botName: string;
114
+ botKey: string;
115
+ sessionsDir: string;
113
116
 
114
- constructor(botName: string, sessions: Map<string, ChatSession>) {
115
- this.botName = botName;
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(getSessionsDir(), this.botName, `${chatId}.memory.json`);
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
- this.sessionManager = new CustomSessionManager(this.id, this.sessions);
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() { return getSoulDir(this.id); }
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
- if (!dir) return `📁 ${session?.cwd || this.defaultCwd}`;
618
- if (session) session.cwd = dir;
619
- return `📁 Switched: ${dir}`;
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}`);
@@ -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, getBotKey } from '../utils/paths';
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 async function runSetupWizard(): Promise<void> {
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
- const existingIdx = bots.findIndex(b => b.name === botName);
463
- if (existingIdx >= 0) {
464
- bots[existingIdx] = bot;
465
- console.log(`✅ Replaced: ${botName}`);
466
- } else {
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 model providers =====
486
- console.log('\n📌 Step 4: Configure model providers\n');
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 5: Select default model =====
604
- console.log('\n📌 Step 5: Select default model\n');
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 6: Generate soul files =====
624
- console.log('\n📌 Step 6: Generate soul files\n');
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
- const botSoulDir = getSoulDir(getBotKey(bot));
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(`Bot: ${bots.map(b => b.name).join(', ')}`);
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
+ }
@@ -201,7 +201,7 @@ export function getSoulDir(botKey: string): string {
201
201
  }
202
202
 
203
203
  export function getRestoreMarkerPath(): string {
204
- return path.join(getSessionsDir(), '.restore');
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imtoagent",
3
- "version": "0.3.22",
3
+ "version": "0.3.23",
4
4
  "description": "IM ↔ Agent 统一网关 — 飞书/Telegram/微信/企业微信对接 Claude Code/Codex/OpenCode",
5
5
  "type": "module",
6
6
  "bin": {