Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
import type { QueueHandle } from '@mosaic/queue';
|
|
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 { 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'],
|
|
@Optional()
|
|
@Inject(forwardRef(() => ReloadService))
|
|
private readonly reloadService: ReloadService | null,
|
|
@Optional()
|
|
@Inject(forwardRef(() => ChatGateway))
|
|
private readonly chatGateway: ChatGateway | 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': {
|
|
// User-scoped sweep for non-admin; system-wide for admin
|
|
const result = await this.sessionGC.sweepOrphans(userId);
|
|
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);
|
|
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 '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) {
|
|
return {
|
|
command: 'model',
|
|
conversationId,
|
|
success: true,
|
|
message: 'Usage: /model <model-name>',
|
|
};
|
|
}
|
|
// Update agent session model if session is active
|
|
// For now, acknowledge the request — full wiring done in P8-012
|
|
const session = this.agentService.getSession(conversationId);
|
|
if (!session) {
|
|
return {
|
|
command: 'model',
|
|
conversationId,
|
|
success: true,
|
|
message: `Model switch to "${args}" requested. No active session for this conversation.`,
|
|
};
|
|
}
|
|
return {
|
|
command: 'model',
|
|
conversationId,
|
|
success: true,
|
|
message: `Model switch to "${args}" requested.`,
|
|
};
|
|
}
|
|
|
|
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,
|
|
): Promise<SlashCommandResultPayload> {
|
|
if (!args) {
|
|
return {
|
|
command: 'agent',
|
|
success: true,
|
|
message: 'Usage: /agent <agent-id> to switch, or /agent list to see available agents.',
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
if (args === 'list') {
|
|
return {
|
|
command: 'agent',
|
|
success: true,
|
|
message: 'Agent listing: use the web dashboard for full agent management.',
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
// Switch agent — stub for now (full implementation in P8-015)
|
|
return {
|
|
command: 'agent',
|
|
success: true,
|
|
message: `Agent switch to "${args}" requested. Restart conversation to apply.`,
|
|
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,
|
|
};
|
|
}
|
|
}
|