587 lines
18 KiB
TypeScript
587 lines
18 KiB
TypeScript
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
import type { QueueHandle } from '@mosaic/queue';
|
|
import type { Brain } from '@mosaic/brain';
|
|
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
|
|
import { AgentService } from '../agent/agent.service.js';
|
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
|
import { ReloadService } from '../reload/reload.service.js';
|
|
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
|
import { BRAIN } from '../brain/brain.tokens.js';
|
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
|
import { CommandRegistryService } from './command-registry.service.js';
|
|
|
|
@Injectable()
|
|
export class CommandExecutorService {
|
|
private readonly logger = new Logger(CommandExecutorService.name);
|
|
|
|
constructor(
|
|
@Inject(CommandRegistryService) private readonly registry: CommandRegistryService,
|
|
@Inject(AgentService) private readonly agentService: AgentService,
|
|
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
|
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
|
@Inject(BRAIN) private readonly brain: Brain,
|
|
@Optional()
|
|
@Inject(forwardRef(() => ReloadService))
|
|
private readonly reloadService: ReloadService | null,
|
|
@Optional()
|
|
@Inject(forwardRef(() => ChatGateway))
|
|
private readonly chatGateway: ChatGateway | null,
|
|
@Optional()
|
|
@Inject(McpClientService)
|
|
private readonly mcpClient: McpClientService | null,
|
|
) {}
|
|
|
|
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
|
const { command, args, conversationId } = payload;
|
|
|
|
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
|
if (!def) {
|
|
return {
|
|
command,
|
|
conversationId,
|
|
success: false,
|
|
message: `Unknown command: /${command}`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
switch (command) {
|
|
case 'model':
|
|
return await this.handleModel(args ?? null, conversationId);
|
|
case 'thinking':
|
|
return await this.handleThinking(args ?? null, conversationId);
|
|
case 'system':
|
|
return await this.handleSystem(args ?? null, conversationId);
|
|
case 'new':
|
|
return {
|
|
command,
|
|
conversationId,
|
|
success: true,
|
|
message: 'Start a new conversation by selecting New Conversation.',
|
|
};
|
|
case 'clear':
|
|
return {
|
|
command,
|
|
conversationId,
|
|
success: true,
|
|
message: 'Conversation display cleared.',
|
|
};
|
|
case 'compact':
|
|
return {
|
|
command,
|
|
conversationId,
|
|
success: true,
|
|
message: 'Context compaction requested.',
|
|
};
|
|
case 'retry':
|
|
return {
|
|
command,
|
|
conversationId,
|
|
success: true,
|
|
message: 'Retry last message requested.',
|
|
};
|
|
case 'gc': {
|
|
// Admin-only: system-wide GC sweep across all sessions
|
|
const result = await this.sessionGC.sweepOrphans();
|
|
return {
|
|
command: 'gc',
|
|
success: true,
|
|
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
|
|
conversationId,
|
|
};
|
|
}
|
|
case 'agent':
|
|
return await this.handleAgent(args ?? null, conversationId, userId);
|
|
case 'provider':
|
|
return await this.handleProvider(args ?? null, userId, conversationId);
|
|
case 'mission':
|
|
return await this.handleMission(args ?? null, conversationId, userId);
|
|
case 'prdy':
|
|
return {
|
|
command: 'prdy',
|
|
success: true,
|
|
message:
|
|
'PRD wizard: run `mosaic prdy` in your project workspace to create or update a PRD.',
|
|
conversationId,
|
|
};
|
|
case 'tools':
|
|
return await this.handleTools(conversationId, userId);
|
|
case 'mcp':
|
|
return await this.handleMcp(args ?? null, conversationId);
|
|
case 'reload': {
|
|
if (!this.reloadService) {
|
|
return {
|
|
command: 'reload',
|
|
conversationId,
|
|
success: false,
|
|
message: 'ReloadService is not available.',
|
|
};
|
|
}
|
|
const reloadResult = await this.reloadService.reload('command');
|
|
this.chatGateway?.broadcastReload(reloadResult);
|
|
return {
|
|
command: 'reload',
|
|
success: true,
|
|
message: reloadResult.message,
|
|
conversationId,
|
|
};
|
|
}
|
|
default:
|
|
return {
|
|
command,
|
|
conversationId,
|
|
success: false,
|
|
message: `Command /${command} is not yet implemented.`,
|
|
};
|
|
}
|
|
} catch (err) {
|
|
this.logger.error(`Command /${command} failed: ${err}`);
|
|
return { command, conversationId, success: false, message: String(err) };
|
|
}
|
|
}
|
|
|
|
private async handleModel(
|
|
args: string | null,
|
|
conversationId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
if (!args || args.trim().length === 0) {
|
|
// Show current override or usage hint
|
|
const currentOverride = this.chatGateway?.getModelOverride(conversationId);
|
|
if (currentOverride) {
|
|
return {
|
|
command: 'model',
|
|
conversationId,
|
|
success: true,
|
|
message: `Current model override: "${currentOverride}". Use /model <name> to change or /model clear to reset.`,
|
|
};
|
|
}
|
|
return {
|
|
command: 'model',
|
|
conversationId,
|
|
success: true,
|
|
message:
|
|
'Usage: /model <model-name> — sets a per-session model override (bypasses routing). Use /model clear to reset.',
|
|
};
|
|
}
|
|
|
|
const modelName = args.trim();
|
|
|
|
// /model clear removes the override and re-enables automatic routing
|
|
if (modelName === 'clear') {
|
|
this.chatGateway?.setModelOverride(conversationId, null);
|
|
return {
|
|
command: 'model',
|
|
conversationId,
|
|
success: true,
|
|
message: 'Model override cleared. Automatic routing will be used for new sessions.',
|
|
};
|
|
}
|
|
|
|
// Set the sticky per-session override (M4-007)
|
|
this.chatGateway?.setModelOverride(conversationId, modelName);
|
|
|
|
const session = this.agentService.getSession(conversationId);
|
|
if (!session) {
|
|
return {
|
|
command: 'model',
|
|
conversationId,
|
|
success: true,
|
|
message: `Model override set to "${modelName}". Will apply when a new session starts for this conversation.`,
|
|
};
|
|
}
|
|
return {
|
|
command: 'model',
|
|
conversationId,
|
|
success: true,
|
|
message: `Model override set to "${modelName}". The override is active for this conversation and will be used on the next message if a new session is needed.`,
|
|
};
|
|
}
|
|
|
|
private async handleThinking(
|
|
args: string | null,
|
|
conversationId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
const level = args?.toLowerCase();
|
|
if (!level || !['none', 'low', 'medium', 'high', 'auto'].includes(level)) {
|
|
return {
|
|
command: 'thinking',
|
|
conversationId,
|
|
success: true,
|
|
message: 'Usage: /thinking <none|low|medium|high|auto>',
|
|
};
|
|
}
|
|
return {
|
|
command: 'thinking',
|
|
conversationId,
|
|
success: true,
|
|
message: `Thinking level set to "${level}".`,
|
|
};
|
|
}
|
|
|
|
private async handleSystem(
|
|
args: string | null,
|
|
conversationId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
if (!args || args.trim().length === 0) {
|
|
// Clear the override when called with no args
|
|
await this.systemOverride.clear(conversationId);
|
|
return {
|
|
command: 'system',
|
|
conversationId,
|
|
success: true,
|
|
message: 'Session system prompt override cleared.',
|
|
};
|
|
}
|
|
|
|
await this.systemOverride.set(conversationId, args.trim());
|
|
return {
|
|
command: 'system',
|
|
conversationId,
|
|
success: true,
|
|
message: `Session system prompt override set (expires in 5 minutes of inactivity).`,
|
|
};
|
|
}
|
|
|
|
private async handleAgent(
|
|
args: string | null,
|
|
conversationId: string,
|
|
userId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
if (!args) {
|
|
return {
|
|
command: 'agent',
|
|
success: true,
|
|
message:
|
|
'Usage: /agent <agent-id> | /agent list | /agent new <name> to create a new agent.',
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
if (args === 'list') {
|
|
return {
|
|
command: 'agent',
|
|
success: true,
|
|
message: 'Agent listing: use the web dashboard for full agent management.',
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
// M5-006: /agent new <name> — create a new agent config via brain.agents.create()
|
|
if (args.startsWith('new')) {
|
|
const namePart = args.slice(3).trim();
|
|
if (!namePart) {
|
|
return {
|
|
command: 'agent',
|
|
success: false,
|
|
message: 'Usage: /agent new <name> — provide a name for the new agent.',
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const defaultProvider = process.env['DEFAULT_PROVIDER'] ?? 'anthropic';
|
|
const defaultModel = process.env['DEFAULT_MODEL'] ?? 'claude-sonnet-4-5-20251001';
|
|
|
|
const newAgent = await this.brain.agents.create({
|
|
name: namePart,
|
|
provider: defaultProvider,
|
|
model: defaultModel,
|
|
status: 'idle',
|
|
ownerId: userId,
|
|
isSystem: false,
|
|
});
|
|
|
|
this.logger.log(`Created new agent "${newAgent.name}" (${newAgent.id}) for user ${userId}`);
|
|
|
|
return {
|
|
command: 'agent',
|
|
success: true,
|
|
message: `Agent "${newAgent.name}" created with ID: ${newAgent.id}. Configure it via the web dashboard.`,
|
|
conversationId,
|
|
data: { agentId: newAgent.id, agentName: newAgent.name },
|
|
};
|
|
} catch (err) {
|
|
this.logger.error(`Failed to create agent: ${err}`);
|
|
return {
|
|
command: 'agent',
|
|
success: false,
|
|
message: `Failed to create agent: ${String(err)}`,
|
|
conversationId,
|
|
};
|
|
}
|
|
}
|
|
|
|
// M5-003: Look up agent by name (or ID) and apply to session mid-conversation
|
|
const agentName = args.trim();
|
|
try {
|
|
// Try lookup by name first; fall back to ID-based lookup
|
|
let agentConfig = await this.brain.agents.findByName(agentName);
|
|
if (!agentConfig) {
|
|
// Try by ID (UUID-style input)
|
|
agentConfig = await this.brain.agents.findById(agentName);
|
|
}
|
|
|
|
if (!agentConfig) {
|
|
return {
|
|
command: 'agent',
|
|
success: false,
|
|
message: `Agent "${agentName}" not found. Use /agent list to see available agents.`,
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
// Apply the agent config to the live session and emit session:info (M5-003)
|
|
this.agentService.applyAgentConfig(
|
|
conversationId,
|
|
agentConfig.id,
|
|
agentConfig.name,
|
|
agentConfig.model ?? undefined,
|
|
);
|
|
|
|
// Broadcast updated session:info so TUI TopBar reflects new agent/model
|
|
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name });
|
|
|
|
this.logger.log(
|
|
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
|
|
);
|
|
|
|
return {
|
|
command: 'agent',
|
|
success: true,
|
|
message: `Switched to agent "${agentConfig.name}". System prompt and tools applied. Model: ${agentConfig.model ?? 'default'}.`,
|
|
conversationId,
|
|
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
|
};
|
|
} catch (err) {
|
|
this.logger.error(`Failed to switch agent "${agentName}": ${err}`);
|
|
return {
|
|
command: 'agent',
|
|
success: false,
|
|
message: `Failed to switch agent: ${String(err)}`,
|
|
conversationId,
|
|
};
|
|
}
|
|
}
|
|
|
|
private async handleProvider(
|
|
args: string | null,
|
|
userId: string,
|
|
conversationId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
if (!args) {
|
|
return {
|
|
command: 'provider',
|
|
success: true,
|
|
message: 'Usage: /provider list | /provider login <name> | /provider logout <name>',
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
const spaceIdx = args.indexOf(' ');
|
|
const subcommand = spaceIdx >= 0 ? args.slice(0, spaceIdx) : args;
|
|
const providerName = spaceIdx >= 0 ? args.slice(spaceIdx + 1).trim() : '';
|
|
|
|
switch (subcommand) {
|
|
case 'list':
|
|
return {
|
|
command: 'provider',
|
|
success: true,
|
|
message: 'Use the web dashboard to manage providers.',
|
|
conversationId,
|
|
};
|
|
|
|
case 'login': {
|
|
if (!providerName) {
|
|
return {
|
|
command: 'provider',
|
|
success: false,
|
|
message: 'Usage: /provider login <provider-name>',
|
|
conversationId,
|
|
};
|
|
}
|
|
const pollToken = crypto.randomUUID();
|
|
const key = `mosaic:auth:poll:${pollToken}`;
|
|
// Store pending state in Valkey (TTL 5 minutes)
|
|
await this.redis.set(
|
|
key,
|
|
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
|
'EX',
|
|
300,
|
|
);
|
|
// In production this would construct an OAuth URL
|
|
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
|
|
return {
|
|
command: 'provider',
|
|
success: true,
|
|
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
|
|
conversationId,
|
|
data: { loginUrl, pollToken, provider: providerName },
|
|
};
|
|
}
|
|
|
|
case 'logout': {
|
|
if (!providerName) {
|
|
return {
|
|
command: 'provider',
|
|
success: false,
|
|
message: 'Usage: /provider logout <provider-name>',
|
|
conversationId,
|
|
};
|
|
}
|
|
return {
|
|
command: 'provider',
|
|
success: true,
|
|
message: `Logout from ${providerName}: use the web dashboard to revoke provider tokens.`,
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
default:
|
|
return {
|
|
command: 'provider',
|
|
success: false,
|
|
message: `Unknown subcommand: ${subcommand}. Use list, login, or logout.`,
|
|
conversationId,
|
|
};
|
|
}
|
|
}
|
|
|
|
private async handleMission(
|
|
args: string | null,
|
|
conversationId: string,
|
|
_userId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
if (!args || args === 'status') {
|
|
// TODO: fetch active mission from DB when MissionsService is available
|
|
return {
|
|
command: 'mission',
|
|
success: true,
|
|
message: 'Mission status: use the web dashboard for full mission management.',
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
if (args.startsWith('set ')) {
|
|
const missionId = args.slice(4).trim();
|
|
return {
|
|
command: 'mission',
|
|
success: true,
|
|
message: `Mission set to ${missionId}. Session context updated.`,
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
return {
|
|
command: 'mission',
|
|
success: true,
|
|
message: 'Usage: /mission [status|set <id>|list|tasks]',
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
private async handleTools(
|
|
conversationId: string,
|
|
_userId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
// TODO: fetch tool list from active agent session
|
|
return {
|
|
command: 'tools',
|
|
success: true,
|
|
message:
|
|
'Available tools depend on the active agent configuration. Use the web dashboard to configure tool access.',
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
private async handleMcp(
|
|
args: string | null,
|
|
conversationId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
if (!this.mcpClient) {
|
|
return {
|
|
command: 'mcp',
|
|
conversationId,
|
|
success: false,
|
|
message: 'MCP client service is not available.',
|
|
};
|
|
}
|
|
|
|
const action = args?.trim().split(/\s+/)[0] ?? 'status';
|
|
|
|
switch (action) {
|
|
case 'status':
|
|
case 'servers': {
|
|
const statuses = this.mcpClient.getServerStatuses();
|
|
if (statuses.length === 0) {
|
|
return {
|
|
command: 'mcp',
|
|
conversationId,
|
|
success: true,
|
|
message:
|
|
'No MCP servers configured. Set MCP_SERVERS env var to connect external tool servers.',
|
|
};
|
|
}
|
|
const lines = ['MCP Server Status:\n'];
|
|
for (const s of statuses) {
|
|
const status = s.connected ? '✓ connected' : '✗ disconnected';
|
|
lines.push(` ${s.name}: ${status}`);
|
|
lines.push(` URL: ${s.url}`);
|
|
lines.push(` Tools: ${s.toolCount}`);
|
|
if (s.error) lines.push(` Error: ${s.error}`);
|
|
lines.push('');
|
|
}
|
|
const tools = this.mcpClient.getToolDefinitions();
|
|
if (tools.length > 0) {
|
|
lines.push(`Total bridged tools: ${tools.length}`);
|
|
lines.push(`Tool names: ${tools.map((t) => t.name).join(', ')}`);
|
|
}
|
|
return {
|
|
command: 'mcp',
|
|
conversationId,
|
|
success: true,
|
|
message: lines.join('\n'),
|
|
};
|
|
}
|
|
|
|
case 'reconnect': {
|
|
const serverName = args?.trim().split(/\s+/).slice(1).join(' ');
|
|
if (!serverName) {
|
|
return {
|
|
command: 'mcp',
|
|
conversationId,
|
|
success: false,
|
|
message: 'Usage: /mcp reconnect <server-name>',
|
|
};
|
|
}
|
|
try {
|
|
await this.mcpClient.reconnectServer(serverName);
|
|
return {
|
|
command: 'mcp',
|
|
conversationId,
|
|
success: true,
|
|
message: `MCP server "${serverName}" reconnected successfully.`,
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
command: 'mcp',
|
|
conversationId,
|
|
success: false,
|
|
message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
default:
|
|
return {
|
|
command: 'mcp',
|
|
conversationId,
|
|
success: false,
|
|
message: `Unknown MCP action: "${action}". Use: /mcp status, /mcp servers, /mcp reconnect <name>`,
|
|
};
|
|
}
|
|
}
|
|
}
|