/** * Step: register — Create v2 entities (agent group, messaging group, wiring). * * Writes to the v2 central DB (data/v2.db) — NOT the v1 store/messages.db. * Creates: agent_group, messaging_group, messaging_group_agents. */ import fs from 'fs'; import path from 'path'; import { DATA_DIR } from '../src/config.js'; import { initDb } from '../src/db/connection.js'; import { runMigrations } from '../src/db/migrations/index.js'; import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js'; import { createDestination, getDestinationByName, normalizeName, } from '../src/db/agent-destinations.js'; import { createMessagingGroup, createMessagingGroupAgent, getMessagingGroupByPlatform, getMessagingGroupAgentByPair, } from '../src/db/messaging-groups.js'; import { isValidGroupFolder } from '../src/group-folder.js'; import { log } from '../src/log.js'; import { resolveSession, writeSessionMessage } from '../src/session-manager.js'; import { emitStatus } from './status.js'; interface RegisterArgs { /** Platform-specific channel/group ID (Discord channel ID, Slack channel, etc.) */ platformId: string; /** Human-readable name for the messaging group */ name: string; /** Trigger pattern (regex or keyword) */ trigger: string; /** Agent group folder name */ folder: string; /** Channel type (discord, slack, telegram, etc.) */ channel: string; /** Whether messages require the trigger pattern to activate */ requiresTrigger: boolean; /** Whether this is the admin/main agent group */ isMain: boolean; /** Display name for the assistant */ assistantName: string; /** Session mode: 'shared' (one session per channel) or 'per-thread' */ sessionMode: string; /** Optional local name the agent uses for this channel (defaults to normalized messaging group name) */ localName: string | null; } function parseArgs(args: string[]): RegisterArgs { const result: RegisterArgs = { platformId: '', name: '', trigger: '', folder: '', channel: 'discord', requiresTrigger: true, isMain: false, assistantName: 'Andy', sessionMode: 'shared', localName: null, }; for (let i = 0; i < args.length; i++) { switch (args[i]) { // Accept both --jid (v1 compat) and --platform-id (v2) case '--jid': case '--platform-id': result.platformId = args[++i] || ''; break; case '--name': result.name = args[++i] || ''; break; case '--trigger': result.trigger = args[++i] || ''; break; case '--folder': result.folder = args[++i] || ''; break; case '--channel': result.channel = (args[++i] || '').toLowerCase(); break; case '--no-trigger-required': result.requiresTrigger = false; break; case '--is-main': result.isMain = true; break; case '--assistant-name': result.assistantName = args[++i] || 'Andy'; break; case '--session-mode': result.sessionMode = args[++i] || 'shared'; break; case '--local-name': result.localName = args[++i] || null; break; } } return result; } function generateId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } export async function run(args: string[]): Promise { const projectRoot = process.cwd(); const parsed = parseArgs(args); if (!parsed.platformId || !parsed.name || !parsed.folder) { emitStatus('REGISTER_CHANNEL', { STATUS: 'failed', ERROR: 'missing_required_args', LOG: 'logs/setup.log', }); process.exit(4); } if (!isValidGroupFolder(parsed.folder)) { emitStatus('REGISTER_CHANNEL', { STATUS: 'failed', ERROR: 'invalid_folder', LOG: 'logs/setup.log', }); process.exit(4); } // Chat SDK adapters prefix platform IDs with the channel type // (e.g. "telegram:123", "discord:guild:channel"). Normalize here so // the stored ID always matches what the adapter sends at runtime. if (!parsed.platformId.startsWith(`${parsed.channel}:`)) { parsed.platformId = `${parsed.channel}:${parsed.platformId}`; } log.info('Registering channel', parsed); // Init v2 central DB fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true }); const dbPath = path.join(DATA_DIR, 'v2.db'); const db = initDb(dbPath); runMigrations(db); // 1. Create or find agent group let agentGroup = getAgentGroupByFolder(parsed.folder); if (!agentGroup) { const agId = generateId('ag'); createAgentGroup({ id: agId, name: parsed.assistantName, folder: parsed.folder, is_admin: parsed.isMain ? 1 : 0, agent_provider: null, container_config: null, created_at: new Date().toISOString(), }); agentGroup = getAgentGroupByFolder(parsed.folder)!; log.info('Created agent group', { id: agId, folder: parsed.folder }); } // 2. Create or find messaging group let messagingGroup = getMessagingGroupByPlatform(parsed.channel, parsed.platformId); if (!messagingGroup) { const mgId = generateId('mg'); createMessagingGroup({ id: mgId, channel_type: parsed.channel, platform_id: parsed.platformId, name: parsed.name, is_group: 1, admin_user_id: null, created_at: new Date().toISOString(), }); messagingGroup = getMessagingGroupByPlatform(parsed.channel, parsed.platformId)!; log.info('Created messaging group', { id: mgId, channel: parsed.channel, platformId: parsed.platformId }); } // 3. Wire agent to messaging group + create destination row for the agent's map let newlyWired = false; const existing = getMessagingGroupAgentByPair(messagingGroup.id, agentGroup.id); if (!existing) { newlyWired = true; const mgaId = generateId('mga'); const triggerRules = parsed.trigger ? JSON.stringify({ pattern: parsed.trigger, requiresTrigger: parsed.requiresTrigger, }) : null; createMessagingGroupAgent({ id: mgaId, messaging_group_id: messagingGroup.id, agent_group_id: agentGroup.id, trigger_rules: triggerRules, response_scope: 'all', session_mode: parsed.sessionMode, priority: parsed.isMain ? 10 : 0, created_at: new Date().toISOString(), }); // Create destination row so the agent can address this channel by name. // Auto-suffix on collision within this agent's namespace. const baseLocalName = normalizeName(parsed.localName || parsed.name); let localName = baseLocalName; let suffix = 2; while (getDestinationByName(agentGroup.id, localName)) { localName = `${baseLocalName}-${suffix}`; suffix++; } createDestination({ agent_group_id: agentGroup.id, local_name: localName, target_type: 'channel', target_id: messagingGroup.id, created_at: new Date().toISOString(), }); log.info('Wired agent to messaging group', { mgaId, agentGroup: agentGroup.id, messagingGroup: messagingGroup.id, localName, }); } // 4. Send onboarding message — only on first wiring, not re-registration if (newlyWired) { const { session } = resolveSession(agentGroup.id, messagingGroup.id, null, parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared'); writeSessionMessage(agentGroup.id, session.id, { id: generateId('onboard'), kind: 'task', timestamp: new Date().toISOString(), platformId: parsed.platformId, channelType: parsed.channel, content: JSON.stringify({ prompt: `A new ${parsed.channel} channel has been connected. Run /welcome to introduce yourself to the user.`, }), }); log.info('Onboarding message written', { sessionId: session.id, channel: parsed.channel }); } // 5. Create group folders fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), { recursive: true }); // Create CLAUDE.md from template if it doesn't exist const groupClaudeMdPath = path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'); if (!fs.existsSync(groupClaudeMdPath)) { const templatePath = parsed.isMain ? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md') : path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, groupClaudeMdPath); log.info('Created CLAUDE.md from template', { file: groupClaudeMdPath, template: templatePath }); } } // 6. Update assistant name in CLAUDE.md files if different from default let nameUpdated = false; if (parsed.assistantName !== 'Andy') { log.info('Updating assistant name', { from: 'Andy', to: parsed.assistantName }); const groupsDir = path.join(projectRoot, 'groups'); const mdFiles = fs .readdirSync(groupsDir) .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) .filter((f) => fs.existsSync(f)); for (const mdFile of mdFiles) { let content = fs.readFileSync(mdFile, 'utf-8'); content = content.replace(/^# Andy$/m, `# ${parsed.assistantName}`); content = content.replace(/You are Andy/g, `You are ${parsed.assistantName}`); fs.writeFileSync(mdFile, content); log.info('Updated CLAUDE.md', { file: mdFile }); } // Update .env const envFile = path.join(projectRoot, '.env'); if (fs.existsSync(envFile)) { let envContent = fs.readFileSync(envFile, 'utf-8'); if (envContent.includes('ASSISTANT_NAME=')) { envContent = envContent.replace(/^ASSISTANT_NAME=.*$/m, `ASSISTANT_NAME="${parsed.assistantName}"`); } else { envContent += `\nASSISTANT_NAME="${parsed.assistantName}"`; } fs.writeFileSync(envFile, envContent); } else { fs.writeFileSync(envFile, `ASSISTANT_NAME="${parsed.assistantName}"\n`); } log.info('Set ASSISTANT_NAME in .env'); nameUpdated = true; } emitStatus('REGISTER_CHANNEL', { PLATFORM_ID: parsed.platformId, NAME: parsed.name, FOLDER: parsed.folder, CHANNEL: parsed.channel, TRIGGER: parsed.trigger, REQUIRES_TRIGGER: parsed.requiresTrigger, ASSISTANT_NAME: parsed.assistantName, SESSION_MODE: parsed.sessionMode, NAME_UPDATED: nameUpdated, STATUS: 'success', LOG: 'logs/setup.log', }); }