feat(core): add command, intent, and routing primitives
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import type { CommandDefinition, CommandResult } from '../types.js';
|
||||
import type { CommandRegistry } from '../registry.js';
|
||||
|
||||
function notAvailable(label: string): CommandResult {
|
||||
return {
|
||||
handled: true,
|
||||
text: `${label} is not available in this session.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function createHelpCommand(registry: CommandRegistry): CommandDefinition {
|
||||
return {
|
||||
name: 'help',
|
||||
description: 'Show available commands',
|
||||
execute: async () => {
|
||||
const lines = ['Available commands:'];
|
||||
for (const command of registry.list()) {
|
||||
const aliases = command.aliases && command.aliases.length > 0
|
||||
? ` (aliases: ${command.aliases.map(alias => `/${alias}`).join(', ')})`
|
||||
: '';
|
||||
lines.push(`- /${command.name}: ${command.description}${aliases}`);
|
||||
}
|
||||
return {
|
||||
handled: true,
|
||||
text: lines.join('\n'),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createStatusCommand(): CommandDefinition {
|
||||
return {
|
||||
name: 'status',
|
||||
description: 'Show current status',
|
||||
execute: async (_args, ctx) => {
|
||||
if (!ctx.services?.getStatus) {
|
||||
return {
|
||||
handled: true,
|
||||
text: 'Flynn is running.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
text: await ctx.services.getStatus(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createUsageCommand(): CommandDefinition {
|
||||
return {
|
||||
name: 'usage',
|
||||
description: 'Show token usage',
|
||||
execute: async (_args, ctx) => {
|
||||
if (!ctx.services?.getUsage) {
|
||||
return notAvailable('Usage command');
|
||||
}
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
text: await ctx.services.getUsage(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createModelCommand(): CommandDefinition {
|
||||
return {
|
||||
name: 'model',
|
||||
description: 'Show or change model tier',
|
||||
execute: async (args, ctx) => {
|
||||
if (args.length === 0) {
|
||||
if (!ctx.services?.getModel) {
|
||||
return notAvailable('Model command');
|
||||
}
|
||||
return {
|
||||
handled: true,
|
||||
text: await ctx.services.getModel(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!ctx.services?.setModel) {
|
||||
return notAvailable('Model command');
|
||||
}
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
text: await ctx.services.setModel(args[0]),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createCompactCommand(): CommandDefinition {
|
||||
return {
|
||||
name: 'compact',
|
||||
description: 'Compact conversation context',
|
||||
execute: async (_args, ctx) => {
|
||||
if (!ctx.services?.compact) {
|
||||
return notAvailable('Compact command');
|
||||
}
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
text: await ctx.services.compact(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createResetCommand(): CommandDefinition {
|
||||
return {
|
||||
name: 'reset',
|
||||
description: 'Reset current session',
|
||||
execute: async (_args, ctx) => {
|
||||
if (!ctx.services?.reset) {
|
||||
return notAvailable('Reset command');
|
||||
}
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
text: await ctx.services.reset(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerBuiltinCommands(registry: CommandRegistry): void {
|
||||
registry.register(createHelpCommand(registry));
|
||||
registry.register(createStatusCommand());
|
||||
registry.register(createUsageCommand());
|
||||
registry.register(createModelCommand());
|
||||
registry.register(createCompactCommand());
|
||||
registry.register(createResetCommand());
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { CommandRegistry } from './registry.js';
|
||||
export type { CommandContext, CommandDefinition, CommandResult, CommandServices } from './types.js';
|
||||
export {
|
||||
createHelpCommand,
|
||||
createStatusCommand,
|
||||
createUsageCommand,
|
||||
createModelCommand,
|
||||
createCompactCommand,
|
||||
createResetCommand,
|
||||
registerBuiltinCommands,
|
||||
} from './builtin/index.js';
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CommandRegistry } from './registry.js';
|
||||
|
||||
describe('CommandRegistry', () => {
|
||||
it('registers commands and retrieves by name/alias', () => {
|
||||
const registry = new CommandRegistry();
|
||||
registry.register({
|
||||
name: 'help',
|
||||
aliases: ['h'],
|
||||
description: 'show help',
|
||||
execute: async () => ({ handled: true, text: 'ok' }),
|
||||
});
|
||||
|
||||
expect(registry.get('help')?.name).toBe('help');
|
||||
expect(registry.get('/help')?.name).toBe('help');
|
||||
expect(registry.get('h')?.name).toBe('help');
|
||||
expect(registry.get('/h')?.name).toBe('help');
|
||||
expect(registry.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('parses slash command input', () => {
|
||||
const registry = new CommandRegistry();
|
||||
|
||||
expect(registry.isCommand('/help')).toBe(true);
|
||||
expect(registry.parse('/model fast')).toEqual({
|
||||
name: 'model',
|
||||
args: ['fast'],
|
||||
});
|
||||
expect(registry.parse('hello')).toBeNull();
|
||||
expect(registry.parse('/')).toBeNull();
|
||||
});
|
||||
|
||||
it('executes known command and returns handled result', async () => {
|
||||
const registry = new CommandRegistry();
|
||||
registry.register({
|
||||
name: 'status',
|
||||
description: 'show status',
|
||||
execute: async (_args, ctx) => ({ handled: true, text: `ok:${ctx.channel}` }),
|
||||
});
|
||||
|
||||
const result = await registry.execute('/status', {
|
||||
channel: 'telegram',
|
||||
senderId: 'u1',
|
||||
sessionId: 'telegram:u1',
|
||||
rawInput: '/status',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ handled: true, text: 'ok:telegram' });
|
||||
});
|
||||
|
||||
it('returns handled=false for unknown commands', async () => {
|
||||
const registry = new CommandRegistry();
|
||||
|
||||
const result = await registry.execute('/unknown', {
|
||||
channel: 'telegram',
|
||||
senderId: 'u1',
|
||||
sessionId: 'telegram:u1',
|
||||
rawInput: '/unknown',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ handled: false, text: '' });
|
||||
});
|
||||
|
||||
it('catches handler errors and returns safe message', async () => {
|
||||
const registry = new CommandRegistry();
|
||||
registry.register({
|
||||
name: 'boom',
|
||||
description: 'throws',
|
||||
execute: async () => {
|
||||
throw new Error('bad things');
|
||||
},
|
||||
});
|
||||
|
||||
const result = await registry.execute('/boom', {
|
||||
channel: 'telegram',
|
||||
senderId: 'u1',
|
||||
sessionId: 'telegram:u1',
|
||||
rawInput: '/boom',
|
||||
});
|
||||
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.text).toContain('Command failed: bad things');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { CommandContext, CommandDefinition, CommandResult } from './types.js';
|
||||
|
||||
const MAX_INPUT_LENGTH = 2000;
|
||||
|
||||
export class CommandRegistry {
|
||||
private commands = new Map<string, CommandDefinition>();
|
||||
private aliasToCommand = new Map<string, string>();
|
||||
|
||||
register(def: CommandDefinition): void {
|
||||
const canonicalName = this.normalizeName(def.name);
|
||||
if (!canonicalName) {
|
||||
throw new Error('Command name is required');
|
||||
}
|
||||
if (this.commands.has(canonicalName)) {
|
||||
throw new Error(`Command already registered: ${canonicalName}`);
|
||||
}
|
||||
|
||||
this.commands.set(canonicalName, { ...def, name: canonicalName });
|
||||
|
||||
for (const alias of def.aliases ?? []) {
|
||||
const canonicalAlias = this.normalizeName(alias);
|
||||
if (!canonicalAlias) {
|
||||
continue;
|
||||
}
|
||||
if (canonicalAlias === canonicalName || this.aliasToCommand.has(canonicalAlias) || this.commands.has(canonicalAlias)) {
|
||||
throw new Error(`Command alias already registered: ${canonicalAlias}`);
|
||||
}
|
||||
this.aliasToCommand.set(canonicalAlias, canonicalName);
|
||||
}
|
||||
}
|
||||
|
||||
get(nameOrAlias: string): CommandDefinition | undefined {
|
||||
const normalized = this.normalizeName(nameOrAlias);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const canonicalName = this.aliasToCommand.get(normalized) ?? normalized;
|
||||
return this.commands.get(canonicalName);
|
||||
}
|
||||
|
||||
list(): CommandDefinition[] {
|
||||
return Array.from(this.commands.values());
|
||||
}
|
||||
|
||||
isCommand(input: string): boolean {
|
||||
return this.parse(input) !== null;
|
||||
}
|
||||
|
||||
parse(input: string): { name: string; args: string[] } | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed.startsWith('/') || trimmed.length > MAX_INPUT_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const withoutSlash = trimmed.slice(1).trim();
|
||||
if (!withoutSlash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [rawName, ...rest] = withoutSlash.split(/\s+/);
|
||||
const name = this.normalizeName(rawName);
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
args: rest,
|
||||
};
|
||||
}
|
||||
|
||||
async execute(input: string, ctx: CommandContext): Promise<CommandResult> {
|
||||
const parsed = this.parse(input);
|
||||
if (!parsed) {
|
||||
return { handled: false, text: '' };
|
||||
}
|
||||
|
||||
const command = this.get(parsed.name);
|
||||
if (!command) {
|
||||
return { handled: false, text: '' };
|
||||
}
|
||||
|
||||
try {
|
||||
return await command.execute(parsed.args, ctx);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown command error';
|
||||
return {
|
||||
handled: true,
|
||||
text: `Command failed: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeName(value: string): string {
|
||||
return value.trim().replace(/^\//, '').toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface CommandContext {
|
||||
channel: string;
|
||||
senderId: string;
|
||||
sessionId: string;
|
||||
rawInput: string;
|
||||
services?: CommandServices;
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
handled: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface CommandDefinition {
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
description: string;
|
||||
execute: (args: string[], ctx: CommandContext) => Promise<CommandResult>;
|
||||
}
|
||||
|
||||
export interface CommandServices {
|
||||
getStatus?: () => Promise<string> | string;
|
||||
getUsage?: () => Promise<string> | string;
|
||||
getModel?: () => Promise<string> | string;
|
||||
setModel?: (tier: string) => Promise<string> | string;
|
||||
compact?: () => Promise<string> | string;
|
||||
reset?: () => Promise<string> | string;
|
||||
}
|
||||
Reference in New Issue
Block a user