feat(core): add command, intent, and routing primitives

This commit is contained in:
William Valentin
2026-02-12 22:47:22 -08:00
parent 7ae0fb51c2
commit 6e8984f788
25 changed files with 1469 additions and 0 deletions
+136
View File
@@ -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());
}
+11
View File
@@ -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';
+84
View File
@@ -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');
});
});
+97
View File
@@ -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();
}
}
+28
View File
@@ -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;
}